@harness.farm/social-cli 0.1.0 → 0.1.2
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 +213 -0
- package/dist/adapters/base.js +2 -0
- package/dist/adapters/index.js +8 -0
- package/dist/adapters/xiaohongshu.js +314 -0
- package/dist/browser/cdp.js +106 -0
- package/dist/browser/runner.js +75 -0
- package/dist/browser/session.js +38 -0
- package/dist/cli.js +99 -0
- package/dist/output/table.js +43 -0
- package/dist/runner/step-executor.js +142 -0
- package/dist/runner/yaml-runner.js +368 -0
- package/dist/scripts/explore-bili.js +37 -0
- package/dist/scripts/explore-douyin.js +30 -0
- package/dist/scripts/explore-x.js +31 -0
- package/package.json +2 -1
package/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# social-cli
|
|
2
|
+
|
|
3
|
+
基于 Chrome DevTools Protocol (CDP) 的社交媒体自动化 CLI,支持 X(推特)、小红书、抖音、B站,无需安装浏览器插件或 Playwright,直接连接你已有的 Chrome。
|
|
4
|
+
|
|
5
|
+
## 工作原理
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Chrome (--remote-debugging-port=9222)
|
|
9
|
+
↕ WebSocket / CDP
|
|
10
|
+
social-cli
|
|
11
|
+
├── adapters/<platform>.yaml ← YAML 适配器(优先)
|
|
12
|
+
└── src/adapters/<platform>.ts ← TypeScript 适配器(兜底)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- **YAML 适配器**:声明式步骤描述,易于阅读和修改,无需编译
|
|
16
|
+
- **TypeScript 适配器**:适合复杂逻辑,编译后运行
|
|
17
|
+
- **登录状态管理**:首次运行时引导登录,自动保存 session cookie,后续免登录
|
|
18
|
+
|
|
19
|
+
## 快速开始
|
|
20
|
+
|
|
21
|
+
### 1. 启动 Chrome(开启远程调试)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# macOS
|
|
25
|
+
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
|
26
|
+
--remote-debugging-port=9222 \
|
|
27
|
+
--user-data-dir=$HOME/.cdp-scraper/chrome-profile
|
|
28
|
+
|
|
29
|
+
# Windows
|
|
30
|
+
chrome.exe --remote-debugging-port=9222 --user-data-dir=%USERPROFILE%\.cdp-scraper\chrome-profile
|
|
31
|
+
|
|
32
|
+
# Linux
|
|
33
|
+
google-chrome --remote-debugging-port=9222 --user-data-dir=~/.cdp-scraper/chrome-profile
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 2. 安装依赖
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 3. 运行命令
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# 开发模式(tsx,无需编译)
|
|
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"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 支持的平台和命令
|
|
54
|
+
|
|
55
|
+
### X(推特)
|
|
56
|
+
|
|
57
|
+
| 命令 | 参数 | 说明 |
|
|
58
|
+
|------|------|------|
|
|
59
|
+
| `search` | `keyword` | 搜索推文 |
|
|
60
|
+
| `like` | `url` | 点赞 / 取消点赞 |
|
|
61
|
+
| `reply` | `url text` | 回复推文 |
|
|
62
|
+
| `post` | `text` | 发推 |
|
|
63
|
+
| `retweet` | `url` | 转推 |
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
tsx src/cli.ts x search "claude ai"
|
|
67
|
+
tsx src/cli.ts x like "https://x.com/user/status/123"
|
|
68
|
+
tsx src/cli.ts x reply "https://x.com/user/status/123" "很有意思!"
|
|
69
|
+
tsx src/cli.ts x post "Hello from social-cli!"
|
|
70
|
+
tsx src/cli.ts x retweet "https://x.com/user/status/123"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 小红书(xhs / xiaohongshu)
|
|
74
|
+
|
|
75
|
+
| 命令 | 参数 | 说明 |
|
|
76
|
+
|------|------|------|
|
|
77
|
+
| `search` | `keyword` | 搜索笔记 |
|
|
78
|
+
| `hot` | — | 获取首页热门笔记 |
|
|
79
|
+
| `like` | `url` | 点赞 / 取消点赞 |
|
|
80
|
+
| `comment` | `url text` | 发表评论 |
|
|
81
|
+
| `post` | `title content` | 发布图文笔记 |
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
tsx src/cli.ts xhs search 法律ai
|
|
85
|
+
tsx src/cli.ts xhs hot
|
|
86
|
+
tsx src/cli.ts xhs like "https://www.xiaohongshu.com/explore/..."
|
|
87
|
+
tsx src/cli.ts xhs comment "https://..." "太棒了!"
|
|
88
|
+
tsx src/cli.ts xhs post --title "我的标题" --content "正文内容"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 抖音(douyin)
|
|
92
|
+
|
|
93
|
+
| 命令 | 参数 | 说明 |
|
|
94
|
+
|------|------|------|
|
|
95
|
+
| `search` | `keyword` | 搜索视频 |
|
|
96
|
+
| `like` | `url` | 点赞(Z 键) |
|
|
97
|
+
| `comment` | `url text` | 发表评论 |
|
|
98
|
+
| `follow` | `url` | 关注用户 |
|
|
99
|
+
| `post` | `video title desc` | 发布视频 |
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
tsx src/cli.ts douyin search 猫咪
|
|
103
|
+
tsx src/cli.ts douyin like "https://www.douyin.com/video/..."
|
|
104
|
+
tsx src/cli.ts douyin comment "https://..." "哈哈哈"
|
|
105
|
+
tsx src/cli.ts douyin post /path/to/video.mp4 "标题" "描述"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### B站(bilibili)
|
|
109
|
+
|
|
110
|
+
| 命令 | 参数 | 说明 |
|
|
111
|
+
|------|------|------|
|
|
112
|
+
| `search` | `keyword` | 搜索视频 |
|
|
113
|
+
| `like` | `url` | 点赞视频 |
|
|
114
|
+
| `follow` | `url` | 关注 UP 主 |
|
|
115
|
+
| `comment` | `url text` | 发表评论 |
|
|
116
|
+
| `reply` | `url text` | 回复第一条评论 |
|
|
117
|
+
| `post` | `video title desc` | 投稿视频 |
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
tsx src/cli.ts bilibili search TypeScript教程
|
|
121
|
+
tsx src/cli.ts bilibili like "https://www.bilibili.com/video/BV..."
|
|
122
|
+
tsx src/cli.ts bilibili comment "https://..." "讲得很好!"
|
|
123
|
+
tsx src/cli.ts bilibili post /path/to/video.mp4 "视频标题" "视频描述"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## YAML 适配器格式
|
|
127
|
+
|
|
128
|
+
YAML 适配器位于 `adapters/` 目录,无需编译即可生效,支持热修改。
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
platform: myplatform
|
|
132
|
+
login_url: https://example.com
|
|
133
|
+
login_check:
|
|
134
|
+
cookie: session_cookie_name # 用于判断是否已登录
|
|
135
|
+
|
|
136
|
+
commands:
|
|
137
|
+
search:
|
|
138
|
+
args: [keyword] # 位置参数名
|
|
139
|
+
steps:
|
|
140
|
+
- open: "https://example.com/search?q={{keyword}}"
|
|
141
|
+
- wait: 3000
|
|
142
|
+
- extract:
|
|
143
|
+
selector: ".item"
|
|
144
|
+
fields:
|
|
145
|
+
title: ".title"
|
|
146
|
+
link:
|
|
147
|
+
selector: "a"
|
|
148
|
+
attr: href
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 支持的 Step 类型
|
|
152
|
+
|
|
153
|
+
| Step | 说明 |
|
|
154
|
+
|------|------|
|
|
155
|
+
| `open: url` | 导航到 URL |
|
|
156
|
+
| `wait: ms` | 等待毫秒数 |
|
|
157
|
+
| `wait: { selector }` | 等待元素出现 |
|
|
158
|
+
| `click: selector` | 点击元素 |
|
|
159
|
+
| `click: { text: "..." }` | 按文本内容点击 |
|
|
160
|
+
| `fill: { selector, value }` | 填写 input 表单 |
|
|
161
|
+
| `type_rich: { selector, value }` | 输入到 contenteditable 元素 |
|
|
162
|
+
| `eval: "js"` | 执行 JavaScript |
|
|
163
|
+
| `capture: { name, eval }` | 执行 JS 并保存结果到变量 |
|
|
164
|
+
| `extract: { selector, fields }` | 批量抓取列表数据 |
|
|
165
|
+
| `return: [{ field, value }]` | 输出结果表格 |
|
|
166
|
+
| `upload: { selector, file }` | 上传本地文件 |
|
|
167
|
+
| `screenshot: path` | 截图保存 |
|
|
168
|
+
| `key: "key"` | 模拟按键(如 `Enter`, `z`, `x`) |
|
|
169
|
+
| `keyboard_insert: text` | 逐字符发送(适用于 Draft.js / React 输入框) |
|
|
170
|
+
| `insert_text: text` | CDP 直接插入文本(可穿透 shadow DOM) |
|
|
171
|
+
| `assert: { eval, message }` | 断言,失败则报错 |
|
|
172
|
+
|
|
173
|
+
模板变量使用 `{{varName}}` 语法,也支持 JS 表达式:`{{a === 'true' ? '是' : '否'}}`
|
|
174
|
+
|
|
175
|
+
## 编译发布
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
npm run build # 编译 TypeScript → dist/
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
编译后可作为 `social-cli` 命令使用(需全局安装或 `npx`)。
|
|
182
|
+
|
|
183
|
+
## 项目结构
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
cdp-scraper/
|
|
187
|
+
├── adapters/ # YAML 适配器(运行时读取,优先生效)
|
|
188
|
+
│ ├── x.yaml
|
|
189
|
+
│ ├── xhs.yaml
|
|
190
|
+
│ ├── xiaohongshu.yaml
|
|
191
|
+
│ ├── douyin.yaml
|
|
192
|
+
│ └── bilibili.yaml
|
|
193
|
+
├── src/
|
|
194
|
+
│ ├── cli.ts # CLI 入口,适配器解析与路由
|
|
195
|
+
│ ├── runner/
|
|
196
|
+
│ │ ├── yaml-runner.ts # YAML 步骤执行引擎
|
|
197
|
+
│ │ └── step-executor.ts
|
|
198
|
+
│ ├── browser/
|
|
199
|
+
│ │ ├── cdp.ts # CDP WebSocket 客户端
|
|
200
|
+
│ │ ├── session.ts # Cookie session 持久化
|
|
201
|
+
│ │ └── runner.ts # TS 适配器运行器
|
|
202
|
+
│ ├── adapters/ # TypeScript 适配器(兜底)
|
|
203
|
+
│ │ ├── base.ts
|
|
204
|
+
│ │ ├── xiaohongshu.ts
|
|
205
|
+
│ │ └── index.ts
|
|
206
|
+
│ └── output/
|
|
207
|
+
│ └── table.ts # 终端表格渲染
|
|
208
|
+
└── package.json
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { XiaohongshuAdapter } from './xiaohongshu.js';
|
|
2
|
+
export const adapters = {
|
|
3
|
+
xhs: new XiaohongshuAdapter(),
|
|
4
|
+
xiaohongshu: new XiaohongshuAdapter(),
|
|
5
|
+
// twitter: new TwitterAdapter(),
|
|
6
|
+
// bilibili: new BilibiliAdapter(),
|
|
7
|
+
// zhihu: new ZhihuAdapter(),
|
|
8
|
+
};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 小红书 adapter
|
|
3
|
+
*
|
|
4
|
+
* Commands:
|
|
5
|
+
* search <keyword> 搜索笔记
|
|
6
|
+
* hot 首页推荐
|
|
7
|
+
* post --title <t> --content <c> [--images <p1,p2>] 发布图文
|
|
8
|
+
*/
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import { Adapter } from './base.js';
|
|
12
|
+
import { sleep } from '../browser/cdp.js';
|
|
13
|
+
// ─── selectors ────────────────────────────────────────────────────────────────
|
|
14
|
+
const SEL = {
|
|
15
|
+
// 搜索 / 首页
|
|
16
|
+
noteItem: '.note-item',
|
|
17
|
+
noteTitle: '.title, [class*="title"]',
|
|
18
|
+
noteAuthor: '.author .name, .nickname, [class*="author"] span',
|
|
19
|
+
noteLikes: '[class*="like"] span, .like-wrapper [class*="count"]',
|
|
20
|
+
// 发布
|
|
21
|
+
postFileInput: 'input[type=file][accept*=jpg]',
|
|
22
|
+
postTitle: 'input.d-text[placeholder*="标题"]',
|
|
23
|
+
postContent: '.tiptap.ProseMirror',
|
|
24
|
+
postSubmit: 'button:last-of-type',
|
|
25
|
+
// 笔记详情页互动
|
|
26
|
+
likeBtn: '.like-wrapper',
|
|
27
|
+
collectBtn: '.collect-wrapper',
|
|
28
|
+
commentArea: '.comment-container, .comments-container',
|
|
29
|
+
commentInput: '.content-input', // contenteditable
|
|
30
|
+
commentSubmit: '.btn-send',
|
|
31
|
+
commentPlaceholder: '说点什么...',
|
|
32
|
+
};
|
|
33
|
+
function parseNotes(raw) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const NOTE_COLUMNS = [
|
|
42
|
+
{ key: 'index', header: '#', width: 3 },
|
|
43
|
+
{ key: 'title', header: '标题', width: 36 },
|
|
44
|
+
{ key: 'author', header: '作者', width: 16 },
|
|
45
|
+
{ key: 'likes', header: '点赞', width: 8 },
|
|
46
|
+
{ key: 'link', header: '链接', width: 48 },
|
|
47
|
+
];
|
|
48
|
+
const SCRAPE_NOTES = `
|
|
49
|
+
(function() {
|
|
50
|
+
const r = [];
|
|
51
|
+
document.querySelectorAll('${SEL.noteItem}').forEach(el => {
|
|
52
|
+
const title = el.querySelector('${SEL.noteTitle}')?.textContent?.trim() ?? '';
|
|
53
|
+
const author = el.querySelector('${SEL.noteAuthor}')?.textContent?.trim() ?? '';
|
|
54
|
+
const likes = el.querySelector('${SEL.noteLikes}')?.textContent?.trim() ?? '';
|
|
55
|
+
const href = el.querySelector('a')?.href ?? '';
|
|
56
|
+
if (title) r.push({ title, author, likes, link: href.split('?')[0] });
|
|
57
|
+
});
|
|
58
|
+
return JSON.stringify(r);
|
|
59
|
+
})()`;
|
|
60
|
+
// ─── post helpers ─────────────────────────────────────────────────────────────
|
|
61
|
+
function parsePostArgs(args) {
|
|
62
|
+
let title = '', content = '', images = [];
|
|
63
|
+
for (let i = 0; i < args.length; i++) {
|
|
64
|
+
if (args[i] === '--title') {
|
|
65
|
+
title = args[++i] ?? '';
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (args[i] === '--content') {
|
|
69
|
+
content = args[++i] ?? '';
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (args[i] === '--images') {
|
|
73
|
+
images = (args[++i] ?? '').split(',').map(p => path.resolve(p)).filter(p => fs.existsSync(p));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { title, content, images };
|
|
78
|
+
}
|
|
79
|
+
// ─── Adapter ──────────────────────────────────────────────────────────────────
|
|
80
|
+
export class XiaohongshuAdapter extends Adapter {
|
|
81
|
+
platform = 'xiaohongshu';
|
|
82
|
+
loginUrl = 'https://www.xiaohongshu.com';
|
|
83
|
+
async isLoggedIn(client) {
|
|
84
|
+
const cookies = await client.getAllCookies();
|
|
85
|
+
return cookies.some(c => c.name === 'web_session');
|
|
86
|
+
}
|
|
87
|
+
commands = {
|
|
88
|
+
// ── search ────────────────────────────────────────────────────────────────
|
|
89
|
+
search: async (client, args) => {
|
|
90
|
+
const keyword = args[0];
|
|
91
|
+
if (!keyword)
|
|
92
|
+
throw new Error('用法: xhs search <关键词>');
|
|
93
|
+
const url = `https://www.xiaohongshu.com/search_result?keyword=${encodeURIComponent(keyword)}&source=web_explore_feed`;
|
|
94
|
+
await client.navigate(url, 4000);
|
|
95
|
+
const raw = await client.eval(SCRAPE_NOTES);
|
|
96
|
+
const notes = parseNotes(raw);
|
|
97
|
+
return { columns: NOTE_COLUMNS, rows: notes.map((n, i) => ({ index: i + 1, ...n })) };
|
|
98
|
+
},
|
|
99
|
+
// ── hot ───────────────────────────────────────────────────────────────────
|
|
100
|
+
hot: async (client) => {
|
|
101
|
+
await client.navigate('https://www.xiaohongshu.com/explore', 4000);
|
|
102
|
+
const raw = await client.eval(SCRAPE_NOTES);
|
|
103
|
+
const notes = parseNotes(raw);
|
|
104
|
+
return { columns: NOTE_COLUMNS, rows: notes.map((n, i) => ({ index: i + 1, ...n })) };
|
|
105
|
+
},
|
|
106
|
+
// ── like ──────────────────────────────────────────────────────────────────
|
|
107
|
+
like: async (client, args) => {
|
|
108
|
+
const url = args[0];
|
|
109
|
+
if (!url)
|
|
110
|
+
throw new Error('用法: xhs like <笔记URL>');
|
|
111
|
+
await client.navigate(url, 4000);
|
|
112
|
+
const result = await client.eval(`
|
|
113
|
+
(function() {
|
|
114
|
+
var btn = document.querySelector('.like-wrapper');
|
|
115
|
+
if (!btn) return JSON.stringify({ok: false, reason: 'like button not found'});
|
|
116
|
+
var wasLiked = btn.classList.contains('like-active');
|
|
117
|
+
btn.click();
|
|
118
|
+
var isLiked = btn.classList.contains('like-active');
|
|
119
|
+
var count = btn.querySelector('.count') ? btn.querySelector('.count').textContent.trim() : '?';
|
|
120
|
+
return JSON.stringify({ok: true, wasLiked: wasLiked, isLiked: isLiked, count: count});
|
|
121
|
+
})()
|
|
122
|
+
`);
|
|
123
|
+
const r = JSON.parse(result);
|
|
124
|
+
if (!r.ok)
|
|
125
|
+
throw new Error(r.reason ?? '点赞失败');
|
|
126
|
+
const action = r.wasLiked ? '取消点赞' : '点赞成功';
|
|
127
|
+
const status = r.isLiked ? '❤️ 已点赞' : '🤍 未点赞';
|
|
128
|
+
return {
|
|
129
|
+
columns: [
|
|
130
|
+
{ key: 'field', header: '字段', width: 12 },
|
|
131
|
+
{ key: 'value', header: '值', width: 40 },
|
|
132
|
+
],
|
|
133
|
+
rows: [
|
|
134
|
+
{ field: '操作', value: action },
|
|
135
|
+
{ field: '状态', value: status },
|
|
136
|
+
{ field: '点赞数', value: r.count },
|
|
137
|
+
{ field: '链接', value: url },
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
// ── comment ───────────────────────────────────────────────────────────────
|
|
142
|
+
comment: async (client, args) => {
|
|
143
|
+
const url = args[0];
|
|
144
|
+
const text = args[1];
|
|
145
|
+
if (!url || !text)
|
|
146
|
+
throw new Error('用法: xhs comment <笔记URL> <评论内容>');
|
|
147
|
+
await client.navigate(url, 4000);
|
|
148
|
+
// 1. 点击「说点什么...」激活输入框
|
|
149
|
+
await client.eval(`
|
|
150
|
+
(function() {
|
|
151
|
+
var placeholder = [...document.querySelectorAll('*')].find(function(e) {
|
|
152
|
+
return e.textContent.trim() === '说点什么...' && e.children.length === 0;
|
|
153
|
+
});
|
|
154
|
+
if (placeholder) placeholder.click();
|
|
155
|
+
})()
|
|
156
|
+
`);
|
|
157
|
+
await sleep(800);
|
|
158
|
+
// 2. 写入评论内容(用 execCommand 兼容 contenteditable)
|
|
159
|
+
await client.eval(`
|
|
160
|
+
(function() {
|
|
161
|
+
var editor = document.querySelector('.content-input');
|
|
162
|
+
if (!editor) return;
|
|
163
|
+
editor.focus();
|
|
164
|
+
document.execCommand('selectAll', false, null);
|
|
165
|
+
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
|
166
|
+
})()
|
|
167
|
+
`);
|
|
168
|
+
await sleep(500);
|
|
169
|
+
// 3. 验证内容已写入
|
|
170
|
+
const inputText = await client.eval(`
|
|
171
|
+
(document.querySelector('.content-input') || {}).textContent || ''
|
|
172
|
+
`);
|
|
173
|
+
console.log(` ✏️ 输入内容: "${inputText}"`);
|
|
174
|
+
// 4. 点击发送
|
|
175
|
+
const sent = await client.eval(`
|
|
176
|
+
(function() {
|
|
177
|
+
var btn = document.querySelector('.btn-send');
|
|
178
|
+
if (!btn) {
|
|
179
|
+
var btns = [...document.querySelectorAll('button')];
|
|
180
|
+
btn = btns.find(function(b) { return b.textContent.trim() === '发送'; });
|
|
181
|
+
}
|
|
182
|
+
if (btn && !btn.disabled) { btn.click(); return true; }
|
|
183
|
+
return false;
|
|
184
|
+
})()
|
|
185
|
+
`);
|
|
186
|
+
if (!sent)
|
|
187
|
+
throw new Error('发送按钮未找到或被禁用(评论内容可能为空)');
|
|
188
|
+
await sleep(1500);
|
|
189
|
+
// 5. 验证:新评论是否出现在列表里
|
|
190
|
+
const appeared = await client.eval(`
|
|
191
|
+
(function() {
|
|
192
|
+
var comments = [...document.querySelectorAll('.comment-item, [class*=comment-item]')];
|
|
193
|
+
return comments.some(function(c) { return c.textContent.includes(${JSON.stringify(text.slice(0, 10))}); });
|
|
194
|
+
})()
|
|
195
|
+
`);
|
|
196
|
+
return {
|
|
197
|
+
columns: [
|
|
198
|
+
{ key: 'field', header: '字段', width: 12 },
|
|
199
|
+
{ key: 'value', header: '值', width: 50 },
|
|
200
|
+
],
|
|
201
|
+
rows: [
|
|
202
|
+
{ field: '状态', value: appeared ? '✅ 评论成功' : '⚠️ 请在浏览器中确认' },
|
|
203
|
+
{ field: '评论内容', value: text },
|
|
204
|
+
{ field: '链接', value: url },
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
// ── post ──────────────────────────────────────────────────────────────────
|
|
209
|
+
post: async (client, args) => {
|
|
210
|
+
const { title, content, images } = parsePostArgs(args);
|
|
211
|
+
if (!title && !content)
|
|
212
|
+
throw new Error('用法: xhs post --title <标题> --content <内容> [--images <图片路径>]');
|
|
213
|
+
// 1. 导航到发布页
|
|
214
|
+
await client.navigate('https://creator.xiaohongshu.com/publish/publish?source=official', 3000);
|
|
215
|
+
// 2. 切换到「上传图文」tab
|
|
216
|
+
await client.eval(`
|
|
217
|
+
(function() {
|
|
218
|
+
const els = [...document.querySelectorAll('*')].filter(e =>
|
|
219
|
+
e.textContent.trim() === '上传图文' && e.children.length === 0
|
|
220
|
+
);
|
|
221
|
+
if (els.length >= 2) els[1].click();
|
|
222
|
+
else if (els.length === 1) els[0].click();
|
|
223
|
+
})()
|
|
224
|
+
`);
|
|
225
|
+
await sleep(1500);
|
|
226
|
+
// 3. 上传图片(如果有)
|
|
227
|
+
if (images.length > 0) {
|
|
228
|
+
await client.uploadFiles('input[type=file][accept*=jpg]', images);
|
|
229
|
+
console.log(` 📎 上传图片: ${images.join(', ')}`);
|
|
230
|
+
await sleep(4000); // 等待图片上传完成
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
// 没有图片时,小红书要求至少一张图,用「文字配图」模式
|
|
234
|
+
await client.eval(`
|
|
235
|
+
(function() {
|
|
236
|
+
const el = [...document.querySelectorAll('*')].find(e =>
|
|
237
|
+
e.textContent.trim() === '文字配图' && e.children.length === 0
|
|
238
|
+
);
|
|
239
|
+
if (el) el.click();
|
|
240
|
+
})()
|
|
241
|
+
`);
|
|
242
|
+
await sleep(2000);
|
|
243
|
+
}
|
|
244
|
+
// 4. 填写标题
|
|
245
|
+
if (title) {
|
|
246
|
+
await client.eval(`
|
|
247
|
+
(function() {
|
|
248
|
+
const input = document.querySelector('input.d-text[placeholder*="标题"]');
|
|
249
|
+
if (!input) return;
|
|
250
|
+
input.focus();
|
|
251
|
+
const nativeInput = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
|
|
252
|
+
nativeInput.set.call(input, ${JSON.stringify(title)});
|
|
253
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
254
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
255
|
+
})()
|
|
256
|
+
`);
|
|
257
|
+
await sleep(500);
|
|
258
|
+
console.log(` ✏️ 标题: ${title}`);
|
|
259
|
+
}
|
|
260
|
+
// 5. 填写正文
|
|
261
|
+
if (content) {
|
|
262
|
+
await client.eval(`
|
|
263
|
+
(function() {
|
|
264
|
+
const editor = document.querySelector('.tiptap.ProseMirror');
|
|
265
|
+
if (!editor) return;
|
|
266
|
+
editor.focus();
|
|
267
|
+
// 清空并输入内容
|
|
268
|
+
document.execCommand('selectAll', false, null);
|
|
269
|
+
document.execCommand('insertText', false, ${JSON.stringify(content)});
|
|
270
|
+
})()
|
|
271
|
+
`);
|
|
272
|
+
await sleep(500);
|
|
273
|
+
console.log(` 📝 正文: ${content.slice(0, 50)}${content.length > 50 ? '...' : ''}`);
|
|
274
|
+
}
|
|
275
|
+
await sleep(1000);
|
|
276
|
+
// 6. 点击发布
|
|
277
|
+
const clicked = await client.eval(`
|
|
278
|
+
(function() {
|
|
279
|
+
const btns = [...document.querySelectorAll('button')];
|
|
280
|
+
const btn = btns.reverse().find(b => b.textContent.trim() === '发布');
|
|
281
|
+
if (btn && !btn.disabled) { btn.click(); return true; }
|
|
282
|
+
return false;
|
|
283
|
+
})()
|
|
284
|
+
`);
|
|
285
|
+
if (!clicked)
|
|
286
|
+
throw new Error('未找到发布按钮,或按钮不可点击(可能图片还在上传中)');
|
|
287
|
+
await sleep(2000);
|
|
288
|
+
// 7. 检查是否发布成功
|
|
289
|
+
const result = await client.eval(`
|
|
290
|
+
JSON.stringify({
|
|
291
|
+
url: location.href,
|
|
292
|
+
toast: document.querySelector('[class*=toast],[class*=message],[class*=notice]')?.textContent?.trim() ?? ''
|
|
293
|
+
})
|
|
294
|
+
`);
|
|
295
|
+
const { url, toast } = JSON.parse(result);
|
|
296
|
+
const success = url.includes('manage') || url.includes('success') || !url.includes('publish');
|
|
297
|
+
const status = success ? '✅ 发布成功' : '⚠️ 请在浏览器中确认';
|
|
298
|
+
return {
|
|
299
|
+
columns: [
|
|
300
|
+
{ key: 'field', header: '字段', width: 12 },
|
|
301
|
+
{ key: 'value', header: '值', width: 60 },
|
|
302
|
+
],
|
|
303
|
+
rows: [
|
|
304
|
+
{ field: '状态', value: status },
|
|
305
|
+
{ field: '标题', value: title || '(无)' },
|
|
306
|
+
{ field: '正文', value: content.slice(0, 60) || '(无)' },
|
|
307
|
+
{ field: '图片', value: images.length > 0 ? `${images.length} 张` : '(无)' },
|
|
308
|
+
{ field: '当前URL', value: url },
|
|
309
|
+
{ field: '提示', value: toast || '(无)' },
|
|
310
|
+
],
|
|
311
|
+
};
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP client — wraps a single Chrome tab via WebSocket.
|
|
3
|
+
* Handles request/response matching, navigate, eval, cookies.
|
|
4
|
+
*/
|
|
5
|
+
import WebSocket from 'ws';
|
|
6
|
+
export class CDPClient {
|
|
7
|
+
ws;
|
|
8
|
+
pending = new Map();
|
|
9
|
+
idCounter = 0;
|
|
10
|
+
ready;
|
|
11
|
+
constructor(wsUrl) {
|
|
12
|
+
this.ws = new WebSocket(wsUrl);
|
|
13
|
+
this.ready = new Promise((resolve, reject) => {
|
|
14
|
+
this.ws.once('open', resolve);
|
|
15
|
+
this.ws.once('error', reject);
|
|
16
|
+
});
|
|
17
|
+
this.ws.on('message', (raw) => {
|
|
18
|
+
const msg = JSON.parse(raw.toString());
|
|
19
|
+
if (msg.id !== undefined) {
|
|
20
|
+
const cb = this.pending.get(msg.id);
|
|
21
|
+
if (!cb)
|
|
22
|
+
return;
|
|
23
|
+
this.pending.delete(msg.id);
|
|
24
|
+
if (msg.error)
|
|
25
|
+
cb.reject(new Error(msg.error.message));
|
|
26
|
+
else
|
|
27
|
+
cb.resolve(msg.result ?? null);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async waitReady() {
|
|
32
|
+
await this.ready;
|
|
33
|
+
}
|
|
34
|
+
send(method, params = {}) {
|
|
35
|
+
const id = ++this.idCounter;
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
this.pending.set(id, { resolve: resolve, reject });
|
|
38
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async navigate(url, waitMs = 3000) {
|
|
42
|
+
await this.send('Page.navigate', { url });
|
|
43
|
+
await sleep(waitMs);
|
|
44
|
+
}
|
|
45
|
+
async eval(expression) {
|
|
46
|
+
const res = await this.send('Runtime.evaluate', {
|
|
47
|
+
expression,
|
|
48
|
+
returnByValue: true,
|
|
49
|
+
awaitPromise: true,
|
|
50
|
+
});
|
|
51
|
+
return res.result.value;
|
|
52
|
+
}
|
|
53
|
+
async getAllCookies() {
|
|
54
|
+
const res = await this.send('Network.getAllCookies');
|
|
55
|
+
return res.cookies;
|
|
56
|
+
}
|
|
57
|
+
/** 通过 DOM.setFileInputFiles 上传本地文件到 input[type=file] */
|
|
58
|
+
async uploadFiles(selector, filePaths) {
|
|
59
|
+
await this.send('DOM.enable');
|
|
60
|
+
const doc = await this.send('DOM.getDocument', { depth: 1 });
|
|
61
|
+
const q = await this.send('DOM.querySelector', {
|
|
62
|
+
nodeId: doc.root.nodeId,
|
|
63
|
+
selector,
|
|
64
|
+
});
|
|
65
|
+
if (!q.nodeId)
|
|
66
|
+
throw new Error(`uploadFiles: selector not found: ${selector}`);
|
|
67
|
+
await this.send('DOM.setFileInputFiles', { files: filePaths, nodeId: q.nodeId });
|
|
68
|
+
}
|
|
69
|
+
async setCookies(cookies) {
|
|
70
|
+
for (const c of cookies) {
|
|
71
|
+
await this.send('Network.setCookie', {
|
|
72
|
+
name: c.name,
|
|
73
|
+
value: c.value,
|
|
74
|
+
domain: c.domain,
|
|
75
|
+
path: c.path,
|
|
76
|
+
secure: c.secure,
|
|
77
|
+
httpOnly: c.httpOnly,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
close() {
|
|
82
|
+
this.ws.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function sleep(ms) {
|
|
86
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
87
|
+
}
|
|
88
|
+
/** 连接到 Chrome CDP,返回指定 tab 的 CDPClient */
|
|
89
|
+
export async function connectTab(cdpPort = 9222, tabIndex = 0) {
|
|
90
|
+
const res = await fetch(`http://localhost:${cdpPort}/json`);
|
|
91
|
+
const tabs = (await res.json());
|
|
92
|
+
const pages = tabs.filter((t) => t.type === 'page');
|
|
93
|
+
if (!pages[tabIndex])
|
|
94
|
+
throw new Error(`No page tab at index ${tabIndex}`);
|
|
95
|
+
const client = new CDPClient(pages[tabIndex].webSocketDebuggerUrl);
|
|
96
|
+
await client.waitReady();
|
|
97
|
+
return client;
|
|
98
|
+
}
|
|
99
|
+
/** 新建 tab,返回其 CDPClient */
|
|
100
|
+
export async function newTab(cdpPort = 9222) {
|
|
101
|
+
const res = await fetch(`http://localhost:${cdpPort}/json/new`, { method: 'PUT' });
|
|
102
|
+
const tab = (await res.json());
|
|
103
|
+
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
104
|
+
await client.waitReady();
|
|
105
|
+
return client;
|
|
106
|
+
}
|