@jackwener/opencli 0.7.11 → 0.9.0
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/CDP.md +103 -0
- package/CDP.zh-CN.md +103 -0
- package/README.md +5 -0
- package/README.zh-CN.md +5 -0
- package/dist/browser/discover.d.ts +15 -0
- package/dist/browser/discover.js +68 -2
- package/dist/browser/errors.d.ts +2 -1
- package/dist/browser/errors.js +13 -0
- package/dist/browser/index.d.ts +1 -0
- package/dist/browser/index.js +1 -0
- package/dist/browser/mcp.js +8 -3
- package/dist/browser/page.js +11 -2
- package/dist/cli-manifest.json +246 -0
- package/dist/clis/antigravity/dump.d.ts +1 -0
- package/dist/clis/antigravity/dump.js +28 -0
- package/dist/clis/antigravity/extract-code.d.ts +1 -0
- package/dist/clis/antigravity/extract-code.js +32 -0
- package/dist/clis/antigravity/model.d.ts +1 -0
- package/dist/clis/antigravity/model.js +44 -0
- package/dist/clis/antigravity/new.d.ts +1 -0
- package/dist/clis/antigravity/new.js +25 -0
- package/dist/clis/antigravity/read.d.ts +1 -0
- package/dist/clis/antigravity/read.js +34 -0
- package/dist/clis/antigravity/send.d.ts +1 -0
- package/dist/clis/antigravity/send.js +35 -0
- package/dist/clis/antigravity/status.d.ts +1 -0
- package/dist/clis/antigravity/status.js +18 -0
- package/dist/clis/antigravity/watch.d.ts +1 -0
- package/dist/clis/antigravity/watch.js +41 -0
- package/dist/clis/barchart/flow.js +56 -58
- package/dist/clis/xiaoyuzhou/episode.d.ts +1 -0
- package/dist/clis/xiaoyuzhou/episode.js +28 -0
- package/dist/clis/xiaoyuzhou/podcast-episodes.d.ts +1 -0
- package/dist/clis/xiaoyuzhou/podcast-episodes.js +36 -0
- package/dist/clis/xiaoyuzhou/podcast.d.ts +1 -0
- package/dist/clis/xiaoyuzhou/podcast.js +27 -0
- package/dist/clis/xiaoyuzhou/utils.d.ts +16 -0
- package/dist/clis/xiaoyuzhou/utils.js +55 -0
- package/dist/clis/xiaoyuzhou/utils.test.d.ts +1 -0
- package/dist/clis/xiaoyuzhou/utils.test.js +99 -0
- package/dist/doctor.js +8 -0
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +59 -1
- package/dist/main.js +2 -15
- package/dist/pipeline/executor.js +2 -24
- package/dist/pipeline/registry.d.ts +19 -0
- package/dist/pipeline/registry.js +41 -0
- package/package.json +1 -1
- package/src/browser/discover.ts +79 -5
- package/src/browser/errors.ts +17 -1
- package/src/browser/index.ts +1 -0
- package/src/browser/mcp.ts +8 -3
- package/src/browser/page.ts +21 -2
- package/src/clis/antigravity/README.md +49 -0
- package/src/clis/antigravity/README.zh-CN.md +52 -0
- package/src/clis/antigravity/SKILL.md +42 -0
- package/src/clis/antigravity/dump.ts +30 -0
- package/src/clis/antigravity/extract-code.ts +34 -0
- package/src/clis/antigravity/model.ts +47 -0
- package/src/clis/antigravity/new.ts +28 -0
- package/src/clis/antigravity/read.ts +36 -0
- package/src/clis/antigravity/send.ts +40 -0
- package/src/clis/antigravity/status.ts +19 -0
- package/src/clis/antigravity/watch.ts +45 -0
- package/src/clis/barchart/flow.ts +57 -58
- package/src/clis/xiaoyuzhou/episode.ts +28 -0
- package/src/clis/xiaoyuzhou/podcast-episodes.ts +36 -0
- package/src/clis/xiaoyuzhou/podcast.ts +27 -0
- package/src/clis/xiaoyuzhou/utils.test.ts +122 -0
- package/src/clis/xiaoyuzhou/utils.ts +65 -0
- package/src/doctor.ts +9 -0
- package/src/engine.ts +58 -1
- package/src/main.ts +6 -11
- package/src/pipeline/executor.ts +2 -28
- package/src/pipeline/registry.ts +60 -0
- package/tests/e2e/public-commands.test.ts +62 -0
|
@@ -27,80 +27,78 @@ cli({
|
|
|
27
27
|
(async () => {
|
|
28
28
|
const limit = ${limit};
|
|
29
29
|
const typeFilter = '${optionType}'.toLowerCase();
|
|
30
|
-
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
31
|
-
const headers = { 'X-CSRF-TOKEN': csrf };
|
|
32
30
|
|
|
31
|
+
// Wait for CSRF token to appear (Angular may inject it after initial render)
|
|
32
|
+
let csrf = '';
|
|
33
|
+
for (let i = 0; i < 10; i++) {
|
|
34
|
+
csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
35
|
+
if (csrf) break;
|
|
36
|
+
await new Promise(r => setTimeout(r, 500));
|
|
37
|
+
}
|
|
38
|
+
if (!csrf) return { error: 'no-csrf' };
|
|
39
|
+
|
|
40
|
+
const headers = { 'X-CSRF-TOKEN': csrf };
|
|
33
41
|
const fields = [
|
|
34
42
|
'baseSymbol','strikePrice','expirationDate','optionType',
|
|
35
43
|
'lastPrice','volume','openInterest','volumeOpenInterestRatio','volatility',
|
|
36
44
|
].join(',');
|
|
37
45
|
|
|
38
|
-
// Fetch extra rows when filtering by type since server-side filter
|
|
46
|
+
// Fetch extra rows when filtering by type since server-side filter doesn't work
|
|
39
47
|
const fetchLimit = typeFilter !== 'all' ? limit * 3 : limit;
|
|
40
|
-
try {
|
|
41
|
-
const url = '/proxies/core-api/v1/options/get?list=options.unusual_activity.stocks.us'
|
|
42
|
-
+ '&fields=' + fields
|
|
43
|
-
+ '&orderBy=volumeOpenInterestRatio&orderDir=desc'
|
|
44
|
-
+ '&raw=1&limit=' + fetchLimit;
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
// Try unusual_activity first, fall back to mostActive (unusual_activity is
|
|
50
|
+
// empty outside market hours)
|
|
51
|
+
const lists = [
|
|
52
|
+
'options.unusual_activity.stocks.us',
|
|
53
|
+
'options.mostActive.us',
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
for (const list of lists) {
|
|
57
|
+
try {
|
|
58
|
+
const url = '/proxies/core-api/v1/options/get?list=' + list
|
|
59
|
+
+ '&fields=' + fields
|
|
60
|
+
+ '&orderBy=volumeOpenInterestRatio&orderDir=desc'
|
|
61
|
+
+ '&raw=1&limit=' + fetchLimit;
|
|
62
|
+
|
|
63
|
+
const resp = await fetch(url, { credentials: 'include', headers });
|
|
64
|
+
if (!resp.ok) continue;
|
|
48
65
|
const d = await resp.json();
|
|
49
66
|
let items = d?.data || [];
|
|
50
|
-
if (items.length
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
return items.slice(0, limit).map(i => {
|
|
59
|
-
const r = i.raw || i;
|
|
60
|
-
return {
|
|
61
|
-
symbol: r.baseSymbol || r.symbol,
|
|
62
|
-
type: r.optionType,
|
|
63
|
-
strike: r.strikePrice,
|
|
64
|
-
expiration: r.expirationDate,
|
|
65
|
-
last: r.lastPrice,
|
|
66
|
-
volume: r.volume,
|
|
67
|
-
openInterest: r.openInterest,
|
|
68
|
-
volOiRatio: r.volumeOpenInterestRatio,
|
|
69
|
-
iv: r.volatility,
|
|
70
|
-
};
|
|
67
|
+
if (items.length === 0) continue;
|
|
68
|
+
|
|
69
|
+
// Apply client-side type filter
|
|
70
|
+
if (typeFilter !== 'all') {
|
|
71
|
+
items = items.filter(i => {
|
|
72
|
+
const t = ((i.raw || i).optionType || '').toLowerCase();
|
|
73
|
+
return t === typeFilter;
|
|
71
74
|
});
|
|
72
75
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
type: getText(1),
|
|
87
|
-
strike: getText(2),
|
|
88
|
-
expiration: getText(3),
|
|
89
|
-
last: getText(4),
|
|
90
|
-
volume: getText(5),
|
|
91
|
-
openInterest: cells.length > 6 ? getText(6) : null,
|
|
92
|
-
volOiRatio: cells.length > 7 ? getText(7) : null,
|
|
93
|
-
iv: cells.length > 8 ? getText(8) : null,
|
|
76
|
+
return items.slice(0, limit).map(i => {
|
|
77
|
+
const r = i.raw || i;
|
|
78
|
+
return {
|
|
79
|
+
symbol: r.baseSymbol || r.symbol,
|
|
80
|
+
type: r.optionType,
|
|
81
|
+
strike: r.strikePrice,
|
|
82
|
+
expiration: r.expirationDate,
|
|
83
|
+
last: r.lastPrice,
|
|
84
|
+
volume: r.volume,
|
|
85
|
+
openInterest: r.openInterest,
|
|
86
|
+
volOiRatio: r.volumeOpenInterestRatio,
|
|
87
|
+
iv: r.volatility,
|
|
88
|
+
};
|
|
94
89
|
});
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
return results;
|
|
98
|
-
} catch(e) {
|
|
99
|
-
return [];
|
|
90
|
+
} catch(e) {}
|
|
100
91
|
}
|
|
92
|
+
|
|
93
|
+
return [];
|
|
101
94
|
})()
|
|
102
95
|
`);
|
|
103
|
-
if (!data
|
|
96
|
+
if (!data)
|
|
97
|
+
return [];
|
|
98
|
+
if (data.error === 'no-csrf') {
|
|
99
|
+
throw new Error('Could not extract CSRF token from barchart.com. Make sure you are logged in.');
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(data))
|
|
104
102
|
return [];
|
|
105
103
|
return data.slice(0, limit).map(r => ({
|
|
106
104
|
symbol: r.symbol || '',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
3
|
+
import { fetchPageProps, formatDuration, formatDate } from './utils.js';
|
|
4
|
+
cli({
|
|
5
|
+
site: 'xiaoyuzhou',
|
|
6
|
+
name: 'episode',
|
|
7
|
+
description: 'View details of a Xiaoyuzhou podcast episode',
|
|
8
|
+
domain: 'www.xiaoyuzhoufm.com',
|
|
9
|
+
strategy: Strategy.PUBLIC,
|
|
10
|
+
browser: false,
|
|
11
|
+
args: [{ name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' }],
|
|
12
|
+
columns: ['title', 'podcast', 'duration', 'plays', 'comments', 'likes', 'date'],
|
|
13
|
+
func: async (_page, args) => {
|
|
14
|
+
const pageProps = await fetchPageProps(`/episode/${args.id}`);
|
|
15
|
+
const ep = pageProps.episode;
|
|
16
|
+
if (!ep)
|
|
17
|
+
throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the ID');
|
|
18
|
+
return [{
|
|
19
|
+
title: ep.title,
|
|
20
|
+
podcast: ep.podcast?.title,
|
|
21
|
+
duration: formatDuration(ep.duration),
|
|
22
|
+
plays: ep.playCount,
|
|
23
|
+
comments: ep.commentCount,
|
|
24
|
+
likes: ep.clapCount,
|
|
25
|
+
date: formatDate(ep.pubDate),
|
|
26
|
+
}];
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
3
|
+
import { fetchPageProps, formatDuration, formatDate } from './utils.js';
|
|
4
|
+
cli({
|
|
5
|
+
site: 'xiaoyuzhou',
|
|
6
|
+
name: 'podcast-episodes',
|
|
7
|
+
description: 'List recent episodes of a Xiaoyuzhou podcast (up to 15, SSR limit)',
|
|
8
|
+
domain: 'www.xiaoyuzhoufm.com',
|
|
9
|
+
strategy: Strategy.PUBLIC,
|
|
10
|
+
browser: false,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' },
|
|
13
|
+
{ name: 'limit', type: 'int', default: 15, help: 'Max episodes to show (up to 15, SSR limit)' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['eid', 'title', 'duration', 'plays', 'date'],
|
|
16
|
+
func: async (_page, args) => {
|
|
17
|
+
const pageProps = await fetchPageProps(`/podcast/${args.id}`);
|
|
18
|
+
const podcast = pageProps.podcast;
|
|
19
|
+
if (!podcast)
|
|
20
|
+
throw new CliError('NOT_FOUND', 'Podcast not found', 'Please check the ID');
|
|
21
|
+
const allEpisodes = podcast.episodes ?? [];
|
|
22
|
+
const requestedLimit = Number(args.limit);
|
|
23
|
+
if (!Number.isInteger(requestedLimit) || requestedLimit < 1) {
|
|
24
|
+
throw new CliError('INVALID_ARGUMENT', 'limit must be a positive integer', 'Example: --limit 5');
|
|
25
|
+
}
|
|
26
|
+
const limit = Math.min(requestedLimit, allEpisodes.length);
|
|
27
|
+
const episodes = allEpisodes.slice(0, limit);
|
|
28
|
+
return episodes.map((ep) => ({
|
|
29
|
+
eid: ep.eid,
|
|
30
|
+
title: ep.title,
|
|
31
|
+
duration: formatDuration(ep.duration),
|
|
32
|
+
plays: ep.playCount,
|
|
33
|
+
date: formatDate(ep.pubDate),
|
|
34
|
+
}));
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
3
|
+
import { fetchPageProps, formatDate } from './utils.js';
|
|
4
|
+
cli({
|
|
5
|
+
site: 'xiaoyuzhou',
|
|
6
|
+
name: 'podcast',
|
|
7
|
+
description: 'View a Xiaoyuzhou podcast profile',
|
|
8
|
+
domain: 'www.xiaoyuzhoufm.com',
|
|
9
|
+
strategy: Strategy.PUBLIC,
|
|
10
|
+
browser: false,
|
|
11
|
+
args: [{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' }],
|
|
12
|
+
columns: ['title', 'author', 'description', 'subscribers', 'episodes', 'updated'],
|
|
13
|
+
func: async (_page, args) => {
|
|
14
|
+
const pageProps = await fetchPageProps(`/podcast/${args.id}`);
|
|
15
|
+
const p = pageProps.podcast;
|
|
16
|
+
if (!p)
|
|
17
|
+
throw new CliError('NOT_FOUND', 'Podcast not found', 'Please check the ID');
|
|
18
|
+
return [{
|
|
19
|
+
title: p.title,
|
|
20
|
+
author: p.author,
|
|
21
|
+
description: p.brief,
|
|
22
|
+
subscribers: p.subscriptionCount,
|
|
23
|
+
episodes: p.episodeCount,
|
|
24
|
+
updated: formatDate(p.latestEpisodePubDate),
|
|
25
|
+
}];
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Xiaoyuzhou utilities — page data extraction and formatting.
|
|
3
|
+
*
|
|
4
|
+
* Xiaoyuzhou (小宇宙) is a Next.js app that embeds full page data in
|
|
5
|
+
* <script id="__NEXT_DATA__">. We fetch the HTML and extract that JSON
|
|
6
|
+
* instead of using their authenticated API.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Fetch a Xiaoyuzhou page and extract __NEXT_DATA__.props.pageProps.
|
|
10
|
+
* @param path - URL path, e.g. '/podcast/xxx' or '/episode/xxx'
|
|
11
|
+
*/
|
|
12
|
+
export declare function fetchPageProps(path: string): Promise<any>;
|
|
13
|
+
/** Format seconds to mm:ss (e.g. 3890 → "64:50"). Returns '-' for invalid input. */
|
|
14
|
+
export declare function formatDuration(seconds: number): string;
|
|
15
|
+
/** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
|
|
16
|
+
export declare function formatDate(iso: string): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Xiaoyuzhou utilities — page data extraction and formatting.
|
|
3
|
+
*
|
|
4
|
+
* Xiaoyuzhou (小宇宙) is a Next.js app that embeds full page data in
|
|
5
|
+
* <script id="__NEXT_DATA__">. We fetch the HTML and extract that JSON
|
|
6
|
+
* instead of using their authenticated API.
|
|
7
|
+
*/
|
|
8
|
+
import { CliError } from '../../errors.js';
|
|
9
|
+
/**
|
|
10
|
+
* Fetch a Xiaoyuzhou page and extract __NEXT_DATA__.props.pageProps.
|
|
11
|
+
* @param path - URL path, e.g. '/podcast/xxx' or '/episode/xxx'
|
|
12
|
+
*/
|
|
13
|
+
export async function fetchPageProps(path) {
|
|
14
|
+
const url = `https://www.xiaoyuzhoufm.com${path}`;
|
|
15
|
+
// Node.js fetch sends UA "node" which gets blocked; use a browser-like UA
|
|
16
|
+
const resp = await fetch(url, {
|
|
17
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli)' },
|
|
18
|
+
});
|
|
19
|
+
if (!resp.ok) {
|
|
20
|
+
throw new CliError('FETCH_ERROR', `HTTP ${resp.status} for ${path}`, 'Please check the ID — you can find it in xiaoyuzhoufm.com URLs');
|
|
21
|
+
}
|
|
22
|
+
const html = await resp.text();
|
|
23
|
+
// [\s\S]*? for multiline safety (JSON may span lines)
|
|
24
|
+
const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
|
|
25
|
+
if (!match) {
|
|
26
|
+
throw new CliError('PARSE_ERROR', 'Failed to extract __NEXT_DATA__', 'Page structure may have changed');
|
|
27
|
+
}
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(match[1]);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new CliError('PARSE_ERROR', 'Malformed __NEXT_DATA__ JSON', 'Page structure may have changed');
|
|
34
|
+
}
|
|
35
|
+
const pageProps = parsed.props?.pageProps;
|
|
36
|
+
if (!pageProps || Object.keys(pageProps).length === 0) {
|
|
37
|
+
throw new CliError('NOT_FOUND', 'Resource not found', 'Please check the ID — you can find it in xiaoyuzhoufm.com URLs');
|
|
38
|
+
}
|
|
39
|
+
return pageProps;
|
|
40
|
+
}
|
|
41
|
+
/** Format seconds to mm:ss (e.g. 3890 → "64:50"). Returns '-' for invalid input. */
|
|
42
|
+
export function formatDuration(seconds) {
|
|
43
|
+
if (!Number.isFinite(seconds) || seconds < 0)
|
|
44
|
+
return '-';
|
|
45
|
+
seconds = Math.round(seconds);
|
|
46
|
+
const m = Math.floor(seconds / 60);
|
|
47
|
+
const s = seconds % 60;
|
|
48
|
+
return `${m}:${String(s).padStart(2, '0')}`;
|
|
49
|
+
}
|
|
50
|
+
/** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
|
|
51
|
+
export function formatDate(iso) {
|
|
52
|
+
if (!iso)
|
|
53
|
+
return '-';
|
|
54
|
+
return iso.slice(0, 10);
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { formatDuration, formatDate, fetchPageProps } from './utils.js';
|
|
3
|
+
describe('formatDuration', () => {
|
|
4
|
+
it('formats typical duration', () => {
|
|
5
|
+
expect(formatDuration(3890)).toBe('64:50');
|
|
6
|
+
});
|
|
7
|
+
it('formats zero seconds', () => {
|
|
8
|
+
expect(formatDuration(0)).toBe('0:00');
|
|
9
|
+
});
|
|
10
|
+
it('pads single-digit seconds', () => {
|
|
11
|
+
expect(formatDuration(65)).toBe('1:05');
|
|
12
|
+
});
|
|
13
|
+
it('formats exact minutes', () => {
|
|
14
|
+
expect(formatDuration(3600)).toBe('60:00');
|
|
15
|
+
});
|
|
16
|
+
it('rounds floating-point seconds', () => {
|
|
17
|
+
expect(formatDuration(3890.7)).toBe('64:51');
|
|
18
|
+
});
|
|
19
|
+
it('returns dash for NaN', () => {
|
|
20
|
+
expect(formatDuration(NaN)).toBe('-');
|
|
21
|
+
});
|
|
22
|
+
it('returns dash for negative', () => {
|
|
23
|
+
expect(formatDuration(-1)).toBe('-');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('formatDate', () => {
|
|
27
|
+
it('extracts YYYY-MM-DD from ISO string', () => {
|
|
28
|
+
expect(formatDate('2026-03-13T11:00:06.686Z')).toBe('2026-03-13');
|
|
29
|
+
});
|
|
30
|
+
it('handles date-only string', () => {
|
|
31
|
+
expect(formatDate('2025-01-01')).toBe('2025-01-01');
|
|
32
|
+
});
|
|
33
|
+
it('returns dash for undefined/empty', () => {
|
|
34
|
+
expect(formatDate('')).toBe('-');
|
|
35
|
+
expect(formatDate(undefined)).toBe('-');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('fetchPageProps', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.restoreAllMocks();
|
|
41
|
+
});
|
|
42
|
+
it('extracts pageProps from valid HTML', async () => {
|
|
43
|
+
const mockHtml = `<html><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"podcast":{"title":"Test"}}}}</script></html>`;
|
|
44
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
45
|
+
ok: true,
|
|
46
|
+
text: () => Promise.resolve(mockHtml),
|
|
47
|
+
}));
|
|
48
|
+
const result = await fetchPageProps('/podcast/abc123');
|
|
49
|
+
expect(result).toEqual({ podcast: { title: 'Test' } });
|
|
50
|
+
});
|
|
51
|
+
it('throws on HTTP error', async () => {
|
|
52
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
53
|
+
ok: false,
|
|
54
|
+
status: 404,
|
|
55
|
+
text: () => Promise.resolve('Not Found'),
|
|
56
|
+
}));
|
|
57
|
+
await expect(fetchPageProps('/podcast/invalid')).rejects.toThrow('HTTP 404');
|
|
58
|
+
});
|
|
59
|
+
it('throws when __NEXT_DATA__ is missing', async () => {
|
|
60
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
61
|
+
ok: true,
|
|
62
|
+
text: () => Promise.resolve('<html><body>No data here</body></html>'),
|
|
63
|
+
}));
|
|
64
|
+
await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Failed to extract');
|
|
65
|
+
});
|
|
66
|
+
it('throws when pageProps is empty', async () => {
|
|
67
|
+
const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}}}</script>`;
|
|
68
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
69
|
+
ok: true,
|
|
70
|
+
text: () => Promise.resolve(mockHtml),
|
|
71
|
+
}));
|
|
72
|
+
await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Resource not found');
|
|
73
|
+
});
|
|
74
|
+
it('throws on malformed JSON in __NEXT_DATA__', async () => {
|
|
75
|
+
const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{broken json</script>`;
|
|
76
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
77
|
+
ok: true,
|
|
78
|
+
text: () => Promise.resolve(mockHtml),
|
|
79
|
+
}));
|
|
80
|
+
await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Malformed __NEXT_DATA__');
|
|
81
|
+
});
|
|
82
|
+
it('handles multiline JSON in __NEXT_DATA__', async () => {
|
|
83
|
+
const mockHtml = `<script id="__NEXT_DATA__" type="application/json">
|
|
84
|
+
{
|
|
85
|
+
"props": {
|
|
86
|
+
"pageProps": {
|
|
87
|
+
"episode": {"title": "Multiline Test"}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
</script>`;
|
|
92
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
93
|
+
ok: true,
|
|
94
|
+
text: () => Promise.resolve(mockHtml),
|
|
95
|
+
}));
|
|
96
|
+
const result = await fetchPageProps('/episode/abc');
|
|
97
|
+
expect(result).toEqual({ episode: { title: 'Multiline Test' } });
|
|
98
|
+
});
|
|
99
|
+
});
|
package/dist/doctor.js
CHANGED
|
@@ -504,6 +504,14 @@ export function renderBrowserDoctorReport(report) {
|
|
|
504
504
|
const uniqueFingerprints = [...new Set(tokenFingerprints)];
|
|
505
505
|
const hasMismatch = uniqueFingerprints.length > 1;
|
|
506
506
|
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
|
507
|
+
// CDP endpoint mode (for remote/server environments)
|
|
508
|
+
const cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
|
509
|
+
if (cdpEndpoint) {
|
|
510
|
+
lines.push(statusLine('OK', `CDP endpoint: ${chalk.cyan(cdpEndpoint)}`));
|
|
511
|
+
lines.push(chalk.dim(' → Remote Chrome mode: extension token not required'));
|
|
512
|
+
lines.push('');
|
|
513
|
+
return lines.join('\n');
|
|
514
|
+
}
|
|
507
515
|
const installStatus = report.extensionInstalled ? 'OK' : 'MISSING';
|
|
508
516
|
const installDetail = report.extensionInstalled
|
|
509
517
|
? `Extension installed (${report.extensionBrowsers.join(', ')})`
|
package/dist/engine.d.ts
CHANGED
|
@@ -17,4 +17,4 @@ export declare function discoverClis(...dirs: string[]): Promise<void>;
|
|
|
17
17
|
/**
|
|
18
18
|
* Execute a CLI command. Handles lazy-loading of TS modules.
|
|
19
19
|
*/
|
|
20
|
-
export declare function executeCommand(cmd: CliCommand, page: IPage | null,
|
|
20
|
+
export declare function executeCommand(cmd: CliCommand, page: IPage | null, rawKwargs: Record<string, any>, debug?: boolean): Promise<any>;
|
package/dist/engine.js
CHANGED
|
@@ -155,10 +155,68 @@ function registerYamlCli(filePath, defaultSite) {
|
|
|
155
155
|
log.warn(`Failed to load ${filePath}: ${err.message}`);
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Validates and coerces arguments based on the command's Arg definitions.
|
|
160
|
+
*/
|
|
161
|
+
function coerceAndValidateArgs(cmdArgs, kwargs) {
|
|
162
|
+
const result = { ...kwargs };
|
|
163
|
+
for (const argDef of cmdArgs) {
|
|
164
|
+
const val = result[argDef.name];
|
|
165
|
+
// 1. Check required
|
|
166
|
+
if (argDef.required && (val === undefined || val === null || val === '')) {
|
|
167
|
+
throw new Error(`Argument "${argDef.name}" is required.\n${argDef.help ? `Hint: ${argDef.help}` : ''}`);
|
|
168
|
+
}
|
|
169
|
+
if (val !== undefined && val !== null) {
|
|
170
|
+
// 2. Type coercion
|
|
171
|
+
if (argDef.type === 'int' || argDef.type === 'number') {
|
|
172
|
+
const num = Number(val);
|
|
173
|
+
if (Number.isNaN(num)) {
|
|
174
|
+
throw new Error(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
|
|
175
|
+
}
|
|
176
|
+
result[argDef.name] = num;
|
|
177
|
+
}
|
|
178
|
+
else if (argDef.type === 'boolean' || argDef.type === 'bool') {
|
|
179
|
+
if (typeof val === 'string') {
|
|
180
|
+
const lower = val.toLowerCase();
|
|
181
|
+
if (lower === 'true' || lower === '1')
|
|
182
|
+
result[argDef.name] = true;
|
|
183
|
+
else if (lower === 'false' || lower === '0')
|
|
184
|
+
result[argDef.name] = false;
|
|
185
|
+
else
|
|
186
|
+
throw new Error(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
result[argDef.name] = Boolean(val);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// 3. Choices validation
|
|
193
|
+
const coercedVal = result[argDef.name];
|
|
194
|
+
if (argDef.choices && argDef.choices.length > 0) {
|
|
195
|
+
// Only stringent check for string/number types against choices array
|
|
196
|
+
if (!argDef.choices.map(String).includes(String(coercedVal))) {
|
|
197
|
+
throw new Error(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else if (argDef.default !== undefined) {
|
|
202
|
+
// Set default if value is missing
|
|
203
|
+
result[argDef.name] = argDef.default;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
158
208
|
/**
|
|
159
209
|
* Execute a CLI command. Handles lazy-loading of TS modules.
|
|
160
210
|
*/
|
|
161
|
-
export async function executeCommand(cmd, page,
|
|
211
|
+
export async function executeCommand(cmd, page, rawKwargs, debug = false) {
|
|
212
|
+
let kwargs;
|
|
213
|
+
try {
|
|
214
|
+
kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
// Re-throw validation errors clearly
|
|
218
|
+
throw new Error(`[Argument Validation Error]\n${err.message}`);
|
|
219
|
+
}
|
|
162
220
|
// Lazy-load TS module on first execution
|
|
163
221
|
const internal = cmd;
|
|
164
222
|
if (internal._lazy && internal._modulePath) {
|
package/dist/main.js
CHANGED
|
@@ -199,9 +199,7 @@ for (const [, cmd] of registry) {
|
|
|
199
199
|
const arg = positionalArgs[i];
|
|
200
200
|
const v = actionArgs[i];
|
|
201
201
|
if (v !== undefined)
|
|
202
|
-
kwargs[arg.name] =
|
|
203
|
-
else if (arg.default != null)
|
|
204
|
-
kwargs[arg.name] = arg.default;
|
|
202
|
+
kwargs[arg.name] = v;
|
|
205
203
|
}
|
|
206
204
|
// Collect named options
|
|
207
205
|
for (const arg of cmd.args) {
|
|
@@ -209,9 +207,7 @@ for (const [, cmd] of registry) {
|
|
|
209
207
|
continue;
|
|
210
208
|
const v = actionOpts[arg.name];
|
|
211
209
|
if (v !== undefined)
|
|
212
|
-
kwargs[arg.name] =
|
|
213
|
-
else if (arg.default != null)
|
|
214
|
-
kwargs[arg.name] = arg.default;
|
|
210
|
+
kwargs[arg.name] = v;
|
|
215
211
|
}
|
|
216
212
|
try {
|
|
217
213
|
if (actionOpts.verbose)
|
|
@@ -244,13 +240,4 @@ for (const [, cmd] of registry) {
|
|
|
244
240
|
}
|
|
245
241
|
});
|
|
246
242
|
}
|
|
247
|
-
function coerce(v, t) {
|
|
248
|
-
if (t === 'bool')
|
|
249
|
-
return ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase());
|
|
250
|
-
if (t === 'int')
|
|
251
|
-
return parseInt(String(v), 10);
|
|
252
|
-
if (t === 'float')
|
|
253
|
-
return parseFloat(String(v));
|
|
254
|
-
return String(v);
|
|
255
|
-
}
|
|
256
243
|
program.parse();
|
|
@@ -1,30 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pipeline executor: runs YAML pipeline steps sequentially.
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
5
|
-
import { stepFetch } from './steps/fetch.js';
|
|
6
|
-
import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js';
|
|
7
|
-
import { stepIntercept } from './steps/intercept.js';
|
|
8
|
-
import { stepTap } from './steps/tap.js';
|
|
4
|
+
import { getStep } from './registry.js';
|
|
9
5
|
import { log } from '../logger.js';
|
|
10
|
-
/** Registry of all available step handlers */
|
|
11
|
-
const STEP_HANDLERS = {
|
|
12
|
-
navigate: stepNavigate,
|
|
13
|
-
fetch: stepFetch,
|
|
14
|
-
select: stepSelect,
|
|
15
|
-
evaluate: stepEvaluate,
|
|
16
|
-
snapshot: stepSnapshot,
|
|
17
|
-
click: stepClick,
|
|
18
|
-
type: stepType,
|
|
19
|
-
wait: stepWait,
|
|
20
|
-
press: stepPress,
|
|
21
|
-
map: stepMap,
|
|
22
|
-
filter: stepFilter,
|
|
23
|
-
sort: stepSort,
|
|
24
|
-
limit: stepLimit,
|
|
25
|
-
intercept: stepIntercept,
|
|
26
|
-
tap: stepTap,
|
|
27
|
-
};
|
|
28
6
|
export async function executePipeline(page, pipeline, ctx = {}) {
|
|
29
7
|
const args = ctx.args ?? {};
|
|
30
8
|
const debug = ctx.debug ?? false;
|
|
@@ -37,7 +15,7 @@ export async function executePipeline(page, pipeline, ctx = {}) {
|
|
|
37
15
|
for (const [op, params] of Object.entries(step)) {
|
|
38
16
|
if (debug)
|
|
39
17
|
debugStepStart(i + 1, total, op, params);
|
|
40
|
-
const handler =
|
|
18
|
+
const handler = getStep(op);
|
|
41
19
|
if (handler) {
|
|
42
20
|
data = await handler(page, params, data, args);
|
|
43
21
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic registry for pipeline steps.
|
|
3
|
+
* Allows core and third-party plugins to register custom YAML operations.
|
|
4
|
+
*/
|
|
5
|
+
import type { IPage } from '../types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Step handler: all pipeline steps conform to this generic interface.
|
|
8
|
+
* TData is the type of the `data` state flowing into the step.
|
|
9
|
+
* TResult is the expected return type.
|
|
10
|
+
*/
|
|
11
|
+
export type StepHandler<TData = any, TResult = any> = (page: IPage | null, params: any, data: TData, args: Record<string, any>) => Promise<TResult>;
|
|
12
|
+
/**
|
|
13
|
+
* Get a registered step handler by name.
|
|
14
|
+
*/
|
|
15
|
+
export declare function getStep(name: string): StepHandler | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Register a new custom step handler for the YAML pipeline.
|
|
18
|
+
*/
|
|
19
|
+
export declare function registerStep(name: string, handler: StepHandler): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic registry for pipeline steps.
|
|
3
|
+
* Allows core and third-party plugins to register custom YAML operations.
|
|
4
|
+
*/
|
|
5
|
+
// Import core steps
|
|
6
|
+
import { stepNavigate, stepClick, stepType, stepWait, stepPress, stepSnapshot, stepEvaluate } from './steps/browser.js';
|
|
7
|
+
import { stepFetch } from './steps/fetch.js';
|
|
8
|
+
import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js';
|
|
9
|
+
import { stepIntercept } from './steps/intercept.js';
|
|
10
|
+
import { stepTap } from './steps/tap.js';
|
|
11
|
+
const _stepRegistry = new Map();
|
|
12
|
+
/**
|
|
13
|
+
* Get a registered step handler by name.
|
|
14
|
+
*/
|
|
15
|
+
export function getStep(name) {
|
|
16
|
+
return _stepRegistry.get(name);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Register a new custom step handler for the YAML pipeline.
|
|
20
|
+
*/
|
|
21
|
+
export function registerStep(name, handler) {
|
|
22
|
+
_stepRegistry.set(name, handler);
|
|
23
|
+
}
|
|
24
|
+
// -------------------------------------------------------------
|
|
25
|
+
// Auto-Register Core Steps
|
|
26
|
+
// -------------------------------------------------------------
|
|
27
|
+
registerStep('navigate', stepNavigate);
|
|
28
|
+
registerStep('fetch', stepFetch);
|
|
29
|
+
registerStep('select', stepSelect);
|
|
30
|
+
registerStep('evaluate', stepEvaluate);
|
|
31
|
+
registerStep('snapshot', stepSnapshot);
|
|
32
|
+
registerStep('click', stepClick);
|
|
33
|
+
registerStep('type', stepType);
|
|
34
|
+
registerStep('wait', stepWait);
|
|
35
|
+
registerStep('press', stepPress);
|
|
36
|
+
registerStep('map', stepMap);
|
|
37
|
+
registerStep('filter', stepFilter);
|
|
38
|
+
registerStep('sort', stepSort);
|
|
39
|
+
registerStep('limit', stepLimit);
|
|
40
|
+
registerStep('intercept', stepIntercept);
|
|
41
|
+
registerStep('tap', stepTap);
|