@harness.farm/social-cli 0.1.1 → 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/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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner — orchestrates login flow + command execution for any adapter.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Connect to Chrome tab
|
|
6
|
+
* 2. Check if already logged in (via saved session or live cookies)
|
|
7
|
+
* 3. If not, navigate to loginUrl and wait for user to log in
|
|
8
|
+
* 4. Save session cookies
|
|
9
|
+
* 5. Run the requested command
|
|
10
|
+
* 6. Render output
|
|
11
|
+
*/
|
|
12
|
+
import { newTab, sleep } from './cdp.js';
|
|
13
|
+
import { captureSession, restoreSession, hasSession } from './session.js';
|
|
14
|
+
import { renderTable } from '../output/table.js';
|
|
15
|
+
export async function run(adapter, opts) {
|
|
16
|
+
const port = opts.cdpPort ?? 9222;
|
|
17
|
+
console.log(`\n🔌 连接 Chrome CDP :${port}...`);
|
|
18
|
+
const client = await newTab(port);
|
|
19
|
+
// --- 登录流程 ---
|
|
20
|
+
let loggedIn = false;
|
|
21
|
+
// 先尝试恢复已保存的 session
|
|
22
|
+
if (hasSession(adapter.platform)) {
|
|
23
|
+
await restoreSession(client, adapter.platform);
|
|
24
|
+
// 导航到目标站验证 session 是否还有效
|
|
25
|
+
await client.navigate(adapter.loginUrl, 3000);
|
|
26
|
+
loggedIn = await adapter.isLoggedIn(client);
|
|
27
|
+
if (!loggedIn) {
|
|
28
|
+
console.log('⚠️ 已保存的 session 已失效,需要重新登录');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// session 无效或没有 session → 引导用户登录
|
|
32
|
+
if (!loggedIn) {
|
|
33
|
+
await client.navigate(adapter.loginUrl, 2000);
|
|
34
|
+
console.log(`\n🔑 请在 Chrome 中登录 ${adapter.platform},完成后按 Enter 继续...`);
|
|
35
|
+
await waitForEnter();
|
|
36
|
+
// 等待并轮询登录状态
|
|
37
|
+
for (let i = 0; i < 30; i++) {
|
|
38
|
+
loggedIn = await adapter.isLoggedIn(client);
|
|
39
|
+
if (loggedIn)
|
|
40
|
+
break;
|
|
41
|
+
process.stdout.write(`\r等待登录... ${(i + 1) * 2}s`);
|
|
42
|
+
await sleep(2000);
|
|
43
|
+
}
|
|
44
|
+
process.stdout.write('\n');
|
|
45
|
+
if (!loggedIn) {
|
|
46
|
+
client.close();
|
|
47
|
+
throw new Error('登录超时,请重试');
|
|
48
|
+
}
|
|
49
|
+
// 保存 session
|
|
50
|
+
await captureSession(client, adapter.platform);
|
|
51
|
+
}
|
|
52
|
+
console.log(`✅ 已登录 ${adapter.platform}`);
|
|
53
|
+
// --- 执行命令 ---
|
|
54
|
+
const handler = adapter.commands[opts.command];
|
|
55
|
+
if (!handler) {
|
|
56
|
+
const available = Object.keys(adapter.commands).join(', ');
|
|
57
|
+
client.close();
|
|
58
|
+
throw new Error(`未知命令 "${opts.command}",可用: ${available}`);
|
|
59
|
+
}
|
|
60
|
+
console.log(`\n🔍 执行: ${adapter.platform} ${opts.command} ${(opts.args ?? []).join(' ')}\n`);
|
|
61
|
+
const result = await handler(client, opts.args ?? []);
|
|
62
|
+
client.close();
|
|
63
|
+
// --- 渲染 ---
|
|
64
|
+
renderTable(result.columns, result.rows);
|
|
65
|
+
}
|
|
66
|
+
function waitForEnter() {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
process.stdin.setRawMode?.(false);
|
|
69
|
+
process.stdin.resume();
|
|
70
|
+
process.stdin.once('data', () => {
|
|
71
|
+
process.stdin.pause();
|
|
72
|
+
resolve();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session manager — save/load cookies to disk per platform.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
const SESSION_DIR = path.join(process.env.HOME ?? '.', '.cdp-scraper', 'sessions');
|
|
7
|
+
function sessionPath(platform) {
|
|
8
|
+
return path.join(SESSION_DIR, `${platform}.json`);
|
|
9
|
+
}
|
|
10
|
+
export function saveSession(platform, cookies) {
|
|
11
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
12
|
+
fs.writeFileSync(sessionPath(platform), JSON.stringify(cookies, null, 2));
|
|
13
|
+
console.log(`✅ Session saved → ${sessionPath(platform)} (${cookies.length} cookies)`);
|
|
14
|
+
}
|
|
15
|
+
export function loadSession(platform) {
|
|
16
|
+
const p = sessionPath(platform);
|
|
17
|
+
if (!fs.existsSync(p))
|
|
18
|
+
return null;
|
|
19
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
20
|
+
}
|
|
21
|
+
export function hasSession(platform) {
|
|
22
|
+
return fs.existsSync(sessionPath(platform));
|
|
23
|
+
}
|
|
24
|
+
/** 从 browser 读取 cookies 并保存 */
|
|
25
|
+
export async function captureSession(client, platform) {
|
|
26
|
+
const cookies = await client.getAllCookies();
|
|
27
|
+
saveSession(platform, cookies);
|
|
28
|
+
return cookies;
|
|
29
|
+
}
|
|
30
|
+
/** 恢复已保存的 session 到 browser */
|
|
31
|
+
export async function restoreSession(client, platform) {
|
|
32
|
+
const cookies = loadSession(platform);
|
|
33
|
+
if (!cookies)
|
|
34
|
+
return false;
|
|
35
|
+
await client.setCookies(cookies);
|
|
36
|
+
console.log(`✅ Session restored ← ${platform} (${cookies.length} cookies)`);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry — supports both YAML adapters and TypeScript adapters.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* tsx src/cli.ts <platform> <command> [args...]
|
|
7
|
+
*
|
|
8
|
+
* Adapter resolution order:
|
|
9
|
+
* 1. adapters/<platform>.yaml ← YAML-first
|
|
10
|
+
* 2. src/adapters/<platform>.ts ← TypeScript fallback
|
|
11
|
+
*
|
|
12
|
+
* Examples:
|
|
13
|
+
* tsx src/cli.ts xhs search 法律ai
|
|
14
|
+
* tsx src/cli.ts xhs like "https://..."
|
|
15
|
+
* tsx src/cli.ts xhs comment "https://..." "太棒了!"
|
|
16
|
+
* tsx src/cli.ts xhs post --title "标题" --content "内容"
|
|
17
|
+
*/
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
import { runYamlCommand } from './runner/yaml-runner.js';
|
|
22
|
+
import { run } from './browser/runner.js';
|
|
23
|
+
import { adapters } from './adapters/index.js';
|
|
24
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
26
|
+
const [, , platform, command, ...rest] = process.argv;
|
|
27
|
+
if (!platform || !command) {
|
|
28
|
+
printHelp();
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
// ── Resolve adapter ────────────────────────────────────────────────────────
|
|
32
|
+
const yamlPath = path.join(ROOT, 'adapters', `${platform}.yaml`);
|
|
33
|
+
const hasYaml = fs.existsSync(yamlPath);
|
|
34
|
+
const tsAdapter = adapters[platform];
|
|
35
|
+
if (!hasYaml && !tsAdapter) {
|
|
36
|
+
console.error(`❌ Unknown platform "${platform}"`);
|
|
37
|
+
console.error(` YAML adapters: ${listYamlAdapters().join(', ') || '(none)'}`);
|
|
38
|
+
console.error(` TS adapters: ${Object.keys(adapters).join(', ')}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
// ── Parse args ─────────────────────────────────────────────────────────────
|
|
42
|
+
// Support both positional args and --key value flags
|
|
43
|
+
const args = parseArgs(rest);
|
|
44
|
+
// ── Run ───────────────────────────────────────────────────────────────────
|
|
45
|
+
if (hasYaml) {
|
|
46
|
+
// YAML adapter: pass positional args in order
|
|
47
|
+
runYamlCommand(yamlPath, command, args.positional).catch(err => {
|
|
48
|
+
console.error('❌', err.message);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// TypeScript adapter: pass flags as array (legacy)
|
|
54
|
+
run(tsAdapter, { command, args: rest }).catch(err => {
|
|
55
|
+
console.error('❌', err.message);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
60
|
+
function parseArgs(argv) {
|
|
61
|
+
const positional = [];
|
|
62
|
+
const flags = {};
|
|
63
|
+
for (let i = 0; i < argv.length; i++) {
|
|
64
|
+
if (argv[i].startsWith('--')) {
|
|
65
|
+
flags[argv[i].slice(2)] = argv[++i] ?? '';
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
positional.push(argv[i]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { positional, flags };
|
|
72
|
+
}
|
|
73
|
+
function listYamlAdapters() {
|
|
74
|
+
const dir = path.join(ROOT, 'adapters');
|
|
75
|
+
if (!fs.existsSync(dir))
|
|
76
|
+
return [];
|
|
77
|
+
return fs.readdirSync(dir)
|
|
78
|
+
.filter(f => f.endsWith('.yaml'))
|
|
79
|
+
.map(f => f.replace('.yaml', ''));
|
|
80
|
+
}
|
|
81
|
+
function printHelp() {
|
|
82
|
+
const yaml = listYamlAdapters();
|
|
83
|
+
const ts = Object.keys(adapters);
|
|
84
|
+
console.log('用法: tsx src/cli.ts <platform> <command> [args...]');
|
|
85
|
+
console.log('');
|
|
86
|
+
if (yaml.length) {
|
|
87
|
+
console.log('YAML 平台 (推荐):');
|
|
88
|
+
yaml.forEach(p => console.log(` ${p}`));
|
|
89
|
+
}
|
|
90
|
+
if (ts.length) {
|
|
91
|
+
console.log('TS 平台:');
|
|
92
|
+
ts.forEach(p => console.log(` ${p}`));
|
|
93
|
+
}
|
|
94
|
+
console.log('');
|
|
95
|
+
console.log('示例:');
|
|
96
|
+
console.log(' tsx src/cli.ts xhs search 法律ai');
|
|
97
|
+
console.log(' tsx src/cli.ts xhs like "https://www.xiaohongshu.com/explore/..."');
|
|
98
|
+
console.log(' tsx src/cli.ts xhs comment "https://..." "太棒了!"');
|
|
99
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI table renderer with CJK character width support.
|
|
3
|
+
*/
|
|
4
|
+
function dispWidth(s) {
|
|
5
|
+
let w = 0;
|
|
6
|
+
for (const c of s)
|
|
7
|
+
w += c.codePointAt(0) > 127 ? 2 : 1;
|
|
8
|
+
return w;
|
|
9
|
+
}
|
|
10
|
+
function truncate(s, maxW) {
|
|
11
|
+
let out = '', cur = 0;
|
|
12
|
+
for (const c of s) {
|
|
13
|
+
const cw = c.codePointAt(0) > 127 ? 2 : 1;
|
|
14
|
+
if (cur + cw > maxW - 1)
|
|
15
|
+
return out + '…';
|
|
16
|
+
out += c;
|
|
17
|
+
cur += cw;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
function pad(s, width) {
|
|
22
|
+
return s + ' '.repeat(Math.max(0, width - dispWidth(s)));
|
|
23
|
+
}
|
|
24
|
+
function sep(cols, char = '-') {
|
|
25
|
+
return '+' + cols.map((c) => char.repeat(c.width + 2)).join('+') + '+';
|
|
26
|
+
}
|
|
27
|
+
function row(cols, values) {
|
|
28
|
+
return '|' + cols.map((c, i) => ` ${pad(truncate(String(values[i] ?? ''), c.width), c.width)} `).join('|') + '|';
|
|
29
|
+
}
|
|
30
|
+
export function renderTable(cols, data) {
|
|
31
|
+
const divider = sep(cols);
|
|
32
|
+
const doubleSep = sep(cols, '=');
|
|
33
|
+
console.log(divider);
|
|
34
|
+
console.log(row(cols, cols.map((c) => c.header)));
|
|
35
|
+
console.log(doubleSep);
|
|
36
|
+
data.forEach((item, i) => {
|
|
37
|
+
console.log(row(cols, cols.map((c) => String(item[c.key] ?? ''))));
|
|
38
|
+
if (i < data.length - 1)
|
|
39
|
+
console.log(divider);
|
|
40
|
+
});
|
|
41
|
+
console.log(divider);
|
|
42
|
+
console.log(`\n共 ${data.length} 条结果`);
|
|
43
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step executor — maps YAML step types to agent-browser CLI calls.
|
|
3
|
+
*
|
|
4
|
+
* Each method runs `agent-browser <args>` and returns parsed JSON result.
|
|
5
|
+
*/
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
export class StepExecutor {
|
|
8
|
+
wsUrl;
|
|
9
|
+
connected = false;
|
|
10
|
+
constructor(wsUrl) {
|
|
11
|
+
this.wsUrl = wsUrl;
|
|
12
|
+
}
|
|
13
|
+
/** Connect to Chrome tab (once per session) */
|
|
14
|
+
connect() {
|
|
15
|
+
if (this.connected)
|
|
16
|
+
return;
|
|
17
|
+
this.run(['connect', this.wsUrl]);
|
|
18
|
+
this.connected = true;
|
|
19
|
+
}
|
|
20
|
+
// ── Core step methods ──────────────────────────────────────────────────────
|
|
21
|
+
open(url) {
|
|
22
|
+
return this.run(['open', url]);
|
|
23
|
+
}
|
|
24
|
+
click(selector) {
|
|
25
|
+
return this.run(['click', selector]);
|
|
26
|
+
}
|
|
27
|
+
/** Click an element by its visible text (uses eval under the hood) */
|
|
28
|
+
clickText(text) {
|
|
29
|
+
const js = `(function(){
|
|
30
|
+
var el = [...document.querySelectorAll('*')].find(function(e){
|
|
31
|
+
return e.textContent.trim() === ${JSON.stringify(text)} && e.children.length === 0;
|
|
32
|
+
});
|
|
33
|
+
if(el){ el.click(); return true; }
|
|
34
|
+
return false;
|
|
35
|
+
})()`;
|
|
36
|
+
const r = this.eval(js);
|
|
37
|
+
if (!r.value)
|
|
38
|
+
return { ok: false, error: `Text not found: "${text}"` };
|
|
39
|
+
return { ok: true };
|
|
40
|
+
}
|
|
41
|
+
fill(selector, value) {
|
|
42
|
+
// Use eval to avoid shell-quoting issues with complex selectors
|
|
43
|
+
const js = `(function(){
|
|
44
|
+
var el = document.querySelector(${JSON.stringify(selector)});
|
|
45
|
+
if (!el) return false;
|
|
46
|
+
el.focus();
|
|
47
|
+
var nativeSet = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
|
|
48
|
+
|| Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value');
|
|
49
|
+
if (nativeSet && nativeSet.set) nativeSet.set.call(el, ${JSON.stringify(value)});
|
|
50
|
+
else el.value = ${JSON.stringify(value)};
|
|
51
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
52
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
53
|
+
return true;
|
|
54
|
+
})()`;
|
|
55
|
+
return this.eval(js);
|
|
56
|
+
}
|
|
57
|
+
type(selector, value) {
|
|
58
|
+
return this.run(['type', selector, value]);
|
|
59
|
+
}
|
|
60
|
+
/** Type into a contenteditable element via execCommand */
|
|
61
|
+
typeContentEditable(selector, value) {
|
|
62
|
+
const js = `(function(){
|
|
63
|
+
var el = document.querySelector(${JSON.stringify(selector)});
|
|
64
|
+
if(!el) return false;
|
|
65
|
+
el.focus();
|
|
66
|
+
document.execCommand('selectAll', false, null);
|
|
67
|
+
document.execCommand('insertText', false, ${JSON.stringify(value)});
|
|
68
|
+
return el.textContent || el.value || true;
|
|
69
|
+
})()`;
|
|
70
|
+
return this.eval(js);
|
|
71
|
+
}
|
|
72
|
+
/** Type via agent-browser's real keystroke simulation (works with Draft.js / React) */
|
|
73
|
+
typeKeys(selector, value) {
|
|
74
|
+
return this.run(['type', selector, value]);
|
|
75
|
+
}
|
|
76
|
+
wait(msOrSelector) {
|
|
77
|
+
if (typeof msOrSelector === 'number') {
|
|
78
|
+
return this.run(['wait', String(msOrSelector)]);
|
|
79
|
+
}
|
|
80
|
+
return this.run(['wait', msOrSelector]);
|
|
81
|
+
}
|
|
82
|
+
/** Press a key via agent-browser press (real key event, e.g. z, x, Enter, Control+Enter) */
|
|
83
|
+
pressKey(key) {
|
|
84
|
+
return this.run(['press', key]);
|
|
85
|
+
}
|
|
86
|
+
/** Insert text into the currently focused element via agent-browser keyboard type */
|
|
87
|
+
keyboardInsertText(text) {
|
|
88
|
+
// 'keyboard type' sends real key events char-by-char — works with Draft.js
|
|
89
|
+
return this.run(['keyboard', 'type', text]);
|
|
90
|
+
}
|
|
91
|
+
eval(js) {
|
|
92
|
+
const r = this.run(['eval', js]);
|
|
93
|
+
if (r.ok && r.value !== undefined) {
|
|
94
|
+
// agent-browser wraps eval result in data.result
|
|
95
|
+
const data = r.value;
|
|
96
|
+
return { ok: true, value: data.result ?? r.value };
|
|
97
|
+
}
|
|
98
|
+
return r;
|
|
99
|
+
}
|
|
100
|
+
screenshot(path) {
|
|
101
|
+
return this.run(path ? ['screenshot', path] : ['screenshot']);
|
|
102
|
+
}
|
|
103
|
+
upload(selector, filePath) {
|
|
104
|
+
return this.run(['upload', selector, filePath]);
|
|
105
|
+
}
|
|
106
|
+
getUrl() {
|
|
107
|
+
const r = this.run(['get', 'url']);
|
|
108
|
+
const data = r.value;
|
|
109
|
+
return data?.url ?? '';
|
|
110
|
+
}
|
|
111
|
+
snapshot() {
|
|
112
|
+
const r = this.run(['snapshot']);
|
|
113
|
+
return String(r.value ?? '');
|
|
114
|
+
}
|
|
115
|
+
// ── Internal runner ────────────────────────────────────────────────────────
|
|
116
|
+
run(args) {
|
|
117
|
+
try {
|
|
118
|
+
const cmd = `agent-browser ${args.map(a => this.shellQuote(a)).join(' ')}`;
|
|
119
|
+
const out = execSync(cmd, {
|
|
120
|
+
env: { ...process.env, AGENT_BROWSER_JSON: '1' },
|
|
121
|
+
timeout: 30000,
|
|
122
|
+
encoding: 'utf8',
|
|
123
|
+
});
|
|
124
|
+
// Find last JSON line (agent-browser may emit warnings before JSON)
|
|
125
|
+
const jsonLine = out.trim().split('\n').reverse().find(l => l.startsWith('{'));
|
|
126
|
+
if (!jsonLine)
|
|
127
|
+
return { ok: true };
|
|
128
|
+
const parsed = JSON.parse(jsonLine);
|
|
129
|
+
if (!parsed.success)
|
|
130
|
+
return { ok: false, error: parsed.error ?? 'unknown error' };
|
|
131
|
+
return { ok: true, value: parsed.data };
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
135
|
+
return { ok: false, error: msg };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
shellQuote(s) {
|
|
139
|
+
// Wrap in single quotes, escape internal single quotes
|
|
140
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML Runner — parses adapter YAML and executes commands step by step.
|
|
3
|
+
*
|
|
4
|
+
* YAML schema:
|
|
5
|
+
* platform: string
|
|
6
|
+
* login_url: string
|
|
7
|
+
* login_check:
|
|
8
|
+
* cookie: string # cookie name that indicates logged-in state
|
|
9
|
+
* commands:
|
|
10
|
+
* <name>:
|
|
11
|
+
* args: string[] # positional arg names
|
|
12
|
+
* steps: Step[]
|
|
13
|
+
*
|
|
14
|
+
* Step types:
|
|
15
|
+
* - open: "{{url}}"
|
|
16
|
+
* - click: ".selector"
|
|
17
|
+
* - click: { text: "visible text" }
|
|
18
|
+
* - fill: { selector: ".sel", value: "{{text}}" }
|
|
19
|
+
* - type_rich: { selector: ".sel", value: "{{text}}" } # for contenteditable
|
|
20
|
+
* - wait: 3000
|
|
21
|
+
* - wait: { selector: ".sel" }
|
|
22
|
+
* - eval: "js expression"
|
|
23
|
+
* - capture: { name: varName, eval: "js" }
|
|
24
|
+
* - upload: { selector: ".sel", file: "{{file}}" }
|
|
25
|
+
* - screenshot: path.png
|
|
26
|
+
* - extract: { selector, fields: { key: ".sel" | { selector, attr } } }
|
|
27
|
+
* - return: [ { field, value } ] → builds output table
|
|
28
|
+
* - assert: { eval: "js", message: "error msg" }
|
|
29
|
+
*/
|
|
30
|
+
import fs from 'fs';
|
|
31
|
+
import { parse as parseYaml } from 'yaml';
|
|
32
|
+
import { StepExecutor } from './step-executor.js';
|
|
33
|
+
import { renderTable } from '../output/table.js';
|
|
34
|
+
import { connectTab } from '../browser/cdp.js';
|
|
35
|
+
import { loadSession } from '../browser/session.js';
|
|
36
|
+
// ─── Runner ───────────────────────────────────────────────────────────────────
|
|
37
|
+
export async function runYamlCommand(adapterPath, commandName, argValues, cdpPort = 9222) {
|
|
38
|
+
// 1. Load & parse YAML
|
|
39
|
+
const raw = fs.readFileSync(adapterPath, 'utf-8');
|
|
40
|
+
const adapter = parseYaml(raw);
|
|
41
|
+
const cmdDef = adapter.commands[commandName];
|
|
42
|
+
if (!cmdDef) {
|
|
43
|
+
const available = Object.keys(adapter.commands).join(', ');
|
|
44
|
+
throw new Error(`Unknown command "${commandName}". Available: ${available}`);
|
|
45
|
+
}
|
|
46
|
+
// 2. Build variables map from args
|
|
47
|
+
const vars = {};
|
|
48
|
+
(cmdDef.args ?? []).forEach((name, i) => {
|
|
49
|
+
vars[name] = argValues[i] ?? '';
|
|
50
|
+
});
|
|
51
|
+
// 3. Ensure logged in → resolve tab ws URL
|
|
52
|
+
const wsUrl = await resolveTabWsUrl(adapter, cdpPort);
|
|
53
|
+
const exec = new StepExecutor(wsUrl);
|
|
54
|
+
exec.connect();
|
|
55
|
+
const cdpClient = await connectTab(cdpPort);
|
|
56
|
+
console.log(`✅ 已连接 ${adapter.platform}`);
|
|
57
|
+
console.log(`\n🔍 执行: ${adapter.platform} ${commandName} ${argValues.join(' ')}\n`);
|
|
58
|
+
// 4. Execute steps
|
|
59
|
+
let extractedRows = null;
|
|
60
|
+
let returnRows = null;
|
|
61
|
+
for (const step of cmdDef.steps) {
|
|
62
|
+
const key = Object.keys(step)[0];
|
|
63
|
+
const val = step[key];
|
|
64
|
+
switch (key) {
|
|
65
|
+
case 'open': {
|
|
66
|
+
const url = interpolate(val, vars);
|
|
67
|
+
console.log(` → open ${url}`);
|
|
68
|
+
exec.open(url);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case 'click': {
|
|
72
|
+
if (typeof val === 'string') {
|
|
73
|
+
// plain selector
|
|
74
|
+
const sel = interpolate(val, vars);
|
|
75
|
+
console.log(` → click "${sel}"`);
|
|
76
|
+
throwIfFail(exec.click(sel), `click failed: ${sel}`);
|
|
77
|
+
}
|
|
78
|
+
else if (typeof val === 'object' && val !== null && 'text' in val) {
|
|
79
|
+
const text = interpolate(val.text, vars);
|
|
80
|
+
console.log(` → click text="${text}"`);
|
|
81
|
+
throwIfFail(exec.clickText(text), `text not found: "${text}"`);
|
|
82
|
+
}
|
|
83
|
+
else if (typeof val === 'object' && val !== null && 'selector' in val) {
|
|
84
|
+
const sel = interpolate(val.selector, vars);
|
|
85
|
+
console.log(` → click selector="${sel}"`);
|
|
86
|
+
throwIfFail(exec.click(sel), `click failed: ${sel}`);
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case 'fill': {
|
|
91
|
+
const { selector, value } = val;
|
|
92
|
+
const sel = interpolate(selector, vars);
|
|
93
|
+
const v = interpolate(value, vars);
|
|
94
|
+
console.log(` → fill "${sel}" = "${v.slice(0, 40)}"`);
|
|
95
|
+
throwIfFail(exec.fill(sel, v), `fill failed: ${sel}`);
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case 'type_rich': {
|
|
99
|
+
const { selector, value } = val;
|
|
100
|
+
const sel = interpolate(selector, vars);
|
|
101
|
+
const v = interpolate(value, vars);
|
|
102
|
+
console.log(` → type_rich "${sel}" = "${v.slice(0, 40)}"`);
|
|
103
|
+
throwIfFail(exec.typeContentEditable(sel, v), `type_rich failed: ${sel}`);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case 'wait': {
|
|
107
|
+
if (typeof val === 'number') {
|
|
108
|
+
console.log(` → wait ${val}ms`);
|
|
109
|
+
exec.wait(val);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const sel = interpolate(val.selector, vars);
|
|
113
|
+
console.log(` → wait selector="${sel}"`);
|
|
114
|
+
exec.wait(sel);
|
|
115
|
+
}
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case 'eval': {
|
|
119
|
+
const js = interpolate(val, vars);
|
|
120
|
+
console.log(` → eval ...`);
|
|
121
|
+
exec.eval(js);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case 'capture': {
|
|
125
|
+
const { name, eval: js } = val;
|
|
126
|
+
const interpolatedJs = interpolate(js, vars);
|
|
127
|
+
console.log(` → capture ${name}`);
|
|
128
|
+
const r = exec.eval(interpolatedJs);
|
|
129
|
+
vars[name] = String(r.value ?? '');
|
|
130
|
+
console.log(` ${name} = ${vars[name].slice(0, 60)}`);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case 'upload': {
|
|
134
|
+
const { selector, file } = val;
|
|
135
|
+
const sel = interpolate(selector, vars);
|
|
136
|
+
const f = interpolate(file, vars);
|
|
137
|
+
console.log(` → upload "${sel}" ← ${f}`);
|
|
138
|
+
throwIfFail(exec.upload(sel, f), `upload failed: ${sel}`);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'screenshot': {
|
|
142
|
+
const p = interpolate(val, vars);
|
|
143
|
+
console.log(` → screenshot → ${p}`);
|
|
144
|
+
exec.screenshot(p);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'extract': {
|
|
148
|
+
const def = val;
|
|
149
|
+
console.log(` → extract "${def.selector}"`);
|
|
150
|
+
extractedRows = runExtract(exec, def, vars);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case 'return': {
|
|
154
|
+
returnRows = val.map(r => ({
|
|
155
|
+
field: interpolate(r.field, vars),
|
|
156
|
+
value: interpolate(r.value, vars),
|
|
157
|
+
}));
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case 'assert': {
|
|
161
|
+
const { eval: js, message } = val;
|
|
162
|
+
const r = exec.eval(interpolate(js, vars));
|
|
163
|
+
if (!r.value)
|
|
164
|
+
throw new Error(message ?? `Assertion failed: ${js}`);
|
|
165
|
+
console.log(` → assert ✅`);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case 'key': {
|
|
169
|
+
const k = interpolate(val, vars);
|
|
170
|
+
console.log(` → key "${k}"`);
|
|
171
|
+
// For Enter/special keys that need to reach the focused element, use CDPClient directly
|
|
172
|
+
if (k === 'Enter' || k === 'Control+Enter') {
|
|
173
|
+
const modifiers = k.startsWith('Control') ? 2 : 0;
|
|
174
|
+
await cdpClient.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, modifiers });
|
|
175
|
+
await cdpClient.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, modifiers });
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
exec.pressKey(k);
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case 'keyboard_insert': {
|
|
183
|
+
// Send text char-by-char via CDP Input.dispatchKeyEvent 'char' events
|
|
184
|
+
// This triggers Draft.js / React input handlers correctly
|
|
185
|
+
const text = interpolate(val, vars);
|
|
186
|
+
console.log(` → keyboard_insert "${text.slice(0, 40)}"`);
|
|
187
|
+
for (const char of text) {
|
|
188
|
+
const code = char.codePointAt(0);
|
|
189
|
+
await cdpClient.send('Input.dispatchKeyEvent', { type: 'keyDown', key: char, windowsVirtualKeyCode: code });
|
|
190
|
+
await cdpClient.send('Input.dispatchKeyEvent', { type: 'char', key: char, text: char });
|
|
191
|
+
await cdpClient.send('Input.dispatchKeyEvent', { type: 'keyUp', key: char, windowsVirtualKeyCode: code });
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case 'insert_text': {
|
|
196
|
+
// CDP Input.insertText — inserts text directly into the focused element,
|
|
197
|
+
// works even inside shadow DOM (unlike keyboard_insert which targets document.activeElement)
|
|
198
|
+
const text = interpolate(val, vars);
|
|
199
|
+
console.log(` → insert_text "${text.slice(0, 40)}"`);
|
|
200
|
+
await cdpClient.send('Input.insertText', { text });
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// 5. Render output
|
|
206
|
+
console.log('');
|
|
207
|
+
if (extractedRows) {
|
|
208
|
+
const fields = Object.keys(extractedRows[0] ?? {});
|
|
209
|
+
const columns = fields.map(k => ({
|
|
210
|
+
key: k,
|
|
211
|
+
header: k,
|
|
212
|
+
width: k === 'index' ? 4 : k === 'link' ? 52 : k === 'title' ? 36 : 24,
|
|
213
|
+
}));
|
|
214
|
+
renderTable(columns, extractedRows);
|
|
215
|
+
}
|
|
216
|
+
else if (returnRows) {
|
|
217
|
+
renderTable([{ key: 'field', header: '字段', width: 12 }, { key: 'value', header: '值', width: 50 }], returnRows);
|
|
218
|
+
}
|
|
219
|
+
cdpClient.close();
|
|
220
|
+
}
|
|
221
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
222
|
+
/** Replace {{expr}} placeholders — supports plain var names and JS expressions */
|
|
223
|
+
function interpolate(template, vars) {
|
|
224
|
+
return template.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
|
|
225
|
+
const trimmed = expr.trim();
|
|
226
|
+
// Plain variable name: fast path
|
|
227
|
+
if (/^\w+$/.test(trimmed))
|
|
228
|
+
return vars[trimmed] ?? '';
|
|
229
|
+
// JS expression: inject vars as locals and eval
|
|
230
|
+
try {
|
|
231
|
+
const keys = Object.keys(vars);
|
|
232
|
+
const vals = keys.map(k => vars[k]);
|
|
233
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
234
|
+
return String(new Function(...keys, `return (${trimmed})`)(...vals) ?? '');
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return '';
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function throwIfFail(r, msg) {
|
|
242
|
+
if (!r.ok)
|
|
243
|
+
throw new Error(`${msg}: ${r.error ?? ''}`);
|
|
244
|
+
}
|
|
245
|
+
/** Run an extract step: scrape a list of items with named fields */
|
|
246
|
+
function runExtract(exec, def, vars) {
|
|
247
|
+
const fieldsJson = JSON.stringify(def.fields);
|
|
248
|
+
const js = `(function(){
|
|
249
|
+
var fields = ${fieldsJson};
|
|
250
|
+
var results = [];
|
|
251
|
+
document.querySelectorAll(${JSON.stringify(def.selector)}).forEach(function(item, i) {
|
|
252
|
+
var row = { index: i + 1 };
|
|
253
|
+
Object.keys(fields).forEach(function(key) {
|
|
254
|
+
var spec = fields[key];
|
|
255
|
+
if (typeof spec === 'string') {
|
|
256
|
+
var el = item.querySelector(spec);
|
|
257
|
+
row[key] = el ? el.textContent.trim() : '';
|
|
258
|
+
} else {
|
|
259
|
+
var el2 = item.querySelector(spec.selector);
|
|
260
|
+
row[key] = el2 ? (spec.attr === 'href' ? (el2.href || el2.getAttribute(spec.attr)) : el2.getAttribute(spec.attr)) || el2.textContent.trim() : '';
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
if (Object.values(row).some(function(v){ return v && v !== i + 1; })) results.push(row);
|
|
264
|
+
});
|
|
265
|
+
return JSON.stringify(results);
|
|
266
|
+
})()`;
|
|
267
|
+
const r = exec.eval(js);
|
|
268
|
+
try {
|
|
269
|
+
return JSON.parse(String(r.value ?? '[]'));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Find the ws URL of the right tab, with full user onboarding if needed */
|
|
276
|
+
async function resolveTabWsUrl(adapter, cdpPort) {
|
|
277
|
+
// Step 1: Check Chrome is running with CDP
|
|
278
|
+
let tabs;
|
|
279
|
+
try {
|
|
280
|
+
const res = await fetch(`http://localhost:${cdpPort}/json`, { signal: AbortSignal.timeout(2000) });
|
|
281
|
+
tabs = (await res.json());
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
console.error(`\n❌ 无法连接到 Chrome CDP (端口 ${cdpPort})`);
|
|
285
|
+
console.error('\n👉 请先启动 Chrome,开启远程调试:\n');
|
|
286
|
+
console.error(` macOS: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome \\`);
|
|
287
|
+
console.error(` --remote-debugging-port=${cdpPort} \\`);
|
|
288
|
+
console.error(` --user-data-dir=$HOME/.cdp-scraper/chrome-profile\n`);
|
|
289
|
+
console.error(` Windows: chrome.exe --remote-debugging-port=${cdpPort} --user-data-dir=%USERPROFILE%\\.cdp-scraper\\chrome-profile\n`);
|
|
290
|
+
console.error(` Linux: google-chrome --remote-debugging-port=${cdpPort} --user-data-dir=~/.cdp-scraper/chrome-profile\n`);
|
|
291
|
+
console.error('启动后重新运行此命令。');
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
const pageTabs = tabs.filter(t => t.type === 'page');
|
|
295
|
+
if (pageTabs.length === 0) {
|
|
296
|
+
console.error('❌ Chrome 中没有打开的页面,请至少保持一个标签页打开。');
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
// Step 2: Prefer a tab already on the platform domain
|
|
300
|
+
const domain = new URL(adapter.login_url).hostname.replace('www.', '');
|
|
301
|
+
const existing = pageTabs.find(t => t.url.includes(domain) && !t.url.includes('creator'));
|
|
302
|
+
if (existing)
|
|
303
|
+
return existing.webSocketDebuggerUrl;
|
|
304
|
+
// Step 3: Check saved session cookie
|
|
305
|
+
const cookies = loadSession(adapter.platform);
|
|
306
|
+
const hasCookie = cookies?.some(c => c.name === adapter.login_check.cookie);
|
|
307
|
+
if (hasCookie) {
|
|
308
|
+
const page = pageTabs[0];
|
|
309
|
+
return page.webSocketDebuggerUrl;
|
|
310
|
+
}
|
|
311
|
+
// Step 4: Not logged in — guide user through login
|
|
312
|
+
console.log(`\n🔑 需要登录 ${adapter.platform}`);
|
|
313
|
+
console.log('─'.repeat(50));
|
|
314
|
+
// Open the platform login page in the first tab via agent-browser
|
|
315
|
+
const firstTab = pageTabs[0].webSocketDebuggerUrl;
|
|
316
|
+
console.log(`\n 正在打开登录页: ${adapter.login_url}`);
|
|
317
|
+
console.log(` (使用 agent-browser 连接到: ${firstTab})\n`);
|
|
318
|
+
const { execSync } = await import('child_process');
|
|
319
|
+
try {
|
|
320
|
+
execSync(`agent-browser connect '${firstTab}'`, {
|
|
321
|
+
env: { ...process.env, AGENT_BROWSER_JSON: '1' },
|
|
322
|
+
timeout: 5000,
|
|
323
|
+
encoding: 'utf8',
|
|
324
|
+
});
|
|
325
|
+
execSync(`agent-browser open '${adapter.login_url}'`, {
|
|
326
|
+
env: { ...process.env, AGENT_BROWSER_JSON: '1' },
|
|
327
|
+
timeout: 10000,
|
|
328
|
+
encoding: 'utf8',
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
console.log(` ⚠️ 无法自动打开页面,请手动在 Chrome 中访问: ${adapter.login_url}`);
|
|
333
|
+
}
|
|
334
|
+
console.log(`👀 请在 Chrome 中完成登录 ${adapter.platform},然后按 Enter...`);
|
|
335
|
+
await waitForEnter();
|
|
336
|
+
// Step 5: Verify login by checking for the cookie
|
|
337
|
+
const resTabs = await fetch(`http://localhost:${cdpPort}/json`);
|
|
338
|
+
const freshTabs = (await resTabs.json());
|
|
339
|
+
const freshPage = freshTabs.filter(t => t.type === 'page')[0];
|
|
340
|
+
if (!freshPage) {
|
|
341
|
+
console.error('❌ Chrome 中没有打开的页面');
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
// Save session from the browser
|
|
345
|
+
const { connectTab } = await import('../browser/cdp.js');
|
|
346
|
+
const { captureSession } = await import('../browser/session.js');
|
|
347
|
+
const client = await connectTab(cdpPort);
|
|
348
|
+
const saved = await captureSession(client, adapter.platform);
|
|
349
|
+
client.close();
|
|
350
|
+
const ok = saved.some(c => c.name === adapter.login_check.cookie);
|
|
351
|
+
if (!ok) {
|
|
352
|
+
console.error(`❌ 未检测到登录 cookie (${adapter.login_check.cookie}),请确认已登录后重试`);
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
console.log(`✅ 登录成功,session 已保存\n`);
|
|
356
|
+
return freshPage.webSocketDebuggerUrl;
|
|
357
|
+
}
|
|
358
|
+
function waitForEnter() {
|
|
359
|
+
return new Promise((resolve) => {
|
|
360
|
+
process.stdin.setRawMode?.(false);
|
|
361
|
+
process.stdin.resume();
|
|
362
|
+
process.stdout.write(' > ');
|
|
363
|
+
process.stdin.once('data', () => {
|
|
364
|
+
process.stdin.pause();
|
|
365
|
+
resolve();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { connectTab } from '../browser/cdp.js';
|
|
2
|
+
const client = await connectTab(9222);
|
|
3
|
+
await client.send('Page.bringToFront');
|
|
4
|
+
await new Promise(r => setTimeout(r, 300));
|
|
5
|
+
// Deep focus into bili-comments shadow DOM
|
|
6
|
+
const focused = await client.eval(`(function(){
|
|
7
|
+
var host = document.querySelector("bili-comments");
|
|
8
|
+
if(!host) return "no bili-comments";
|
|
9
|
+
function deepFocus(root){
|
|
10
|
+
var sr = root.shadowRoot;
|
|
11
|
+
if(!sr) return null;
|
|
12
|
+
var editor = sr.querySelector(".brt-editor");
|
|
13
|
+
if(editor) { editor.click(); editor.focus(); return "ok"; }
|
|
14
|
+
var els = sr.querySelectorAll("*");
|
|
15
|
+
for(var i=0; i<els.length; i++){
|
|
16
|
+
var r = deepFocus(els[i]);
|
|
17
|
+
if(r) return r;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return deepFocus(host) || "not found";
|
|
22
|
+
})()`);
|
|
23
|
+
console.log('focus result:', focused);
|
|
24
|
+
await new Promise(r => setTimeout(r, 500));
|
|
25
|
+
// Try Input.insertText
|
|
26
|
+
await client.send('Input.insertText', { text: 'hello shadow insertText' });
|
|
27
|
+
await new Promise(r => setTimeout(r, 500));
|
|
28
|
+
const content = await client.eval(`(function(){
|
|
29
|
+
var ed = document.querySelector("bili-comments")
|
|
30
|
+
.shadowRoot.querySelector("bili-comments-header-renderer")
|
|
31
|
+
.shadowRoot.querySelector("bili-comment-box")
|
|
32
|
+
.shadowRoot.querySelector("bili-comment-rich-textarea")
|
|
33
|
+
.shadowRoot.querySelector(".brt-editor");
|
|
34
|
+
return ed ? JSON.stringify({ text: ed.textContent.trim(), html: ed.innerHTML.slice(0,100) }) : "not found";
|
|
35
|
+
})()`);
|
|
36
|
+
console.log('editor content:', content);
|
|
37
|
+
client.close();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { connectTab } from '../browser/cdp.js';
|
|
2
|
+
const client = await connectTab(9222);
|
|
3
|
+
await client.send('Page.bringToFront');
|
|
4
|
+
await new Promise(r => setTimeout(r, 200));
|
|
5
|
+
// Get follow button (JbfEzak6) coordinates
|
|
6
|
+
const coords = await client.eval(`(function(){
|
|
7
|
+
var el = document.querySelector('.JbfEzak6');
|
|
8
|
+
if(!el) return 'null';
|
|
9
|
+
var r = el.getBoundingClientRect();
|
|
10
|
+
return JSON.stringify({ x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) });
|
|
11
|
+
})()`);
|
|
12
|
+
console.log('follow btn coords:', coords);
|
|
13
|
+
// check current state by checking parent avatar area
|
|
14
|
+
const beforeInfo = await client.eval(`JSON.stringify({
|
|
15
|
+
JbfEzak6Count: document.querySelectorAll('.JbfEzak6').length,
|
|
16
|
+
hasFollowedState: document.body.innerHTML.includes('已关注')
|
|
17
|
+
})`);
|
|
18
|
+
console.log('before:', JSON.parse(beforeInfo));
|
|
19
|
+
const { x, y } = JSON.parse(coords);
|
|
20
|
+
await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
|
|
21
|
+
await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
|
|
22
|
+
await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
|
|
23
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
24
|
+
const afterInfo = await client.eval(`JSON.stringify({
|
|
25
|
+
JbfEzak6Count: document.querySelectorAll('.JbfEzak6').length,
|
|
26
|
+
hasFollowedState: document.body.innerHTML.includes('已关注'),
|
|
27
|
+
bodySnippet: [...document.querySelectorAll('[class*=follow]')].map(function(el){ return el.className.slice(0,60) + ':' + el.textContent.trim().slice(0,20); }).join(' | ')
|
|
28
|
+
})`);
|
|
29
|
+
console.log('after:', JSON.parse(afterInfo));
|
|
30
|
+
client.close();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { newTab } from '../browser/cdp.js';
|
|
2
|
+
const client = await newTab(9222);
|
|
3
|
+
await client.navigate('https://x.com/search?q=AI+law&src=typed_query&f=top', 4000);
|
|
4
|
+
const info = await client.eval(`JSON.stringify({
|
|
5
|
+
url: location.href,
|
|
6
|
+
tweetItems: (() => {
|
|
7
|
+
var tweets = [...document.querySelectorAll('[data-testid="tweet"]')];
|
|
8
|
+
return tweets.slice(0,3).map(function(t){
|
|
9
|
+
return {
|
|
10
|
+
text: t.querySelector('[data-testid="tweetText"]')?.textContent?.trim()?.slice(0,60) || '',
|
|
11
|
+
user: t.querySelector('[data-testid="User-Name"]')?.textContent?.trim()?.slice(0,30) || '',
|
|
12
|
+
link: (() => {
|
|
13
|
+
var a = [...t.querySelectorAll('a')].find(function(a){ return /\\/status\\//.test(a.href); });
|
|
14
|
+
return a ? a.href : '';
|
|
15
|
+
})(),
|
|
16
|
+
time: t.querySelector('time')?.getAttribute('datetime') || ''
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
})()
|
|
20
|
+
})`);
|
|
21
|
+
const d = JSON.parse(info);
|
|
22
|
+
console.log('URL:', d.url);
|
|
23
|
+
console.log('\n搜索结果结构:');
|
|
24
|
+
d.tweetItems.forEach((t, i) => {
|
|
25
|
+
console.log(`\n[${i + 1}]`);
|
|
26
|
+
console.log(' text:', t.text);
|
|
27
|
+
console.log(' user:', t.user);
|
|
28
|
+
console.log(' link:', t.link);
|
|
29
|
+
console.log(' time:', t.time);
|
|
30
|
+
});
|
|
31
|
+
client.close();
|
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.2",
|
|
4
4
|
"description": "CDP-based social media automation CLI — X, 小红书, 抖音, B站, Temu",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"adapters"
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
|
+
"prepublishOnly": "tsc",
|
|
17
18
|
"dev": "tsx src/cli.ts",
|
|
18
19
|
"build": "tsc",
|
|
19
20
|
"x": "tsx src/cli.ts x",
|