@jackwener/opencli 1.6.7 → 1.6.9
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 +5 -1
- package/README.zh-CN.md +8 -3
- package/dist/clis/1688/assets.d.ts +42 -0
- package/dist/clis/1688/assets.js +204 -0
- package/dist/clis/1688/assets.test.d.ts +1 -0
- package/dist/clis/1688/assets.test.js +39 -0
- package/dist/clis/1688/download.d.ts +9 -0
- package/dist/clis/1688/download.js +76 -0
- package/dist/clis/1688/download.test.d.ts +1 -0
- package/dist/clis/1688/download.test.js +31 -0
- package/dist/clis/1688/shared.d.ts +10 -0
- package/dist/clis/1688/shared.js +43 -0
- package/dist/clis/jianyu/search.d.ts +14 -0
- package/dist/clis/jianyu/search.js +135 -0
- package/dist/clis/jianyu/search.test.d.ts +1 -0
- package/dist/clis/jianyu/search.test.js +23 -0
- package/dist/clis/linux-do/topic-content.d.ts +35 -0
- package/dist/clis/linux-do/topic-content.js +154 -0
- package/dist/clis/linux-do/topic-content.test.d.ts +1 -0
- package/dist/clis/linux-do/topic-content.test.js +59 -0
- package/dist/clis/linux-do/topic.yaml +1 -16
- package/dist/clis/quark/ls.d.ts +1 -0
- package/dist/clis/quark/ls.js +63 -0
- package/dist/clis/quark/mkdir.d.ts +1 -0
- package/dist/clis/quark/mkdir.js +36 -0
- package/dist/clis/quark/mv.d.ts +1 -0
- package/dist/clis/quark/mv.js +53 -0
- package/dist/clis/quark/rename.d.ts +1 -0
- package/dist/clis/quark/rename.js +26 -0
- package/dist/clis/quark/rm.d.ts +1 -0
- package/dist/clis/quark/rm.js +24 -0
- package/dist/clis/quark/save.d.ts +1 -0
- package/dist/clis/quark/save.js +80 -0
- package/dist/clis/quark/share-tree.d.ts +1 -0
- package/dist/clis/quark/share-tree.js +45 -0
- package/dist/clis/quark/utils.d.ts +50 -0
- package/dist/clis/quark/utils.js +146 -0
- package/dist/clis/quark/utils.test.d.ts +1 -0
- package/dist/clis/quark/utils.test.js +58 -0
- package/dist/clis/twitter/reply.js +3 -8
- package/dist/clis/twitter/reply.test.js +5 -5
- package/dist/clis/xiaohongshu/note.js +8 -3
- package/dist/clis/xiaohongshu/note.test.js +11 -0
- package/dist/clis/xueqiu/groups.yaml +23 -0
- package/dist/clis/xueqiu/kline.yaml +65 -0
- package/dist/clis/xueqiu/watchlist.yaml +9 -9
- package/dist/clis/zhihu/answer.d.ts +1 -0
- package/dist/clis/zhihu/answer.js +194 -0
- package/dist/clis/zhihu/answer.test.d.ts +1 -0
- package/dist/clis/zhihu/answer.test.js +81 -0
- package/dist/clis/zhihu/comment.d.ts +1 -0
- package/dist/clis/zhihu/comment.js +335 -0
- package/dist/clis/zhihu/comment.test.d.ts +1 -0
- package/dist/clis/zhihu/comment.test.js +54 -0
- package/dist/clis/zhihu/favorite.d.ts +1 -0
- package/dist/clis/zhihu/favorite.js +224 -0
- package/dist/clis/zhihu/favorite.test.d.ts +1 -0
- package/dist/clis/zhihu/favorite.test.js +196 -0
- package/dist/clis/zhihu/follow.d.ts +1 -0
- package/dist/clis/zhihu/follow.js +80 -0
- package/dist/clis/zhihu/follow.test.d.ts +1 -0
- package/dist/clis/zhihu/follow.test.js +45 -0
- package/dist/clis/zhihu/like.d.ts +1 -0
- package/dist/clis/zhihu/like.js +91 -0
- package/dist/clis/zhihu/like.test.d.ts +1 -0
- package/dist/clis/zhihu/like.test.js +64 -0
- package/dist/clis/zhihu/target.d.ts +24 -0
- package/dist/clis/zhihu/target.js +91 -0
- package/dist/clis/zhihu/target.test.d.ts +1 -0
- package/dist/clis/zhihu/target.test.js +77 -0
- package/dist/clis/zhihu/write-shared.d.ts +32 -0
- package/dist/clis/zhihu/write-shared.js +221 -0
- package/dist/clis/zhihu/write-shared.test.d.ts +1 -0
- package/dist/clis/zhihu/write-shared.test.js +175 -0
- package/dist/src/analysis.d.ts +2 -0
- package/dist/src/analysis.js +6 -0
- package/dist/src/browser/bridge.d.ts +2 -0
- package/dist/src/browser/bridge.js +30 -24
- package/dist/src/browser/cdp.js +96 -0
- package/dist/src/browser/daemon-client.d.ts +17 -8
- package/dist/src/browser/daemon-client.js +12 -13
- package/dist/src/browser/daemon-client.test.js +32 -25
- package/dist/src/browser/index.d.ts +2 -1
- package/dist/src/browser/index.js +1 -1
- package/dist/src/browser.test.js +2 -3
- package/dist/src/build-manifest.d.ts +3 -1
- package/dist/src/build-manifest.js +10 -7
- package/dist/src/build-manifest.test.js +8 -4
- package/dist/src/cli.d.ts +2 -1
- package/dist/src/cli.js +48 -46
- package/dist/src/clis/binance/commands.test.d.ts +1 -0
- package/dist/src/clis/binance/commands.test.js +54 -0
- package/dist/src/commanderAdapter.js +19 -6
- package/dist/src/commands/daemon.js +2 -10
- package/dist/src/diagnostic.d.ts +28 -2
- package/dist/src/diagnostic.js +263 -25
- package/dist/src/diagnostic.test.js +220 -1
- package/dist/src/discovery.js +7 -17
- package/dist/src/doctor.d.ts +2 -0
- package/dist/src/doctor.js +59 -31
- package/dist/src/doctor.test.js +89 -16
- package/dist/src/download/progress.js +7 -2
- package/dist/src/execution.js +1 -13
- package/dist/src/explore.d.ts +0 -2
- package/dist/src/explore.js +61 -38
- package/dist/src/extension-manifest-regression.test.js +0 -1
- package/dist/src/generate.d.ts +3 -6
- package/dist/src/generate.js +4 -8
- package/dist/src/package-paths.d.ts +8 -0
- package/dist/src/package-paths.js +41 -0
- package/dist/src/plugin-scaffold.js +1 -3
- package/dist/src/plugin.d.ts +2 -1
- package/dist/src/plugin.js +25 -8
- package/dist/src/plugin.test.js +16 -1
- package/dist/src/record.d.ts +1 -2
- package/dist/src/record.js +14 -52
- package/dist/src/synthesize.d.ts +0 -2
- package/dist/src/synthesize.js +8 -4
- package/package.json +3 -3
- package/dist/cli-manifest.json +0 -17250
- package/dist/src/browser/discover.d.ts +0 -15
- package/dist/src/browser/discover.js +0 -19
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { CliError } from '@jackwener/opencli/errors';
|
|
6
|
+
import { __test__ } from './write-shared.js';
|
|
7
|
+
class FakeNode {
|
|
8
|
+
attrs;
|
|
9
|
+
textContent;
|
|
10
|
+
hasAvatar;
|
|
11
|
+
constructor(attrs, textContent = null, hasAvatar = false) {
|
|
12
|
+
this.attrs = attrs;
|
|
13
|
+
this.textContent = textContent;
|
|
14
|
+
this.hasAvatar = hasAvatar;
|
|
15
|
+
}
|
|
16
|
+
getAttribute(name) {
|
|
17
|
+
return this.attrs[name] ?? null;
|
|
18
|
+
}
|
|
19
|
+
querySelector(selector) {
|
|
20
|
+
if (this.hasAvatar && selector.includes('img'))
|
|
21
|
+
return {};
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
class FakeRoot {
|
|
26
|
+
selectors;
|
|
27
|
+
constructor(selectors) {
|
|
28
|
+
this.selectors = selectors;
|
|
29
|
+
}
|
|
30
|
+
querySelectorAll(selector) {
|
|
31
|
+
return this.selectors[selector] ?? [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function createPageForDom(documentRoot, state = undefined) {
|
|
35
|
+
return {
|
|
36
|
+
evaluate: vi.fn().mockImplementation(async (js) => {
|
|
37
|
+
const previousDocument = globalThis.document;
|
|
38
|
+
const previousWindow = globalThis.window;
|
|
39
|
+
const previousState = globalThis.__INITIAL_STATE__;
|
|
40
|
+
const windowObject = { __INITIAL_STATE__: state };
|
|
41
|
+
try {
|
|
42
|
+
Object.assign(globalThis, {
|
|
43
|
+
document: documentRoot,
|
|
44
|
+
window: windowObject,
|
|
45
|
+
__INITIAL_STATE__: state,
|
|
46
|
+
});
|
|
47
|
+
return globalThis.eval(js);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
Object.assign(globalThis, {
|
|
51
|
+
document: previousDocument,
|
|
52
|
+
window: previousWindow,
|
|
53
|
+
__INITIAL_STATE__: previousState,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
describe('zhihu write shared helpers', () => {
|
|
60
|
+
it('rejects missing --execute', () => {
|
|
61
|
+
expect(() => __test__.requireExecute({})).toThrowError(CliError);
|
|
62
|
+
});
|
|
63
|
+
it('accepts a non-empty text payload', async () => {
|
|
64
|
+
await expect(__test__.resolvePayload({ text: 'hello' })).resolves.toBe('hello');
|
|
65
|
+
});
|
|
66
|
+
it('rejects whitespace-only payloads', async () => {
|
|
67
|
+
await expect(__test__.resolvePayload({ text: ' ' })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
|
|
68
|
+
});
|
|
69
|
+
it('rejects missing file payloads as INVALID_INPUT', async () => {
|
|
70
|
+
await expect(__test__.resolvePayload({ file: join(tmpdir(), 'zhihu-write-shared-missing.txt') })).rejects.toMatchObject({
|
|
71
|
+
code: 'INVALID_INPUT',
|
|
72
|
+
message: expect.stringContaining('File not found'),
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
it('rejects invalid UTF-8 file payloads as INVALID_INPUT', async () => {
|
|
76
|
+
const dir = await mkdtemp(join(tmpdir(), 'zhihu-write-shared-'));
|
|
77
|
+
const file = join(dir, 'payload.txt');
|
|
78
|
+
await writeFile(file, Buffer.from([0xc3, 0x28]));
|
|
79
|
+
try {
|
|
80
|
+
await expect(__test__.resolvePayload({ file })).rejects.toMatchObject({
|
|
81
|
+
code: 'INVALID_INPUT',
|
|
82
|
+
message: expect.stringContaining('decoded as UTF-8'),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await rm(dir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
it('rejects generic file read failures as INVALID_INPUT', async () => {
|
|
90
|
+
const dir = await mkdtemp(join(tmpdir(), 'zhihu-write-shared-'));
|
|
91
|
+
const file = join(dir, 'payload.txt');
|
|
92
|
+
await writeFile(file, 'hello');
|
|
93
|
+
try {
|
|
94
|
+
await expect(__test__.resolvePayload({ file }, {
|
|
95
|
+
stat: async () => ({ isFile: () => true }),
|
|
96
|
+
readFile: async () => {
|
|
97
|
+
throw new Error('boom');
|
|
98
|
+
},
|
|
99
|
+
decodeUtf8: (raw) => new TextDecoder('utf-8', { fatal: true }).decode(raw),
|
|
100
|
+
})).rejects.toMatchObject({
|
|
101
|
+
code: 'INVALID_INPUT',
|
|
102
|
+
message: expect.stringContaining('could not be read'),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await rm(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
it('prefers the state slug before DOM fallback', async () => {
|
|
110
|
+
const documentRoot = new FakeRoot({
|
|
111
|
+
'header, nav, [role="banner"], [role="navigation"]': [],
|
|
112
|
+
'a[href^="/people/"]': [new FakeNode({ href: '/people/not-used', 'data-testid': 'profile-link' }, null, true)],
|
|
113
|
+
});
|
|
114
|
+
expect(__test__.resolveCurrentUserSlugFromDom({ me: { slug: 'alice' } }, documentRoot)).toBe('alice');
|
|
115
|
+
});
|
|
116
|
+
it('accepts nav avatar links as a conservative fallback', async () => {
|
|
117
|
+
const navRoot = new FakeRoot({
|
|
118
|
+
'a[href^="/people/"]': [new FakeNode({ href: '/people/alice' }, null, true)],
|
|
119
|
+
});
|
|
120
|
+
const documentRoot = new FakeRoot({
|
|
121
|
+
'header, nav, [role="banner"], [role="navigation"]': [navRoot],
|
|
122
|
+
'a[href^="/people/"]': [],
|
|
123
|
+
});
|
|
124
|
+
expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBe('alice');
|
|
125
|
+
});
|
|
126
|
+
it('accepts document-wide fallback only for explicit account/profile signals', async () => {
|
|
127
|
+
const documentRoot = new FakeRoot({
|
|
128
|
+
'header, nav, [role="banner"], [role="navigation"]': [],
|
|
129
|
+
'a[href^="/people/"]': [
|
|
130
|
+
new FakeNode({ href: '/people/alice', 'data-testid': 'account-profile-link' }),
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBe('alice');
|
|
134
|
+
});
|
|
135
|
+
it('does not accept a document-wide author avatar link as current-user fallback', async () => {
|
|
136
|
+
const documentRoot = new FakeRoot({
|
|
137
|
+
'header, nav, [role="banner"], [role="navigation"]': [],
|
|
138
|
+
'a[href^="/people/"]': [new FakeNode({ href: '/people/author-1' }, 'Author', true)],
|
|
139
|
+
});
|
|
140
|
+
expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
it('does not accept generic document metadata like user or dropdown alone', async () => {
|
|
143
|
+
const documentRoot = new FakeRoot({
|
|
144
|
+
'header, nav, [role="banner"], [role="navigation"]': [],
|
|
145
|
+
'a[href^="/people/"]': [
|
|
146
|
+
new FakeNode({ href: '/people/author-1', 'data-testid': 'user-menu-dropdown' }, 'Author'),
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
it('freezes a stable current-user identity before write', async () => {
|
|
152
|
+
const navRoot = new FakeRoot({
|
|
153
|
+
'a[href^="/people/"]': [new FakeNode({ href: '/people/alice' }, null, true)],
|
|
154
|
+
});
|
|
155
|
+
const documentRoot = new FakeRoot({
|
|
156
|
+
'header, nav, [role="banner"], [role="navigation"]': [navRoot],
|
|
157
|
+
'a[href^="/people/"]': [],
|
|
158
|
+
});
|
|
159
|
+
const page = createPageForDom(documentRoot);
|
|
160
|
+
await expect(__test__.resolveCurrentUserIdentity(page)).resolves.toBe('alice');
|
|
161
|
+
});
|
|
162
|
+
it('rejects when current-user identity cannot be resolved', async () => {
|
|
163
|
+
const documentRoot = new FakeRoot({
|
|
164
|
+
'header, nav, [role="banner"], [role="navigation"]': [],
|
|
165
|
+
'a[href^="/people/"]': [],
|
|
166
|
+
});
|
|
167
|
+
const page = createPageForDom(documentRoot);
|
|
168
|
+
await expect(__test__.resolveCurrentUserIdentity(page)).rejects.toMatchObject({
|
|
169
|
+
code: 'ACTION_NOT_AVAILABLE',
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
it('rejects reserved buildResultRow extra keys', () => {
|
|
173
|
+
expect(() => __test__.buildResultRow('done', 'question', '123', 'applied', { status: 'oops' })).toThrowError(CliError);
|
|
174
|
+
});
|
|
175
|
+
});
|
package/dist/src/analysis.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export declare function inferStrategy(authIndicators: string[]): string;
|
|
|
29
29
|
export declare function detectAuthFromHeaders(headers?: Record<string, string>): string[];
|
|
30
30
|
/** Detect auth indicators from URL and response body (heuristic). */
|
|
31
31
|
export declare function detectAuthFromContent(url: string, body: unknown): string[];
|
|
32
|
+
/** Check whether a URL looks like tracking/telemetry noise rather than a business API. */
|
|
33
|
+
export declare function isNoiseUrl(url: string): boolean;
|
|
32
34
|
/** Extract non-volatile query params and classify them. */
|
|
33
35
|
export declare function classifyQueryParams(url: string): {
|
|
34
36
|
params: string[];
|
package/dist/src/analysis.js
CHANGED
|
@@ -148,6 +148,12 @@ export function detectAuthFromContent(url, body) {
|
|
|
148
148
|
indicators.push('bearer');
|
|
149
149
|
return indicators;
|
|
150
150
|
}
|
|
151
|
+
// ── Noise filtering ─────────────────────────────────────────────────────────
|
|
152
|
+
const NOISE_URL_PATTERN = /\/(track|log|analytics|beacon|pixel|ping|heartbeat|keep.?alive)\b/i;
|
|
153
|
+
/** Check whether a URL looks like tracking/telemetry noise rather than a business API. */
|
|
154
|
+
export function isNoiseUrl(url) {
|
|
155
|
+
return NOISE_URL_PATTERN.test(url);
|
|
156
|
+
}
|
|
151
157
|
// ── Query param classification ──────────────────────────────────────────────
|
|
152
158
|
/** Extract non-volatile query params and classify them. */
|
|
153
159
|
export function classifyQueryParams(url) {
|
|
@@ -6,8 +6,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import * as path from 'node:path';
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import { Page } from './page.js';
|
|
9
|
-
import {
|
|
9
|
+
import { getDaemonHealth } from './daemon-client.js';
|
|
10
10
|
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
11
|
+
import { BrowserConnectError } from '../errors.js';
|
|
11
12
|
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
|
|
12
13
|
/**
|
|
13
14
|
* Browser factory: manages daemon lifecycle and provides IPage instances.
|
|
@@ -52,25 +53,22 @@ export class BrowserBridge {
|
|
|
52
53
|
async _ensureDaemon(timeoutSeconds) {
|
|
53
54
|
const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
|
|
54
55
|
const timeoutMs = effectiveSeconds * 1000;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (status?.extensionConnected)
|
|
56
|
+
const health = await getDaemonHealth();
|
|
57
|
+
// Fast path: everything ready
|
|
58
|
+
if (health.state === 'ready')
|
|
59
59
|
return;
|
|
60
60
|
// Daemon running but no extension — wait for extension with progress
|
|
61
|
-
if (
|
|
61
|
+
if (health.state === 'no-extension') {
|
|
62
62
|
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
|
63
63
|
process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
|
|
64
64
|
process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
|
|
65
65
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
throw new Error('Daemon is running but the Browser Extension is not connected.\n' +
|
|
73
|
-
'Please install and enable the opencli Browser Bridge extension in Chrome or Chromium.');
|
|
66
|
+
if (await this._pollUntilReady(timeoutMs))
|
|
67
|
+
return;
|
|
68
|
+
throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
|
|
69
|
+
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
|
|
70
|
+
' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
|
|
71
|
+
' Then run: opencli doctor', 'extension-not-connected');
|
|
74
72
|
}
|
|
75
73
|
// No daemon — spawn one
|
|
76
74
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -91,19 +89,27 @@ export class BrowserBridge {
|
|
|
91
89
|
env: { ...process.env },
|
|
92
90
|
});
|
|
93
91
|
this._daemonProc.unref();
|
|
94
|
-
// Wait for daemon + extension
|
|
92
|
+
// Wait for daemon + extension
|
|
93
|
+
if (await this._pollUntilReady(timeoutMs))
|
|
94
|
+
return;
|
|
95
|
+
const finalHealth = await getDaemonHealth();
|
|
96
|
+
if (finalHealth.state === 'no-extension') {
|
|
97
|
+
throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
|
|
98
|
+
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
|
|
99
|
+
' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
|
|
100
|
+
' Then run: opencli doctor', 'extension-not-connected');
|
|
101
|
+
}
|
|
102
|
+
throw new BrowserConnectError('Failed to start opencli daemon', `Try running manually:\n node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, 'daemon-not-running');
|
|
103
|
+
}
|
|
104
|
+
/** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
|
|
105
|
+
async _pollUntilReady(timeoutMs) {
|
|
95
106
|
const deadline = Date.now() + timeoutMs;
|
|
96
107
|
while (Date.now() < deadline) {
|
|
97
108
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if ((await fetchDaemonStatus()) !== null) {
|
|
102
|
-
throw new Error('Daemon is running but the Browser Extension is not connected.\n' +
|
|
103
|
-
'Please install and enable the opencli Browser Bridge extension in Chrome or Chromium.');
|
|
109
|
+
const h = await getDaemonHealth();
|
|
110
|
+
if (h.state === 'ready')
|
|
111
|
+
return true;
|
|
104
112
|
}
|
|
105
|
-
|
|
106
|
-
` node ${daemonPath}\n` +
|
|
107
|
-
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`);
|
|
113
|
+
return false;
|
|
108
114
|
}
|
|
109
115
|
}
|
package/dist/src/browser/cdp.js
CHANGED
|
@@ -136,6 +136,14 @@ export class CDPBridge {
|
|
|
136
136
|
class CDPPage extends BasePage {
|
|
137
137
|
bridge;
|
|
138
138
|
_pageEnabled = false;
|
|
139
|
+
// Network capture state (mirrors extension/src/cdp.ts NetworkCaptureEntry shape)
|
|
140
|
+
_networkCapturing = false;
|
|
141
|
+
_networkCapturePattern = '';
|
|
142
|
+
_networkEntries = [];
|
|
143
|
+
_pendingRequests = new Map(); // requestId → index in _networkEntries
|
|
144
|
+
_pendingBodyFetches = new Set(); // track in-flight getResponseBody calls
|
|
145
|
+
_consoleMessages = [];
|
|
146
|
+
_consoleCapturing = false;
|
|
139
147
|
constructor(bridge) {
|
|
140
148
|
super();
|
|
141
149
|
this.bridge = bridge;
|
|
@@ -186,6 +194,94 @@ class CDPPage extends BasePage {
|
|
|
186
194
|
}
|
|
187
195
|
return base64;
|
|
188
196
|
}
|
|
197
|
+
async startNetworkCapture(pattern = '') {
|
|
198
|
+
this._networkCapturePattern = pattern;
|
|
199
|
+
this._networkEntries = [];
|
|
200
|
+
this._pendingRequests.clear();
|
|
201
|
+
this._pendingBodyFetches.clear();
|
|
202
|
+
if (!this._networkCapturing) {
|
|
203
|
+
await this.bridge.send('Network.enable');
|
|
204
|
+
// Step 1: Record request method/url on requestWillBeSent
|
|
205
|
+
this.bridge.on('Network.requestWillBeSent', (params) => {
|
|
206
|
+
const p = params;
|
|
207
|
+
if (!pattern || p.request.url.includes(pattern)) {
|
|
208
|
+
const idx = this._networkEntries.push({
|
|
209
|
+
url: p.request.url,
|
|
210
|
+
method: p.request.method,
|
|
211
|
+
timestamp: p.timestamp,
|
|
212
|
+
}) - 1;
|
|
213
|
+
this._pendingRequests.set(p.requestId, idx);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
// Step 2: Fill in response metadata on responseReceived
|
|
217
|
+
this.bridge.on('Network.responseReceived', (params) => {
|
|
218
|
+
const p = params;
|
|
219
|
+
const idx = this._pendingRequests.get(p.requestId);
|
|
220
|
+
if (idx !== undefined) {
|
|
221
|
+
this._networkEntries[idx].responseStatus = p.response.status;
|
|
222
|
+
this._networkEntries[idx].responseContentType = p.response.mimeType || '';
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
// Step 3: Fetch body on loadingFinished (body is only reliably available after this)
|
|
226
|
+
this.bridge.on('Network.loadingFinished', (params) => {
|
|
227
|
+
const p = params;
|
|
228
|
+
const idx = this._pendingRequests.get(p.requestId);
|
|
229
|
+
if (idx !== undefined) {
|
|
230
|
+
const bodyFetch = this.bridge.send('Network.getResponseBody', { requestId: p.requestId }).then((result) => {
|
|
231
|
+
const r = result;
|
|
232
|
+
if (typeof r?.body === 'string') {
|
|
233
|
+
this._networkEntries[idx].responsePreview = r.base64Encoded
|
|
234
|
+
? `base64:${r.body.slice(0, 4000)}`
|
|
235
|
+
: r.body.slice(0, 4000);
|
|
236
|
+
}
|
|
237
|
+
}).catch(() => {
|
|
238
|
+
// Body unavailable for some requests (e.g. uploads) — non-fatal
|
|
239
|
+
}).finally(() => {
|
|
240
|
+
this._pendingBodyFetches.delete(bodyFetch);
|
|
241
|
+
});
|
|
242
|
+
this._pendingBodyFetches.add(bodyFetch);
|
|
243
|
+
this._pendingRequests.delete(p.requestId);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
this._networkCapturing = true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async readNetworkCapture() {
|
|
250
|
+
// Await all in-flight body fetches so entries have responsePreview populated
|
|
251
|
+
if (this._pendingBodyFetches.size > 0) {
|
|
252
|
+
await Promise.all([...this._pendingBodyFetches]);
|
|
253
|
+
}
|
|
254
|
+
const entries = [...this._networkEntries];
|
|
255
|
+
this._networkEntries = [];
|
|
256
|
+
return entries;
|
|
257
|
+
}
|
|
258
|
+
async consoleMessages(level = 'all') {
|
|
259
|
+
if (!this._consoleCapturing) {
|
|
260
|
+
await this.bridge.send('Runtime.enable');
|
|
261
|
+
this.bridge.on('Runtime.consoleAPICalled', (params) => {
|
|
262
|
+
const p = params;
|
|
263
|
+
const text = (p.args || []).map(a => a.value !== undefined ? String(a.value) : (a.description || '')).join(' ');
|
|
264
|
+
this._consoleMessages.push({ type: p.type, text, timestamp: p.timestamp });
|
|
265
|
+
if (this._consoleMessages.length > 500)
|
|
266
|
+
this._consoleMessages.shift();
|
|
267
|
+
});
|
|
268
|
+
// Capture uncaught exceptions as error-level messages
|
|
269
|
+
this.bridge.on('Runtime.exceptionThrown', (params) => {
|
|
270
|
+
const p = params;
|
|
271
|
+
const desc = p.exceptionDetails?.exception?.description || p.exceptionDetails?.text || 'Unknown exception';
|
|
272
|
+
this._consoleMessages.push({ type: 'error', text: desc, timestamp: p.timestamp });
|
|
273
|
+
if (this._consoleMessages.length > 500)
|
|
274
|
+
this._consoleMessages.shift();
|
|
275
|
+
});
|
|
276
|
+
this._consoleCapturing = true;
|
|
277
|
+
}
|
|
278
|
+
if (level === 'all')
|
|
279
|
+
return [...this._consoleMessages];
|
|
280
|
+
// 'error' level includes both console.error() and uncaught exceptions
|
|
281
|
+
if (level === 'error')
|
|
282
|
+
return this._consoleMessages.filter(m => m.type === 'error' || m.type === 'warning');
|
|
283
|
+
return this._consoleMessages.filter(m => m.type === level);
|
|
284
|
+
}
|
|
189
285
|
async tabs() {
|
|
190
286
|
return [];
|
|
191
287
|
}
|
|
@@ -50,17 +50,26 @@ export interface DaemonStatus {
|
|
|
50
50
|
export declare function fetchDaemonStatus(opts?: {
|
|
51
51
|
timeout?: number;
|
|
52
52
|
}): Promise<DaemonStatus | null>;
|
|
53
|
+
export type DaemonHealth = {
|
|
54
|
+
state: 'stopped';
|
|
55
|
+
status: null;
|
|
56
|
+
} | {
|
|
57
|
+
state: 'no-extension';
|
|
58
|
+
status: DaemonStatus;
|
|
59
|
+
} | {
|
|
60
|
+
state: 'ready';
|
|
61
|
+
status: DaemonStatus;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Unified daemon health check — single entry point for all status queries.
|
|
65
|
+
* Replaces isDaemonRunning(), isExtensionConnected(), and checkDaemonStatus().
|
|
66
|
+
*/
|
|
67
|
+
export declare function getDaemonHealth(opts?: {
|
|
68
|
+
timeout?: number;
|
|
69
|
+
}): Promise<DaemonHealth>;
|
|
53
70
|
export declare function requestDaemonShutdown(opts?: {
|
|
54
71
|
timeout?: number;
|
|
55
72
|
}): Promise<boolean>;
|
|
56
|
-
/**
|
|
57
|
-
* Check if daemon is running.
|
|
58
|
-
*/
|
|
59
|
-
export declare function isDaemonRunning(): Promise<boolean>;
|
|
60
|
-
/**
|
|
61
|
-
* Check if daemon is running AND the extension is connected.
|
|
62
|
-
*/
|
|
63
|
-
export declare function isExtensionConnected(): Promise<boolean>;
|
|
64
73
|
/**
|
|
65
74
|
* Send a command to the daemon and wait for a result.
|
|
66
75
|
* Retries up to 4 times: network errors retry at 500ms,
|
|
@@ -39,6 +39,18 @@ export async function fetchDaemonStatus(opts) {
|
|
|
39
39
|
return null;
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Unified daemon health check — single entry point for all status queries.
|
|
44
|
+
* Replaces isDaemonRunning(), isExtensionConnected(), and checkDaemonStatus().
|
|
45
|
+
*/
|
|
46
|
+
export async function getDaemonHealth(opts) {
|
|
47
|
+
const status = await fetchDaemonStatus(opts);
|
|
48
|
+
if (!status)
|
|
49
|
+
return { state: 'stopped', status: null };
|
|
50
|
+
if (!status.extensionConnected)
|
|
51
|
+
return { state: 'no-extension', status };
|
|
52
|
+
return { state: 'ready', status };
|
|
53
|
+
}
|
|
42
54
|
export async function requestDaemonShutdown(opts) {
|
|
43
55
|
try {
|
|
44
56
|
const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
|
|
@@ -48,19 +60,6 @@ export async function requestDaemonShutdown(opts) {
|
|
|
48
60
|
return false;
|
|
49
61
|
}
|
|
50
62
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Check if daemon is running.
|
|
53
|
-
*/
|
|
54
|
-
export async function isDaemonRunning() {
|
|
55
|
-
return (await fetchDaemonStatus()) !== null;
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Check if daemon is running AND the extension is connected.
|
|
59
|
-
*/
|
|
60
|
-
export async function isExtensionConnected() {
|
|
61
|
-
const status = await fetchDaemonStatus();
|
|
62
|
-
return !!status?.extensionConnected;
|
|
63
|
-
}
|
|
64
63
|
/**
|
|
65
64
|
* Send a command to the daemon and wait for a result.
|
|
66
65
|
* Retries up to 4 times: network errors retry at 500ms,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { fetchDaemonStatus,
|
|
2
|
+
import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, } from './daemon-client.js';
|
|
3
3
|
describe('daemon-client', () => {
|
|
4
4
|
beforeEach(() => {
|
|
5
5
|
vi.stubGlobal('fetch', vi.fn());
|
|
@@ -42,36 +42,43 @@ describe('daemon-client', () => {
|
|
|
42
42
|
headers: expect.objectContaining({ 'X-OpenCLI': '1' }),
|
|
43
43
|
}));
|
|
44
44
|
});
|
|
45
|
-
it('
|
|
45
|
+
it('getDaemonHealth returns stopped when daemon is not reachable', async () => {
|
|
46
|
+
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
|
|
47
|
+
await expect(getDaemonHealth()).resolves.toEqual({ state: 'stopped', status: null });
|
|
48
|
+
});
|
|
49
|
+
it('getDaemonHealth returns no-extension when daemon is running but extension disconnected', async () => {
|
|
50
|
+
const status = {
|
|
51
|
+
ok: true,
|
|
52
|
+
pid: 123,
|
|
53
|
+
uptime: 10,
|
|
54
|
+
extensionConnected: false,
|
|
55
|
+
pending: 0,
|
|
56
|
+
lastCliRequestTime: Date.now(),
|
|
57
|
+
memoryMB: 16,
|
|
58
|
+
port: 19825,
|
|
59
|
+
};
|
|
46
60
|
vi.mocked(fetch).mockResolvedValue({
|
|
47
61
|
ok: true,
|
|
48
|
-
json: () => Promise.resolve(
|
|
49
|
-
ok: true,
|
|
50
|
-
pid: 123,
|
|
51
|
-
uptime: 10,
|
|
52
|
-
extensionConnected: false,
|
|
53
|
-
pending: 0,
|
|
54
|
-
lastCliRequestTime: Date.now(),
|
|
55
|
-
memoryMB: 16,
|
|
56
|
-
port: 19825,
|
|
57
|
-
}),
|
|
62
|
+
json: () => Promise.resolve(status),
|
|
58
63
|
});
|
|
59
|
-
await expect(
|
|
64
|
+
await expect(getDaemonHealth()).resolves.toEqual({ state: 'no-extension', status });
|
|
60
65
|
});
|
|
61
|
-
it('
|
|
66
|
+
it('getDaemonHealth returns ready when daemon and extension are both connected', async () => {
|
|
67
|
+
const status = {
|
|
68
|
+
ok: true,
|
|
69
|
+
pid: 123,
|
|
70
|
+
uptime: 10,
|
|
71
|
+
extensionConnected: true,
|
|
72
|
+
extensionVersion: '1.2.3',
|
|
73
|
+
pending: 0,
|
|
74
|
+
lastCliRequestTime: Date.now(),
|
|
75
|
+
memoryMB: 32,
|
|
76
|
+
port: 19825,
|
|
77
|
+
};
|
|
62
78
|
vi.mocked(fetch).mockResolvedValue({
|
|
63
79
|
ok: true,
|
|
64
|
-
json: () => Promise.resolve(
|
|
65
|
-
ok: true,
|
|
66
|
-
pid: 123,
|
|
67
|
-
uptime: 10,
|
|
68
|
-
extensionConnected: false,
|
|
69
|
-
pending: 0,
|
|
70
|
-
lastCliRequestTime: Date.now(),
|
|
71
|
-
memoryMB: 16,
|
|
72
|
-
port: 19825,
|
|
73
|
-
}),
|
|
80
|
+
json: () => Promise.resolve(status),
|
|
74
81
|
});
|
|
75
|
-
await expect(
|
|
82
|
+
await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status });
|
|
76
83
|
});
|
|
77
84
|
});
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
export { Page } from './page.js';
|
|
8
8
|
export { BrowserBridge } from './bridge.js';
|
|
9
9
|
export { CDPBridge } from './cdp.js';
|
|
10
|
-
export {
|
|
10
|
+
export { getDaemonHealth } from './daemon-client.js';
|
|
11
|
+
export type { DaemonHealth } from './daemon-client.js';
|
|
11
12
|
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
12
13
|
export { generateStealthJs } from './stealth.js';
|
|
13
14
|
export type { DomSnapshotOptions } from './dom-snapshot.js';
|
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
export { Page } from './page.js';
|
|
8
8
|
export { BrowserBridge } from './bridge.js';
|
|
9
9
|
export { CDPBridge } from './cdp.js';
|
|
10
|
-
export {
|
|
10
|
+
export { getDaemonHealth } from './daemon-client.js';
|
|
11
11
|
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
12
12
|
export { generateStealthJs } from './stealth.js';
|
package/dist/src/browser.test.js
CHANGED
|
@@ -105,10 +105,9 @@ describe('BrowserBridge state', () => {
|
|
|
105
105
|
await expect(bridge.connect()).rejects.toThrow('Session is closing');
|
|
106
106
|
});
|
|
107
107
|
it('fails fast when daemon is running but extension is disconnected', async () => {
|
|
108
|
-
vi.spyOn(daemonClient, '
|
|
109
|
-
vi.spyOn(daemonClient, 'fetchDaemonStatus').mockResolvedValue({ extensionConnected: false });
|
|
108
|
+
vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({ state: 'no-extension', status: { extensionConnected: false } });
|
|
110
109
|
const bridge = new BrowserBridge();
|
|
111
|
-
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser
|
|
110
|
+
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected');
|
|
112
111
|
});
|
|
113
112
|
});
|
|
114
113
|
describe('stealth anti-detection', () => {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* manifest.json for instant cold-start registration (no runtime YAML parsing).
|
|
7
7
|
*
|
|
8
8
|
* Usage: npx tsx src/build-manifest.ts
|
|
9
|
-
* Output:
|
|
9
|
+
* Output: cli-manifest.json at the package root
|
|
10
10
|
*/
|
|
11
11
|
export interface ManifestEntry {
|
|
12
12
|
site: string;
|
|
@@ -35,6 +35,8 @@ export interface ManifestEntry {
|
|
|
35
35
|
type: 'yaml' | 'ts';
|
|
36
36
|
/** Relative path from clis/ dir, e.g. 'bilibili/hot.yaml' or 'bilibili/search.js' */
|
|
37
37
|
modulePath?: string;
|
|
38
|
+
/** Relative path to the original source file from clis/ dir (for YAML: 'site/cmd.yaml') */
|
|
39
|
+
sourceFile?: string;
|
|
38
40
|
/** Pre-navigation control — see CliCommand.navigateBefore */
|
|
39
41
|
navigateBefore?: boolean | string;
|
|
40
42
|
}
|