@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.
@@ -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.0",
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",