@xbongbong/xbbcli 2.2.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -7
- package/auth-login.js +306 -0
- package/auth-logout.js +175 -0
- package/auth-status.js +105 -0
- package/{ai-call-add-task.js → hkh-call-add-task.js} +1 -1
- package/{ai-word-list.js → hkh-word-list.js} +1 -1
- package/orginal-text-list.js +131 -0
- package/package.json +2 -2
- package/token-set.js +5 -389
- package/xbb-browser.js +330 -0
- package/xbb-config.js +33 -0
- package/xbb-token-store.js +395 -0
package/xbb-browser.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const LOGIN_PAGE_URL = 'https://appwebfront.xbongbong.com/stand-alone-login.html#/';
|
|
7
|
+
export const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.xbbcli', 'browser-profile');
|
|
8
|
+
|
|
9
|
+
const WINDOWS_BROWSER_PATHS = [
|
|
10
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
11
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
12
|
+
path.join(os.homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
13
|
+
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
14
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const MACOS_BROWSER_PATHS = [
|
|
18
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
19
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
20
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const LINUX_BROWSER_NAMES = [
|
|
24
|
+
'google-chrome',
|
|
25
|
+
'google-chrome-stable',
|
|
26
|
+
'microsoft-edge',
|
|
27
|
+
'microsoft-edge-stable',
|
|
28
|
+
'chromium',
|
|
29
|
+
'chromium-browser',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// 别名 -> { names: PATH 查找用名, keywords: 安装目录关键字 }
|
|
33
|
+
const DEFAULT_BROWSER_ALIASES = [
|
|
34
|
+
{ names: ['google-chrome', 'chrome'], keywords: ['chrome'] },
|
|
35
|
+
{ names: ['microsoft-edge', 'msedge'], keywords: ['edge'] },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function delay(ms) {
|
|
39
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isFile(file) {
|
|
43
|
+
try {
|
|
44
|
+
return fs.statSync(file).isFile();
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findOnPath(names) {
|
|
51
|
+
const dirs = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
52
|
+
for (const name of names) {
|
|
53
|
+
for (const dir of dirs) {
|
|
54
|
+
const candidate = path.join(dir, name);
|
|
55
|
+
if (isFile(candidate)) return candidate;
|
|
56
|
+
if (process.platform === 'win32' && isFile(`${candidate}.exe`)) return `${candidate}.exe`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function platformCandidates() {
|
|
63
|
+
if (process.platform === 'win32') return WINDOWS_BROWSER_PATHS;
|
|
64
|
+
if (process.platform === 'darwin') return MACOS_BROWSER_PATHS;
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveAlias(alias) {
|
|
69
|
+
const lower = alias.toLowerCase();
|
|
70
|
+
const group = DEFAULT_BROWSER_ALIASES.find((item) => item.keywords.includes(lower) || item.names.includes(lower));
|
|
71
|
+
if (!group) return '';
|
|
72
|
+
if (process.platform !== 'linux') {
|
|
73
|
+
// 关键字命中的候选里还要过滤掉不存在的路径,否则会误报空结果
|
|
74
|
+
const candidate = platformCandidates()
|
|
75
|
+
.filter((item) => group.keywords.some((keyword) => path.basename(item).toLowerCase().includes(keyword)))
|
|
76
|
+
.find((item) => isFile(item));
|
|
77
|
+
if (candidate) return candidate;
|
|
78
|
+
}
|
|
79
|
+
return findOnPath(group.names);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --browser 支持三种写法:浏览器别名(chrome/edge)、可执行文件绝对路径、不传则自动探测
|
|
83
|
+
export function findBrowserExecutable(explicit) {
|
|
84
|
+
const value = String(explicit ?? '').trim();
|
|
85
|
+
if (value) {
|
|
86
|
+
if (isFile(value)) return value;
|
|
87
|
+
return resolveAlias(value);
|
|
88
|
+
}
|
|
89
|
+
for (const candidate of platformCandidates()) {
|
|
90
|
+
if (isFile(candidate)) return candidate;
|
|
91
|
+
}
|
|
92
|
+
if (process.platform === 'linux') {
|
|
93
|
+
const found = findOnPath(LINUX_BROWSER_NAMES);
|
|
94
|
+
if (found) return found;
|
|
95
|
+
}
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Chrome 的 --remote-debugging-pipe 约定:fd3 写入 CDP 指令,fd4 读取 CDP 消息(NUL 分隔的 JSON)
|
|
100
|
+
function createPipeClient(child) {
|
|
101
|
+
const input = child.stdio[3];
|
|
102
|
+
const output = child.stdio[4];
|
|
103
|
+
const pending = new Map();
|
|
104
|
+
let buffer = Buffer.alloc(0);
|
|
105
|
+
let nextId = 0;
|
|
106
|
+
let closed = false;
|
|
107
|
+
|
|
108
|
+
output.on('data', (chunk) => {
|
|
109
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
110
|
+
let index = buffer.indexOf(0);
|
|
111
|
+
while (index !== -1) {
|
|
112
|
+
const raw = buffer.subarray(0, index).toString('utf8');
|
|
113
|
+
buffer = buffer.subarray(index + 1);
|
|
114
|
+
index = buffer.indexOf(0);
|
|
115
|
+
if (!raw) continue;
|
|
116
|
+
let message;
|
|
117
|
+
try {
|
|
118
|
+
message = JSON.parse(raw);
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const resolve = pending.get(message.id);
|
|
123
|
+
if (!resolve) continue;
|
|
124
|
+
pending.delete(message.id);
|
|
125
|
+
resolve(message);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
output.on('error', () => {});
|
|
130
|
+
input.on('error', () => {});
|
|
131
|
+
|
|
132
|
+
function rejectAll(reason) {
|
|
133
|
+
closed = true;
|
|
134
|
+
for (const resolve of pending.values()) resolve({ error: { message: reason } });
|
|
135
|
+
pending.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
child.once('exit', () => rejectAll('浏览器进程已退出'));
|
|
139
|
+
child.once('error', (error) => rejectAll(String(error.message || error)));
|
|
140
|
+
|
|
141
|
+
function send(method, params = {}, sessionId) {
|
|
142
|
+
if (closed) return Promise.reject(new Error('浏览器进程已退出'));
|
|
143
|
+
const id = nextId + 1;
|
|
144
|
+
nextId = id;
|
|
145
|
+
const payload = { id, method, params };
|
|
146
|
+
if (sessionId) payload.sessionId = sessionId;
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
pending.set(id, (message) => {
|
|
149
|
+
if (message.error) reject(new Error(`${method}: ${message.error.message || 'CDP 调用失败'}`));
|
|
150
|
+
else resolve(message.result || {});
|
|
151
|
+
});
|
|
152
|
+
input.write(`${JSON.stringify(payload)}\0`);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { send };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const XBB_PAGE_PATTERN = /xbongbong\.com/i;
|
|
160
|
+
|
|
161
|
+
export async function openBrowser(options = {}) {
|
|
162
|
+
const executablePath = findBrowserExecutable(options.browser);
|
|
163
|
+
if (!executablePath) {
|
|
164
|
+
throw new Error('未找到 Chrome 或 Edge 浏览器,请用 --browser <可执行文件路径> 指定');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Chrome 要求 --user-data-dir 为绝对路径,相对路径会导致进程直接退出(exit code 21)
|
|
168
|
+
const userDataDir = path.resolve(String(options.userDataDir || '').trim() || DEFAULT_PROFILE_DIR);
|
|
169
|
+
fs.mkdirSync(userDataDir, { recursive: true });
|
|
170
|
+
|
|
171
|
+
const args = [
|
|
172
|
+
'--remote-debugging-pipe',
|
|
173
|
+
'--no-first-run',
|
|
174
|
+
'--no-default-browser-check',
|
|
175
|
+
'--disable-background-networking',
|
|
176
|
+
'--disable-client-side-phishing-detection',
|
|
177
|
+
'--disable-default-apps',
|
|
178
|
+
'--disable-sync',
|
|
179
|
+
'--no-service-autorun',
|
|
180
|
+
`--user-data-dir=${userDataDir}`,
|
|
181
|
+
];
|
|
182
|
+
if (options.headless) args.push('--headless=new');
|
|
183
|
+
args.push('--new-window', String(options.url || LOGIN_PAGE_URL));
|
|
184
|
+
|
|
185
|
+
const child = spawn(executablePath, args, {
|
|
186
|
+
stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
|
|
187
|
+
windowsHide: Boolean(options.headless),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// 保留最后几行 stderr,Chrome 启动失败(如 profile 被占用)时用于提示用户
|
|
191
|
+
const stderrLines = [];
|
|
192
|
+
child.stderr.on('data', (chunk) => {
|
|
193
|
+
for (const line of String(chunk).split(/\r?\n/)) {
|
|
194
|
+
if (line.trim()) stderrLines.push(line.trim());
|
|
195
|
+
}
|
|
196
|
+
if (stderrLines.length > 5) stderrLines.splice(0, stderrLines.length - 5);
|
|
197
|
+
});
|
|
198
|
+
child.stderr.on('error', () => {});
|
|
199
|
+
|
|
200
|
+
const { send } = createPipeClient(child);
|
|
201
|
+
let exited = false;
|
|
202
|
+
child.once('exit', () => { exited = true; });
|
|
203
|
+
|
|
204
|
+
const session = {
|
|
205
|
+
executablePath,
|
|
206
|
+
userDataDir,
|
|
207
|
+
child,
|
|
208
|
+
send,
|
|
209
|
+
stderrText() {
|
|
210
|
+
return stderrLines.join('\n');
|
|
211
|
+
},
|
|
212
|
+
attachedTargetId: '',
|
|
213
|
+
attachedSessionId: '',
|
|
214
|
+
pageReady: false,
|
|
215
|
+
isClosed() {
|
|
216
|
+
return exited || child.exitCode !== null;
|
|
217
|
+
},
|
|
218
|
+
async version() {
|
|
219
|
+
const result = await send('Browser.getVersion');
|
|
220
|
+
return String(result.product || '');
|
|
221
|
+
},
|
|
222
|
+
async pageSession(force) {
|
|
223
|
+
const result = await send('Target.getTargets');
|
|
224
|
+
const targets = result.targetInfos || [];
|
|
225
|
+
const page = targets.find((item) => item.type === 'page' && XBB_PAGE_PATTERN.test(item.url || ''));
|
|
226
|
+
if (!page) return '';
|
|
227
|
+
if (force || session.attachedTargetId !== page.targetId) {
|
|
228
|
+
const attached = await send('Target.attachToTarget', { targetId: page.targetId, flatten: true });
|
|
229
|
+
session.attachedTargetId = page.targetId;
|
|
230
|
+
session.attachedSessionId = attached.sessionId;
|
|
231
|
+
}
|
|
232
|
+
return session.attachedSessionId;
|
|
233
|
+
},
|
|
234
|
+
// 页面可能尚未提交导航、或登录跳转会重建执行上下文:失败时重新 attach 并重试
|
|
235
|
+
async evaluate(expression, options = {}) {
|
|
236
|
+
const attempts = Number(options.attempts) > 0 ? Number(options.attempts) : 6;
|
|
237
|
+
const delayMs = Number(options.delayMs) > 0 ? Number(options.delayMs) : 250;
|
|
238
|
+
let lastError = null;
|
|
239
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
240
|
+
try {
|
|
241
|
+
const sessionId = await session.pageSession(attempt > 0);
|
|
242
|
+
if (!sessionId) throw new Error('未找到销帮帮页面');
|
|
243
|
+
const result = await send('Runtime.evaluate', {
|
|
244
|
+
expression,
|
|
245
|
+
returnByValue: true,
|
|
246
|
+
awaitPromise: true,
|
|
247
|
+
}, sessionId);
|
|
248
|
+
if (result.exceptionDetails) {
|
|
249
|
+
const message = result.exceptionDetails.text
|
|
250
|
+
|| result.exceptionDetails.exception?.description
|
|
251
|
+
|| '页面脚本执行失败';
|
|
252
|
+
throw new Error(message);
|
|
253
|
+
}
|
|
254
|
+
session.pageReady = true;
|
|
255
|
+
return result.result ? result.result.value : undefined;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
lastError = error;
|
|
258
|
+
session.attachedSessionId = '';
|
|
259
|
+
session.attachedTargetId = '';
|
|
260
|
+
if (attempt + 1 < attempts) await delay(delayMs);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
throw lastError || new Error('页面脚本执行失败');
|
|
264
|
+
},
|
|
265
|
+
// 等待页面真正落到销帮帮域名,避免在 about:blank 上读写 localStorage 抛 SecurityError
|
|
266
|
+
async waitForPage(timeoutMs) {
|
|
267
|
+
const deadline = Date.now() + (Number(timeoutMs) > 0 ? Number(timeoutMs) : 30000);
|
|
268
|
+
while (Date.now() < deadline) {
|
|
269
|
+
if (session.isClosed()) throw new Error(`浏览器已关闭${session.stderrText() ? `:${session.stderrText()}` : ''}`);
|
|
270
|
+
try {
|
|
271
|
+
const href = await session.evaluate('location.href', { attempts: 1 });
|
|
272
|
+
if (typeof href === 'string' && XBB_PAGE_PATTERN.test(href) && !/^about:/.test(href)) return href;
|
|
273
|
+
} catch {
|
|
274
|
+
// 页面尚未就绪,继续等待
|
|
275
|
+
}
|
|
276
|
+
await delay(250);
|
|
277
|
+
}
|
|
278
|
+
throw new Error('等待销帮帮登录页加载超时');
|
|
279
|
+
},
|
|
280
|
+
async close() {
|
|
281
|
+
try {
|
|
282
|
+
await send('Browser.close');
|
|
283
|
+
} catch {
|
|
284
|
+
// 忽略:进程可能已退出
|
|
285
|
+
}
|
|
286
|
+
if (await session.waitForExit(1500)) return true;
|
|
287
|
+
// Browser.close 未生效(部分 Edge/Chrome 版本):强制结束并确认退出
|
|
288
|
+
try {
|
|
289
|
+
child.kill();
|
|
290
|
+
} catch {
|
|
291
|
+
// 忽略
|
|
292
|
+
}
|
|
293
|
+
return session.waitForExit(3000);
|
|
294
|
+
},
|
|
295
|
+
waitForExit(timeoutMs) {
|
|
296
|
+
if (session.isClosed()) return Promise.resolve(true);
|
|
297
|
+
return new Promise((resolve) => {
|
|
298
|
+
const timer = setTimeout(() => {
|
|
299
|
+
child.removeListener('exit', onExit);
|
|
300
|
+
resolve(session.isClosed());
|
|
301
|
+
}, timeoutMs);
|
|
302
|
+
function onExit() {
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
resolve(true);
|
|
305
|
+
}
|
|
306
|
+
child.once('exit', onExit);
|
|
307
|
+
});
|
|
308
|
+
},
|
|
309
|
+
// --keepOpen:断开 CDP 管道并解除引用,让浏览器独立留在桌面上
|
|
310
|
+
detach() {
|
|
311
|
+
try {
|
|
312
|
+
child.stdio[3].end();
|
|
313
|
+
} catch {
|
|
314
|
+
// 忽略
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
child.stdio[4].destroy();
|
|
318
|
+
} catch {
|
|
319
|
+
// 忽略
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
child.unref();
|
|
323
|
+
} catch {
|
|
324
|
+
// 忽略
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
return session;
|
|
330
|
+
}
|
package/xbb-config.js
CHANGED
|
@@ -111,3 +111,36 @@ export function persistEnvVars(values) {
|
|
|
111
111
|
fs.writeFileSync(ENV_FILE, `${lines.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
112
112
|
return { stored: 'file', file: ENV_FILE, failed: [] };
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
// auth-logout --env:清除 XBB_* 环境变量(Windows 删除用户级变量,其他平台清理 env.sh 里的 export 行)
|
|
116
|
+
export function clearEnvVars() {
|
|
117
|
+
const names = getEnvVarNames();
|
|
118
|
+
for (const name of names) {
|
|
119
|
+
delete process.env[name];
|
|
120
|
+
}
|
|
121
|
+
if (process.platform === 'win32') {
|
|
122
|
+
const failed = [];
|
|
123
|
+
let deleted = 0;
|
|
124
|
+
for (const name of names) {
|
|
125
|
+
const result = spawnSync('reg', ['delete', 'HKCU\\Environment', '/f', '/v', name], { encoding: 'utf8', windowsHide: true });
|
|
126
|
+
// 变量原本就不存在时 reg 返回非 0,这里只把进程无法启动视为失败
|
|
127
|
+
if (result.error) failed.push(name);
|
|
128
|
+
else if (result.status === 0) deleted += 1;
|
|
129
|
+
}
|
|
130
|
+
if (failed.length) {
|
|
131
|
+
throw new Error(`清除用户环境变量失败:${failed.join(', ')}`);
|
|
132
|
+
}
|
|
133
|
+
return { stored: deleted ? 'user' : 'none', file: '' };
|
|
134
|
+
}
|
|
135
|
+
if (!fs.existsSync(ENV_FILE)) {
|
|
136
|
+
return { stored: 'none', file: ENV_FILE };
|
|
137
|
+
}
|
|
138
|
+
const pattern = new RegExp('^\\s*export\\s+(' + names.join('|') + ')(\\s|=)');
|
|
139
|
+
const kept = fs.readFileSync(ENV_FILE, 'utf8').split(/\r?\n/).filter((line) => line.trim() !== '' && !pattern.test(line));
|
|
140
|
+
if (!kept.length) {
|
|
141
|
+
fs.rmSync(ENV_FILE, { force: true });
|
|
142
|
+
return { stored: 'removed', file: ENV_FILE };
|
|
143
|
+
}
|
|
144
|
+
fs.writeFileSync(ENV_FILE, `${kept.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
145
|
+
return { stored: 'cleared', file: ENV_FILE };
|
|
146
|
+
}
|