@harness.farm/social-cli 0.1.1 → 0.1.3

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.
@@ -213,10 +213,31 @@ commands:
213
213
  - upload:
214
214
  selector: "input[accept*='.mp4']"
215
215
  file: "{{video}}"
216
- # 等待上传完成(标题输入框出现)
216
+ # 等待编辑器渲染(标题输入框出现)
217
217
  - wait:
218
218
  selector: "input[placeholder='请输入稿件标题']"
219
- - wait: 2000
219
+ - wait: 1000
220
+ # 等待视频上传+转码真正完成
221
+ # B站上传页:上传中显示进度百分比,完成后显示"上传完成"并且"立即投稿"按钮变为可点击
222
+ - wait_until:
223
+ eval: >-
224
+ (function(){
225
+ var body = document.body.innerText;
226
+ if (body.includes('上传完成') && !body.includes('上传中')) return true;
227
+ var progress = document.querySelector('.upload-progress, [class*=progress], [class*=uploading]');
228
+ if (progress) {
229
+ var text = progress.textContent || '';
230
+ if (text.includes('100%')) return true;
231
+ }
232
+ var btn = document.querySelector('span.submit-add');
233
+ if (btn) {
234
+ var parent = btn.closest('button') || btn.parentElement;
235
+ if (parent && !parent.classList.contains('disabled') && !parent.hasAttribute('disabled')) return true;
236
+ }
237
+ return false;
238
+ })()
239
+ timeout: 300000
240
+ interval: 3000
220
241
  # 关掉所有弹窗(二创计划 / 定时发布 / 通知)
221
242
  - eval: >-
222
243
  (function(){
@@ -115,6 +115,22 @@ commands:
115
115
  - wait:
116
116
  selector: "input[placeholder='填写作品标题,为作品获得更多流量']"
117
117
  - wait: 1000
118
+ # 等待视频上传完成(进度条消失 或 上传成功文字出现)
119
+ # 抖音上传中会显示进度百分比,上传完成后进度区域消失或显示"上传完成"
120
+ - wait_until:
121
+ eval: >-
122
+ (function(){
123
+ var body = document.body.innerText;
124
+ if (body.includes('上传完成') || body.includes('Upload complete')) return true;
125
+ var progress = document.querySelector('.progress-bar, .upload-progress, [class*=progress]');
126
+ if (!progress) return true;
127
+ var text = progress.textContent || '';
128
+ if (text.includes('100%')) return true;
129
+ var btn = [...document.querySelectorAll('button')].find(function(b){ return b.textContent.trim() === '发布' && !b.disabled; });
130
+ return !!btn;
131
+ })()
132
+ timeout: 300000
133
+ interval: 3000
118
134
  # 填写标题
119
135
  - fill:
120
136
  selector: "input[placeholder='填写作品标题,为作品获得更多流量']"
@@ -165,9 +181,12 @@ commands:
165
181
  - capture:
166
182
  name: result_url
167
183
  eval: "location.href"
184
+ - capture:
185
+ name: success
186
+ eval: "document.body.innerText.includes('发布成功') || document.body.innerText.includes('作品发布') || location.href.includes('/content/manage') || location.href !== 'https://creator.douyin.com/creator-micro/content/upload'"
168
187
  - return:
169
188
  - field: 状态
170
- value: "✅ 发布成功"
189
+ value: "{{success === 'true' ? '✅ 发布成功' : '⚠️ 请检查页面'}}"
171
190
  - field: 标题
172
191
  value: "{{title}}"
173
192
  - field: 描述
@@ -0,0 +1,2 @@
1
+ export class Adapter {
2
+ }
@@ -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
+ }