@jackwener/opencli 0.6.1 → 0.6.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.
- package/.github/actions/setup-chrome/action.yml +26 -0
- package/.github/workflows/ci.yml +59 -3
- package/.github/workflows/e2e-headed.yml +37 -0
- package/README.md +20 -1
- package/README.zh-CN.md +1 -1
- package/TESTING.md +233 -0
- package/dist/bilibili.js +2 -2
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +12 -3
- package/dist/browser.test.js +56 -16
- package/dist/cli-manifest.json +39 -0
- package/dist/clis/boss/detail.d.ts +1 -0
- package/dist/clis/boss/detail.js +104 -0
- package/dist/clis/boss/search.js +2 -1
- package/dist/interceptor.test.d.ts +4 -0
- package/dist/interceptor.test.js +81 -0
- package/dist/output.test.d.ts +3 -0
- package/dist/output.test.js +60 -0
- package/dist/pipeline/executor.js +0 -6
- package/dist/pipeline/executor.test.d.ts +4 -0
- package/dist/pipeline/executor.test.js +145 -0
- package/dist/pipeline/steps/fetch.js +4 -3
- package/dist/registry.d.ts +2 -2
- package/package.json +4 -4
- package/src/bilibili.ts +2 -2
- package/src/browser.test.ts +54 -16
- package/src/browser.ts +11 -3
- package/src/clis/boss/detail.ts +115 -0
- package/src/clis/boss/search.ts +2 -1
- package/src/clis/twitter/notifications.ts +1 -1
- package/src/engine.ts +2 -2
- package/src/interceptor.test.ts +94 -0
- package/src/output.test.ts +69 -4
- package/src/pipeline/executor.test.ts +161 -0
- package/src/pipeline/executor.ts +0 -5
- package/src/pipeline/steps/fetch.ts +4 -3
- package/src/registry.ts +2 -2
- package/tests/e2e/browser-auth.test.ts +90 -0
- package/tests/e2e/browser-public.test.ts +169 -0
- package/tests/e2e/helpers.ts +63 -0
- package/tests/e2e/management.test.ts +106 -0
- package/tests/e2e/output-formats.test.ts +48 -0
- package/tests/e2e/public-commands.test.ts +56 -0
- package/tests/smoke/api-health.test.ts +72 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOSS直聘 job detail — fetch full job posting details via browser cookie API.
|
|
3
|
+
*
|
|
4
|
+
* Uses securityId from search results to call the detail API.
|
|
5
|
+
* Returns: job description, skills, welfare, boss info, company info, address.
|
|
6
|
+
*/
|
|
7
|
+
import { cli, Strategy } from '../../registry.js';
|
|
8
|
+
cli({
|
|
9
|
+
site: 'boss',
|
|
10
|
+
name: 'detail',
|
|
11
|
+
description: 'BOSS直聘查看职位详情',
|
|
12
|
+
domain: 'www.zhipin.com',
|
|
13
|
+
strategy: Strategy.COOKIE,
|
|
14
|
+
browser: true,
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'security_id', required: true, help: 'Security ID from search results (securityId field)' },
|
|
17
|
+
],
|
|
18
|
+
columns: [
|
|
19
|
+
'name', 'salary', 'experience', 'degree', 'city', 'district',
|
|
20
|
+
'description', 'skills', 'welfare',
|
|
21
|
+
'boss_name', 'boss_title', 'active_time',
|
|
22
|
+
'company', 'industry', 'scale', 'stage',
|
|
23
|
+
'address', 'url',
|
|
24
|
+
],
|
|
25
|
+
func: async (page, kwargs) => {
|
|
26
|
+
if (!page)
|
|
27
|
+
throw new Error('Browser page required');
|
|
28
|
+
const securityId = kwargs.security_id;
|
|
29
|
+
// Navigate to zhipin.com first to establish cookie context (referrer + cookies)
|
|
30
|
+
await page.goto('https://www.zhipin.com/web/geek/job');
|
|
31
|
+
await page.wait({ time: 1 });
|
|
32
|
+
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/job/detail.json?securityId=${encodeURIComponent(securityId)}`;
|
|
33
|
+
if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
|
|
34
|
+
console.error(`[opencli:boss] Fetching job detail...`);
|
|
35
|
+
}
|
|
36
|
+
const evaluateScript = `
|
|
37
|
+
async () => {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const xhr = new window.XMLHttpRequest();
|
|
40
|
+
xhr.open('GET', ${JSON.stringify(targetUrl)}, true);
|
|
41
|
+
xhr.withCredentials = true;
|
|
42
|
+
xhr.timeout = 15000;
|
|
43
|
+
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
|
44
|
+
xhr.onload = () => {
|
|
45
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
46
|
+
try {
|
|
47
|
+
resolve(JSON.parse(xhr.responseText));
|
|
48
|
+
} catch (e) {
|
|
49
|
+
reject(new Error('Failed to parse JSON. Raw (200 chars): ' + xhr.responseText.substring(0, 200)));
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
reject(new Error('XHR HTTP Status: ' + xhr.status));
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
xhr.onerror = () => reject(new Error('XHR Network Error'));
|
|
56
|
+
xhr.ontimeout = () => reject(new Error('XHR Timeout'));
|
|
57
|
+
xhr.send();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
let data;
|
|
62
|
+
try {
|
|
63
|
+
data = await page.evaluate(evaluateScript);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
throw new Error('API evaluate failed: ' + e.message);
|
|
67
|
+
}
|
|
68
|
+
if (data.code !== 0) {
|
|
69
|
+
if (data.code === 37) {
|
|
70
|
+
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`BOSS API error: ${data.message || 'Unknown'} (code=${data.code})`);
|
|
73
|
+
}
|
|
74
|
+
const zpData = data.zpData || {};
|
|
75
|
+
const jobInfo = zpData.jobInfo || {};
|
|
76
|
+
const bossInfo = zpData.bossInfo || {};
|
|
77
|
+
const brandComInfo = zpData.brandComInfo || {};
|
|
78
|
+
if (!jobInfo.jobName) {
|
|
79
|
+
throw new Error('该职位信息不存在或已下架');
|
|
80
|
+
}
|
|
81
|
+
return [{
|
|
82
|
+
name: jobInfo.jobName || '',
|
|
83
|
+
salary: jobInfo.salaryDesc || '',
|
|
84
|
+
experience: jobInfo.experienceName || '',
|
|
85
|
+
degree: jobInfo.degreeName || '',
|
|
86
|
+
city: jobInfo.locationName || '',
|
|
87
|
+
district: [jobInfo.areaDistrict, jobInfo.businessDistrict].filter(Boolean).join('·'),
|
|
88
|
+
description: jobInfo.postDescription || '',
|
|
89
|
+
skills: (jobInfo.showSkills || []).join(', '),
|
|
90
|
+
welfare: (brandComInfo.labels || []).join(', '),
|
|
91
|
+
boss_name: bossInfo.name || '',
|
|
92
|
+
boss_title: bossInfo.title || '',
|
|
93
|
+
active_time: bossInfo.activeTimeDesc || '',
|
|
94
|
+
company: brandComInfo.brandName || bossInfo.brandName || '',
|
|
95
|
+
industry: brandComInfo.industryName || '',
|
|
96
|
+
scale: brandComInfo.scaleName || '',
|
|
97
|
+
stage: brandComInfo.stageName || '',
|
|
98
|
+
address: jobInfo.address || '',
|
|
99
|
+
url: jobInfo.encryptId
|
|
100
|
+
? 'https://www.zhipin.com/job_detail/' + jobInfo.encryptId + '.html'
|
|
101
|
+
: '',
|
|
102
|
+
}];
|
|
103
|
+
},
|
|
104
|
+
});
|
package/dist/clis/boss/search.js
CHANGED
|
@@ -78,7 +78,7 @@ cli({
|
|
|
78
78
|
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
|
79
79
|
{ name: 'limit', type: 'int', default: 15, help: 'Number of results' },
|
|
80
80
|
],
|
|
81
|
-
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'url'],
|
|
81
|
+
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'],
|
|
82
82
|
func: async (page, kwargs) => {
|
|
83
83
|
if (!page)
|
|
84
84
|
throw new Error('Browser page required');
|
|
@@ -180,6 +180,7 @@ cli({
|
|
|
180
180
|
degree: j.jobDegree,
|
|
181
181
|
skills: (j.skills || []).join(','),
|
|
182
182
|
boss: j.bossName + ' · ' + j.bossTitle,
|
|
183
|
+
security_id: j.securityId || '',
|
|
183
184
|
url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
|
|
184
185
|
});
|
|
185
186
|
addedInBatch++;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for interceptor.ts: JavaScript code generators for XHR/Fetch interception.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { generateInterceptorJs, generateReadInterceptedJs, generateTapInterceptorJs } from './interceptor.js';
|
|
6
|
+
describe('generateInterceptorJs', () => {
|
|
7
|
+
it('generates valid JavaScript function source', () => {
|
|
8
|
+
const js = generateInterceptorJs('"api/search"');
|
|
9
|
+
expect(js).toContain('window.fetch');
|
|
10
|
+
expect(js).toContain('XMLHttpRequest');
|
|
11
|
+
expect(js).toContain('"api/search"');
|
|
12
|
+
// Should be a function expression wrapping
|
|
13
|
+
expect(js.trim()).toMatch(/^\(\)\s*=>/);
|
|
14
|
+
});
|
|
15
|
+
it('uses default array name and patch guard', () => {
|
|
16
|
+
const js = generateInterceptorJs('"test"');
|
|
17
|
+
expect(js).toContain('__opencli_intercepted');
|
|
18
|
+
expect(js).toContain('__opencli_interceptor_patched');
|
|
19
|
+
});
|
|
20
|
+
it('uses custom array name and patch guard', () => {
|
|
21
|
+
const js = generateInterceptorJs('"test"', {
|
|
22
|
+
arrayName: '__my_data',
|
|
23
|
+
patchGuard: '__my_guard',
|
|
24
|
+
});
|
|
25
|
+
expect(js).toContain('__my_data');
|
|
26
|
+
expect(js).toContain('__my_guard');
|
|
27
|
+
expect(js).not.toContain('__opencli_intercepted');
|
|
28
|
+
});
|
|
29
|
+
it('includes fetch clone and json parsing', () => {
|
|
30
|
+
const js = generateInterceptorJs('"api"');
|
|
31
|
+
expect(js).toContain('response.clone()');
|
|
32
|
+
expect(js).toContain('clone.json()');
|
|
33
|
+
});
|
|
34
|
+
it('includes XHR open and send patching', () => {
|
|
35
|
+
const js = generateInterceptorJs('"api"');
|
|
36
|
+
expect(js).toContain('XMLHttpRequest.prototype');
|
|
37
|
+
expect(js).toContain('__origOpen');
|
|
38
|
+
expect(js).toContain('__origSend');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe('generateReadInterceptedJs', () => {
|
|
42
|
+
it('generates valid JavaScript to read and clear data', () => {
|
|
43
|
+
const js = generateReadInterceptedJs();
|
|
44
|
+
expect(js).toContain('__opencli_intercepted');
|
|
45
|
+
// Should clear the array after reading
|
|
46
|
+
expect(js).toContain('= []');
|
|
47
|
+
});
|
|
48
|
+
it('uses custom array name', () => {
|
|
49
|
+
const js = generateReadInterceptedJs('__custom_arr');
|
|
50
|
+
expect(js).toContain('__custom_arr');
|
|
51
|
+
expect(js).not.toContain('__opencli_intercepted');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('generateTapInterceptorJs', () => {
|
|
55
|
+
it('returns all required fields', () => {
|
|
56
|
+
const tap = generateTapInterceptorJs('"api/data"');
|
|
57
|
+
expect(tap.setupVar).toBeDefined();
|
|
58
|
+
expect(tap.capturedVar).toBe('captured');
|
|
59
|
+
expect(tap.promiseVar).toBe('capturePromise');
|
|
60
|
+
expect(tap.resolveVar).toBe('captureResolve');
|
|
61
|
+
expect(tap.fetchPatch).toBeDefined();
|
|
62
|
+
expect(tap.xhrPatch).toBeDefined();
|
|
63
|
+
expect(tap.restorePatch).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
it('contains the capture pattern in setup', () => {
|
|
66
|
+
const tap = generateTapInterceptorJs('"my-pattern"');
|
|
67
|
+
expect(tap.setupVar).toContain('"my-pattern"');
|
|
68
|
+
});
|
|
69
|
+
it('restores original fetch and XHR in restorePatch', () => {
|
|
70
|
+
const tap = generateTapInterceptorJs('"test"');
|
|
71
|
+
expect(tap.restorePatch).toContain('origFetch');
|
|
72
|
+
expect(tap.restorePatch).toContain('origXhrOpen');
|
|
73
|
+
expect(tap.restorePatch).toContain('origXhrSend');
|
|
74
|
+
});
|
|
75
|
+
it('uses first-match capture (only first response)', () => {
|
|
76
|
+
const tap = generateTapInterceptorJs('"test"');
|
|
77
|
+
// Both fetch and xhr patches should check !captured before storing
|
|
78
|
+
expect(tap.fetchPatch).toContain('!captured');
|
|
79
|
+
expect(tap.xhrPatch).toContain('!captured');
|
|
80
|
+
});
|
|
81
|
+
});
|
package/dist/output.test.d.ts
CHANGED
package/dist/output.test.js
CHANGED
|
@@ -1,9 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for output.ts: render function format coverage.
|
|
3
|
+
*/
|
|
1
4
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
5
|
import { render } from './output.js';
|
|
3
6
|
afterEach(() => {
|
|
4
7
|
vi.restoreAllMocks();
|
|
5
8
|
});
|
|
6
9
|
describe('render', () => {
|
|
10
|
+
it('renders JSON output', () => {
|
|
11
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
12
|
+
render([{ title: 'Hello', rank: 1 }], { fmt: 'json' });
|
|
13
|
+
expect(log).toHaveBeenCalledOnce();
|
|
14
|
+
const output = log.mock.calls[0]?.[0];
|
|
15
|
+
const parsed = JSON.parse(output);
|
|
16
|
+
expect(parsed).toEqual([{ title: 'Hello', rank: 1 }]);
|
|
17
|
+
});
|
|
18
|
+
it('renders Markdown table output', () => {
|
|
19
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
20
|
+
render([{ name: 'Alice', score: 100 }], { fmt: 'md', columns: ['name', 'score'] });
|
|
21
|
+
const calls = log.mock.calls.map(c => c[0]);
|
|
22
|
+
expect(calls[0]).toContain('| name | score |');
|
|
23
|
+
expect(calls[1]).toContain('| --- | --- |');
|
|
24
|
+
expect(calls[2]).toContain('| Alice | 100 |');
|
|
25
|
+
});
|
|
26
|
+
it('renders CSV output with proper quoting', () => {
|
|
27
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
28
|
+
render([{ name: 'Alice, Bob', value: 'say "hi"' }], { fmt: 'csv' });
|
|
29
|
+
const calls = log.mock.calls.map(c => c[0]);
|
|
30
|
+
// Header
|
|
31
|
+
expect(calls[0]).toBe('name,value');
|
|
32
|
+
// Values with commas/quotes are quoted
|
|
33
|
+
expect(calls[1]).toContain('"Alice, Bob"');
|
|
34
|
+
expect(calls[1]).toContain('"say ""hi"""');
|
|
35
|
+
});
|
|
36
|
+
it('handles null and undefined data', () => {
|
|
37
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
38
|
+
render(null, { fmt: 'json' });
|
|
39
|
+
expect(log).toHaveBeenCalledWith(null);
|
|
40
|
+
});
|
|
41
|
+
it('renders single object as single-row table', () => {
|
|
42
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
43
|
+
render({ title: 'Test' }, { fmt: 'json' });
|
|
44
|
+
const output = log.mock.calls[0]?.[0];
|
|
45
|
+
const parsed = JSON.parse(output);
|
|
46
|
+
expect(parsed).toEqual({ title: 'Test' });
|
|
47
|
+
});
|
|
48
|
+
it('handles empty array gracefully', () => {
|
|
49
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
50
|
+
render([], { fmt: 'table' });
|
|
51
|
+
// Should show "(no data)" for empty arrays
|
|
52
|
+
expect(log).toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
it('uses custom columns for CSV', () => {
|
|
55
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
56
|
+
render([{ a: 1, b: 2, c: 3 }], { fmt: 'csv', columns: ['a', 'c'] });
|
|
57
|
+
const calls = log.mock.calls.map(c => c[0]);
|
|
58
|
+
expect(calls[0]).toBe('a,c');
|
|
59
|
+
expect(calls[1]).toBe('1,3');
|
|
60
|
+
});
|
|
7
61
|
it('renders YAML output', () => {
|
|
8
62
|
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
9
63
|
render([{ title: 'Hello', rank: 1 }], { fmt: 'yaml' });
|
|
@@ -17,4 +71,10 @@ describe('render', () => {
|
|
|
17
71
|
expect(log).toHaveBeenCalledOnce();
|
|
18
72
|
expect(log.mock.calls[0]?.[0]).toContain('title: Hello');
|
|
19
73
|
});
|
|
74
|
+
it('handles null values in CSV cells', () => {
|
|
75
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
76
|
+
render([{ name: 'test', value: null }], { fmt: 'csv' });
|
|
77
|
+
const calls = log.mock.calls.map(c => c[0]);
|
|
78
|
+
expect(calls[1]).toBe('test,');
|
|
79
|
+
});
|
|
20
80
|
});
|
|
@@ -45,12 +45,6 @@ export async function executePipeline(page, pipeline, ctx = {}) {
|
|
|
45
45
|
if (debug)
|
|
46
46
|
process.stderr.write(` ${chalk.yellow('⚠')} Unknown step: ${op}\n`);
|
|
47
47
|
}
|
|
48
|
-
// Detect error objects returned by steps (e.g. tap store not found)
|
|
49
|
-
if (data && typeof data === 'object' && !Array.isArray(data) && data.error) {
|
|
50
|
-
process.stderr.write(` ${chalk.yellow('⚠')} ${chalk.yellow(op)}: ${data.error}\n`);
|
|
51
|
-
if (data.hint)
|
|
52
|
-
process.stderr.write(` ${chalk.dim('💡')} ${chalk.dim(data.hint)}\n`);
|
|
53
|
-
}
|
|
54
48
|
if (debug)
|
|
55
49
|
debugStepResult(op, data);
|
|
56
50
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for pipeline/executor.ts: pipeline execution with mock page.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
5
|
+
import { executePipeline } from './index.js';
|
|
6
|
+
/** Create a minimal mock page for testing */
|
|
7
|
+
function createMockPage(overrides = {}) {
|
|
8
|
+
return {
|
|
9
|
+
goto: vi.fn(),
|
|
10
|
+
evaluate: vi.fn().mockResolvedValue(null),
|
|
11
|
+
snapshot: vi.fn().mockResolvedValue(''),
|
|
12
|
+
click: vi.fn(),
|
|
13
|
+
typeText: vi.fn(),
|
|
14
|
+
pressKey: vi.fn(),
|
|
15
|
+
wait: vi.fn(),
|
|
16
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
17
|
+
closeTab: vi.fn(),
|
|
18
|
+
newTab: vi.fn(),
|
|
19
|
+
selectTab: vi.fn(),
|
|
20
|
+
networkRequests: vi.fn().mockResolvedValue([]),
|
|
21
|
+
consoleMessages: vi.fn().mockResolvedValue(''),
|
|
22
|
+
scroll: vi.fn(),
|
|
23
|
+
autoScroll: vi.fn(),
|
|
24
|
+
installInterceptor: vi.fn(),
|
|
25
|
+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
describe('executePipeline', () => {
|
|
30
|
+
it('returns null for empty pipeline', async () => {
|
|
31
|
+
const result = await executePipeline(null, []);
|
|
32
|
+
expect(result).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
it('skips null/invalid steps', async () => {
|
|
35
|
+
const result = await executePipeline(null, [null, undefined, 42]);
|
|
36
|
+
expect(result).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
it('executes navigate step', async () => {
|
|
39
|
+
const page = createMockPage();
|
|
40
|
+
await executePipeline(page, [
|
|
41
|
+
{ navigate: 'https://example.com' },
|
|
42
|
+
]);
|
|
43
|
+
expect(page.goto).toHaveBeenCalledWith('https://example.com');
|
|
44
|
+
});
|
|
45
|
+
it('executes evaluate + select pipeline', async () => {
|
|
46
|
+
const page = createMockPage({
|
|
47
|
+
evaluate: vi.fn().mockResolvedValue({ data: { list: [{ name: 'a' }, { name: 'b' }] } }),
|
|
48
|
+
});
|
|
49
|
+
const result = await executePipeline(page, [
|
|
50
|
+
{ evaluate: '() => ({ data: { list: [{name: "a"}, {name: "b"}] } })' },
|
|
51
|
+
{ select: 'data.list' },
|
|
52
|
+
]);
|
|
53
|
+
expect(result).toEqual([{ name: 'a' }, { name: 'b' }]);
|
|
54
|
+
});
|
|
55
|
+
it('executes map step to transform items', async () => {
|
|
56
|
+
const page = createMockPage({
|
|
57
|
+
evaluate: vi.fn().mockResolvedValue([
|
|
58
|
+
{ title: 'Hello', count: 10 },
|
|
59
|
+
{ title: 'World', count: 20 },
|
|
60
|
+
]),
|
|
61
|
+
});
|
|
62
|
+
const result = await executePipeline(page, [
|
|
63
|
+
{ evaluate: 'test' },
|
|
64
|
+
{ map: { name: '${{ item.title }}', score: '${{ item.count }}' } },
|
|
65
|
+
]);
|
|
66
|
+
expect(result).toEqual([
|
|
67
|
+
{ name: 'Hello', score: 10 },
|
|
68
|
+
{ name: 'World', score: 20 },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
it('executes limit step', async () => {
|
|
72
|
+
const page = createMockPage({
|
|
73
|
+
evaluate: vi.fn().mockResolvedValue([1, 2, 3, 4, 5]),
|
|
74
|
+
});
|
|
75
|
+
const result = await executePipeline(page, [
|
|
76
|
+
{ evaluate: 'test' },
|
|
77
|
+
{ limit: '3' },
|
|
78
|
+
]);
|
|
79
|
+
expect(result).toEqual([1, 2, 3]);
|
|
80
|
+
});
|
|
81
|
+
it('executes sort step', async () => {
|
|
82
|
+
const page = createMockPage({
|
|
83
|
+
evaluate: vi.fn().mockResolvedValue([{ n: 3 }, { n: 1 }, { n: 2 }]),
|
|
84
|
+
});
|
|
85
|
+
const result = await executePipeline(page, [
|
|
86
|
+
{ evaluate: 'test' },
|
|
87
|
+
{ sort: { by: 'n', order: 'asc' } },
|
|
88
|
+
]);
|
|
89
|
+
expect(result).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]);
|
|
90
|
+
});
|
|
91
|
+
it('executes sort step with desc order', async () => {
|
|
92
|
+
const page = createMockPage({
|
|
93
|
+
evaluate: vi.fn().mockResolvedValue([{ n: 1 }, { n: 3 }, { n: 2 }]),
|
|
94
|
+
});
|
|
95
|
+
const result = await executePipeline(page, [
|
|
96
|
+
{ evaluate: 'test' },
|
|
97
|
+
{ sort: { by: 'n', order: 'desc' } },
|
|
98
|
+
]);
|
|
99
|
+
expect(result).toEqual([{ n: 3 }, { n: 2 }, { n: 1 }]);
|
|
100
|
+
});
|
|
101
|
+
it('executes wait step with number', async () => {
|
|
102
|
+
const page = createMockPage();
|
|
103
|
+
await executePipeline(page, [
|
|
104
|
+
{ wait: 2 },
|
|
105
|
+
]);
|
|
106
|
+
expect(page.wait).toHaveBeenCalledWith(2);
|
|
107
|
+
});
|
|
108
|
+
it('handles unknown steps gracefully in debug mode', async () => {
|
|
109
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
110
|
+
await executePipeline(null, [
|
|
111
|
+
{ unknownStep: 'test' },
|
|
112
|
+
], { debug: true });
|
|
113
|
+
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Unknown step'));
|
|
114
|
+
stderr.mockRestore();
|
|
115
|
+
});
|
|
116
|
+
it('passes args through template rendering', async () => {
|
|
117
|
+
const page = createMockPage({
|
|
118
|
+
evaluate: vi.fn().mockResolvedValue([1, 2, 3, 4, 5]),
|
|
119
|
+
});
|
|
120
|
+
const result = await executePipeline(page, [
|
|
121
|
+
{ evaluate: 'test' },
|
|
122
|
+
{ limit: '${{ args.count }}' },
|
|
123
|
+
], { args: { count: 2 } });
|
|
124
|
+
expect(result).toEqual([1, 2]);
|
|
125
|
+
});
|
|
126
|
+
it('click step calls page.click', async () => {
|
|
127
|
+
const page = createMockPage();
|
|
128
|
+
await executePipeline(page, [
|
|
129
|
+
{ click: '@5' },
|
|
130
|
+
]);
|
|
131
|
+
expect(page.click).toHaveBeenCalledWith('5');
|
|
132
|
+
});
|
|
133
|
+
it('navigate preserves existing data through pipeline', async () => {
|
|
134
|
+
const page = createMockPage({
|
|
135
|
+
evaluate: vi.fn().mockResolvedValue([{ a: 1 }]),
|
|
136
|
+
});
|
|
137
|
+
const result = await executePipeline(page, [
|
|
138
|
+
{ evaluate: 'test' },
|
|
139
|
+
{ navigate: 'https://example.com' },
|
|
140
|
+
]);
|
|
141
|
+
// navigate should preserve existing data
|
|
142
|
+
expect(result).toEqual([{ a: 1 }]);
|
|
143
|
+
expect(page.goto).toHaveBeenCalledWith('https://example.com');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -34,11 +34,12 @@ async function fetchSingle(page, url, method, queryParams, headers, args, data)
|
|
|
34
34
|
return resp.json();
|
|
35
35
|
}
|
|
36
36
|
const headersJs = JSON.stringify(renderedHeaders);
|
|
37
|
-
const
|
|
37
|
+
const urlJs = JSON.stringify(finalUrl);
|
|
38
|
+
const methodJs = JSON.stringify(method.toUpperCase());
|
|
38
39
|
return page.evaluate(`
|
|
39
40
|
async () => {
|
|
40
|
-
const resp = await fetch(
|
|
41
|
-
method:
|
|
41
|
+
const resp = await fetch(${urlJs}, {
|
|
42
|
+
method: ${methodJs}, headers: ${headersJs}, credentials: "include"
|
|
42
43
|
});
|
|
43
44
|
return await resp.json();
|
|
44
45
|
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface CliCommand {
|
|
|
26
26
|
browser?: boolean;
|
|
27
27
|
args: Arg[];
|
|
28
28
|
columns?: string[];
|
|
29
|
-
func?: (page: IPage
|
|
29
|
+
func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
|
|
30
30
|
pipeline?: any[];
|
|
31
31
|
timeoutSeconds?: number;
|
|
32
32
|
source?: string;
|
|
@@ -45,7 +45,7 @@ export interface CliOptions {
|
|
|
45
45
|
browser?: boolean;
|
|
46
46
|
args?: Arg[];
|
|
47
47
|
columns?: string[];
|
|
48
|
-
func?: (page: IPage
|
|
48
|
+
func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
|
|
49
49
|
pipeline?: any[];
|
|
50
50
|
timeoutSeconds?: number;
|
|
51
51
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jackwener/opencli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"dev": "tsx src/main.ts",
|
|
18
18
|
"build": "tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest",
|
|
19
|
-
"build-manifest": "node dist/build-manifest.js
|
|
20
|
-
"clean-yaml": "
|
|
21
|
-
"copy-yaml": "
|
|
19
|
+
"build-manifest": "node dist/build-manifest.js",
|
|
20
|
+
"clean-yaml": "node -e \"const{readdirSync:r,rmSync:d,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(dir){if(!e(dir))return;for(const f of r(dir)){const fp=p.join(dir,f);s(fp).isDirectory()?w(fp):/\\.ya?ml$/.test(f)&&d(fp)}}w('dist/clis')\"",
|
|
21
|
+
"copy-yaml": "node -e \"const{readdirSync:r,copyFileSync:c,mkdirSync:m,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(src,dst){if(!e(src))return;for(const f of r(src)){const sp=p.join(src,f),dp=p.join(dst,f);s(sp).isDirectory()?w(sp,dp):/\\.ya?ml$/.test(f)&&(m(p.dirname(dp),{recursive:!0}),c(sp,dp))}}w('src/clis','dist/clis')\"",
|
|
22
22
|
"start": "node dist/main.js",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"lint": "tsc --noEmit",
|
package/src/bilibili.ts
CHANGED
|
@@ -84,10 +84,10 @@ export async function apiGet(
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
export async function fetchJson(page: IPage, url: string): Promise<any> {
|
|
87
|
-
const
|
|
87
|
+
const urlJs = JSON.stringify(url);
|
|
88
88
|
return page.evaluate(`
|
|
89
89
|
async () => {
|
|
90
|
-
const res = await fetch(
|
|
90
|
+
const res = await fetch(${urlJs}, { credentials: "include" });
|
|
91
91
|
return await res.json();
|
|
92
92
|
}
|
|
93
93
|
`);
|
package/src/browser.test.ts
CHANGED
|
@@ -49,23 +49,61 @@ describe('browser helpers', () => {
|
|
|
49
49
|
expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
it('builds
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
52
|
+
it('builds extension MCP args in local mode (no CI)', () => {
|
|
53
|
+
const savedCI = process.env.CI;
|
|
54
|
+
delete process.env.CI;
|
|
55
|
+
try {
|
|
56
|
+
expect(__test__.buildMcpArgs({
|
|
57
|
+
mcpPath: '/tmp/cli.js',
|
|
58
|
+
executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
|
59
|
+
})).toEqual([
|
|
60
|
+
'/tmp/cli.js',
|
|
61
|
+
'--extension',
|
|
62
|
+
'--executable-path',
|
|
63
|
+
'/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
expect(__test__.buildMcpArgs({
|
|
67
|
+
mcpPath: '/tmp/cli.js',
|
|
68
|
+
})).toEqual([
|
|
69
|
+
'/tmp/cli.js',
|
|
70
|
+
'--extension',
|
|
71
|
+
]);
|
|
72
|
+
} finally {
|
|
73
|
+
if (savedCI !== undefined) {
|
|
74
|
+
process.env.CI = savedCI;
|
|
75
|
+
} else {
|
|
76
|
+
delete process.env.CI;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
62
80
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
it('builds standalone MCP args in CI mode', () => {
|
|
82
|
+
const savedCI = process.env.CI;
|
|
83
|
+
process.env.CI = 'true';
|
|
84
|
+
try {
|
|
85
|
+
// CI mode: no --extension — browser launches in standalone headed mode
|
|
86
|
+
expect(__test__.buildMcpArgs({
|
|
87
|
+
mcpPath: '/tmp/cli.js',
|
|
88
|
+
})).toEqual([
|
|
89
|
+
'/tmp/cli.js',
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
expect(__test__.buildMcpArgs({
|
|
93
|
+
mcpPath: '/tmp/cli.js',
|
|
94
|
+
executablePath: '/usr/bin/chromium',
|
|
95
|
+
})).toEqual([
|
|
96
|
+
'/tmp/cli.js',
|
|
97
|
+
'--executable-path',
|
|
98
|
+
'/usr/bin/chromium',
|
|
99
|
+
]);
|
|
100
|
+
} finally {
|
|
101
|
+
if (savedCI !== undefined) {
|
|
102
|
+
process.env.CI = savedCI;
|
|
103
|
+
} else {
|
|
104
|
+
delete process.env.CI;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
69
107
|
});
|
|
70
108
|
|
|
71
109
|
it('times out slow promises', async () => {
|
package/src/browser.ts
CHANGED
|
@@ -214,7 +214,7 @@ export class Page implements IPage {
|
|
|
214
214
|
return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
async scroll(direction: string = 'down',
|
|
217
|
+
async scroll(direction: string = 'down', _amount: number = 500): Promise<void> {
|
|
218
218
|
await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
|
|
219
219
|
}
|
|
220
220
|
|
|
@@ -349,6 +349,7 @@ export class PlaywrightMCP {
|
|
|
349
349
|
return new Promise<Page>((resolve, reject) => {
|
|
350
350
|
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
|
|
351
351
|
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
|
|
352
|
+
const useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
|
352
353
|
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
|
353
354
|
const tokenFingerprint = getTokenFingerprint(extensionToken);
|
|
354
355
|
let stderrBuffer = '';
|
|
@@ -392,7 +393,8 @@ export class PlaywrightMCP {
|
|
|
392
393
|
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
|
|
393
394
|
});
|
|
394
395
|
if (process.env.OPENCLI_VERBOSE) {
|
|
395
|
-
console.error(`[opencli]
|
|
396
|
+
console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`);
|
|
397
|
+
if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
|
|
396
398
|
}
|
|
397
399
|
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
|
|
398
400
|
|
|
@@ -610,7 +612,13 @@ function appendLimited(current: string, chunk: string, limit: number): string {
|
|
|
610
612
|
}
|
|
611
613
|
|
|
612
614
|
function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
|
|
613
|
-
const args = [input.mcpPath
|
|
615
|
+
const args = [input.mcpPath];
|
|
616
|
+
if (!process.env.CI) {
|
|
617
|
+
// Local: always connect to user's running Chrome via MCP Bridge extension
|
|
618
|
+
args.push('--extension');
|
|
619
|
+
}
|
|
620
|
+
// CI: standalone mode — @playwright/mcp launches its own browser (headed by default).
|
|
621
|
+
// xvfb provides a virtual display for headed mode in GitHub Actions.
|
|
614
622
|
if (input.executablePath) {
|
|
615
623
|
args.push('--executable-path', input.executablePath);
|
|
616
624
|
}
|