@jackwener/opencli 0.7.0 → 0.7.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/LICENSE +190 -28
- package/README.md +6 -5
- package/README.zh-CN.md +5 -4
- package/SKILL.md +18 -4
- package/dist/browser.js +2 -3
- package/dist/cli-manifest.json +195 -22
- package/dist/clis/linkedin/search.d.ts +1 -0
- package/dist/clis/linkedin/search.js +366 -0
- package/dist/clis/reddit/read.d.ts +1 -0
- package/dist/clis/reddit/read.js +184 -0
- package/dist/clis/youtube/transcript-group.d.ts +44 -0
- package/dist/clis/youtube/transcript-group.js +226 -0
- package/dist/clis/youtube/transcript-group.test.d.ts +1 -0
- package/dist/clis/youtube/transcript-group.test.js +99 -0
- package/dist/clis/youtube/transcript.d.ts +1 -0
- package/dist/clis/youtube/transcript.js +264 -0
- package/dist/clis/youtube/utils.d.ts +8 -0
- package/dist/clis/youtube/utils.js +28 -0
- package/dist/clis/youtube/video.d.ts +1 -0
- package/dist/clis/youtube/video.js +114 -0
- package/dist/engine.js +2 -1
- package/dist/main.js +10 -2
- package/dist/output.js +2 -1
- package/dist/registry.d.ts +1 -8
- package/dist/snapshotFormatter.d.ts +9 -0
- package/dist/snapshotFormatter.js +352 -15
- package/dist/snapshotFormatter.test.d.ts +7 -0
- package/dist/snapshotFormatter.test.js +521 -0
- package/dist/validate.d.ts +14 -2
- package/dist/verify.d.ts +14 -2
- package/package.json +2 -2
- package/src/browser.ts +2 -4
- package/src/clis/linkedin/search.ts +416 -0
- package/src/clis/reddit/read.ts +186 -0
- package/src/clis/youtube/transcript-group.test.ts +108 -0
- package/src/clis/youtube/transcript-group.ts +287 -0
- package/src/clis/youtube/transcript.ts +280 -0
- package/src/clis/youtube/utils.ts +28 -0
- package/src/clis/youtube/video.ts +116 -0
- package/src/engine.ts +4 -1
- package/src/main.ts +10 -2
- package/src/output.ts +2 -1
- package/src/registry.ts +1 -8
- package/src/snapshotFormatter.test.ts +579 -0
- package/src/snapshotFormatter.ts +399 -13
- package/src/validate.ts +19 -4
- package/src/verify.ts +17 -3
- package/vitest.config.ts +15 -1
- package/dist/clis/reddit/read.yaml +0 -76
- package/src/clis/reddit/read.yaml +0 -76
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared YouTube utilities — URL parsing, video ID extraction, etc.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract a YouTube video ID from a URL or bare video ID string.
|
|
7
|
+
* Supports: watch?v=, youtu.be/, /shorts/, /embed/, /live/, /v/
|
|
8
|
+
*/
|
|
9
|
+
export function parseVideoId(input: string): string {
|
|
10
|
+
if (!input.startsWith('http')) return input;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const parsed = new URL(input);
|
|
14
|
+
if (parsed.searchParams.has('v')) {
|
|
15
|
+
return parsed.searchParams.get('v')!;
|
|
16
|
+
}
|
|
17
|
+
if (parsed.hostname === 'youtu.be') {
|
|
18
|
+
return parsed.pathname.slice(1).split('/')[0];
|
|
19
|
+
}
|
|
20
|
+
// Handle /shorts/xxx, /embed/xxx, /live/xxx, /v/xxx
|
|
21
|
+
const pathMatch = parsed.pathname.match(/^\/(shorts|embed|live|v)\/([^/?]+)/);
|
|
22
|
+
if (pathMatch) return pathMatch[2];
|
|
23
|
+
} catch {
|
|
24
|
+
// Not a valid URL — treat entire input as video ID
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return input;
|
|
28
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube video metadata — read ytInitialPlayerResponse + ytInitialData from video page.
|
|
3
|
+
*/
|
|
4
|
+
import { cli, Strategy } from '../../registry.js';
|
|
5
|
+
import { parseVideoId } from './utils.js';
|
|
6
|
+
|
|
7
|
+
cli({
|
|
8
|
+
site: 'youtube',
|
|
9
|
+
name: 'video',
|
|
10
|
+
description: 'Get YouTube video metadata (title, views, description, etc.)',
|
|
11
|
+
domain: 'www.youtube.com',
|
|
12
|
+
strategy: Strategy.COOKIE,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'url', required: true, help: 'YouTube video URL or video ID' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['field', 'value'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
const videoId = parseVideoId(kwargs.url);
|
|
19
|
+
const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
20
|
+
await page.goto(videoUrl);
|
|
21
|
+
await page.wait(3);
|
|
22
|
+
|
|
23
|
+
const data = await page.evaluate(`
|
|
24
|
+
(async () => {
|
|
25
|
+
const player = window.ytInitialPlayerResponse;
|
|
26
|
+
const yt = window.ytInitialData;
|
|
27
|
+
if (!player) return { error: 'ytInitialPlayerResponse not found' };
|
|
28
|
+
|
|
29
|
+
const details = player.videoDetails || {};
|
|
30
|
+
const microformat = player.microformat?.playerMicroformatRenderer || {};
|
|
31
|
+
|
|
32
|
+
// Try to get full description from ytInitialData
|
|
33
|
+
let fullDescription = details.shortDescription || '';
|
|
34
|
+
try {
|
|
35
|
+
const contents = yt?.contents?.twoColumnWatchNextResults
|
|
36
|
+
?.results?.results?.contents;
|
|
37
|
+
if (contents) {
|
|
38
|
+
for (const c of contents) {
|
|
39
|
+
const desc = c.videoSecondaryInfoRenderer?.attributedDescription?.content;
|
|
40
|
+
if (desc) { fullDescription = desc; break; }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch {}
|
|
44
|
+
|
|
45
|
+
// Get like count if available
|
|
46
|
+
let likes = '';
|
|
47
|
+
try {
|
|
48
|
+
const contents = yt?.contents?.twoColumnWatchNextResults
|
|
49
|
+
?.results?.results?.contents;
|
|
50
|
+
if (contents) {
|
|
51
|
+
for (const c of contents) {
|
|
52
|
+
const buttons = c.videoPrimaryInfoRenderer?.videoActions
|
|
53
|
+
?.menuRenderer?.topLevelButtons;
|
|
54
|
+
if (buttons) {
|
|
55
|
+
for (const b of buttons) {
|
|
56
|
+
const toggle = b.segmentedLikeDislikeButtonViewModel
|
|
57
|
+
?.likeButtonViewModel?.likeButtonViewModel?.toggleButtonViewModel
|
|
58
|
+
?.toggleButtonViewModel?.defaultButtonViewModel?.buttonViewModel;
|
|
59
|
+
if (toggle?.title) { likes = toggle.title; break; }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
|
|
66
|
+
// Get publish date
|
|
67
|
+
const publishDate = microformat.publishDate
|
|
68
|
+
|| microformat.uploadDate
|
|
69
|
+
|| details.publishDate || '';
|
|
70
|
+
|
|
71
|
+
// Get category
|
|
72
|
+
const category = microformat.category || '';
|
|
73
|
+
|
|
74
|
+
// Get channel subscriber count if available
|
|
75
|
+
let subscribers = '';
|
|
76
|
+
try {
|
|
77
|
+
const contents = yt?.contents?.twoColumnWatchNextResults
|
|
78
|
+
?.results?.results?.contents;
|
|
79
|
+
if (contents) {
|
|
80
|
+
for (const c of contents) {
|
|
81
|
+
const owner = c.videoSecondaryInfoRenderer?.owner
|
|
82
|
+
?.videoOwnerRenderer?.subscriberCountText?.simpleText;
|
|
83
|
+
if (owner) { subscribers = owner; break; }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
title: details.title || '',
|
|
90
|
+
channel: details.author || '',
|
|
91
|
+
channelId: details.channelId || '',
|
|
92
|
+
videoId: details.videoId || '',
|
|
93
|
+
views: details.viewCount || '',
|
|
94
|
+
likes,
|
|
95
|
+
subscribers,
|
|
96
|
+
duration: details.lengthSeconds ? details.lengthSeconds + 's' : '',
|
|
97
|
+
publishDate,
|
|
98
|
+
category,
|
|
99
|
+
description: fullDescription,
|
|
100
|
+
keywords: (details.keywords || []).join(', '),
|
|
101
|
+
isLive: details.isLiveContent || false,
|
|
102
|
+
thumbnail: details.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
|
|
103
|
+
};
|
|
104
|
+
})()
|
|
105
|
+
`);
|
|
106
|
+
|
|
107
|
+
if (!data || typeof data !== 'object') throw new Error('Failed to extract video metadata from page');
|
|
108
|
+
if (data.error) throw new Error(data.error);
|
|
109
|
+
|
|
110
|
+
// Return as field/value pairs for table display
|
|
111
|
+
return Object.entries(data).map(([field, value]) => ({
|
|
112
|
+
field,
|
|
113
|
+
value: String(value),
|
|
114
|
+
}));
|
|
115
|
+
},
|
|
116
|
+
});
|
package/src/engine.ts
CHANGED
|
@@ -101,7 +101,10 @@ async function discoverClisFromFs(dir: string): Promise<void> {
|
|
|
101
101
|
const filePath = path.join(siteDir, file);
|
|
102
102
|
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
|
|
103
103
|
registerYamlCli(filePath, site);
|
|
104
|
-
} else if (
|
|
104
|
+
} else if (
|
|
105
|
+
(file.endsWith('.js') && !file.endsWith('.d.js')) ||
|
|
106
|
+
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))
|
|
107
|
+
) {
|
|
105
108
|
promises.push(
|
|
106
109
|
import(`file://${filePath}`).catch((err: any) => {
|
|
107
110
|
process.stderr.write(`Warning: failed to load module ${filePath}: ${err.message}\n`);
|
package/src/main.ts
CHANGED
|
@@ -62,10 +62,18 @@ program.command('list').description('List all available CLI commands').option('-
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
program.command('validate').description('Validate CLI definitions').argument('[target]', 'site or site/name')
|
|
65
|
-
.action(async (target) => {
|
|
65
|
+
.action(async (target) => {
|
|
66
|
+
const { validateClisWithTarget, renderValidationReport } = await import('./validate.js');
|
|
67
|
+
console.log(renderValidationReport(validateClisWithTarget([BUILTIN_CLIS, USER_CLIS], target)));
|
|
68
|
+
});
|
|
66
69
|
|
|
67
70
|
program.command('verify').description('Validate + smoke test').argument('[target]').option('--smoke', 'Run smoke tests', false)
|
|
68
|
-
.action(async (target, opts) => {
|
|
71
|
+
.action(async (target, opts) => {
|
|
72
|
+
const { verifyClis, renderVerifyReport } = await import('./verify.js');
|
|
73
|
+
const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke });
|
|
74
|
+
console.log(renderVerifyReport(r));
|
|
75
|
+
process.exitCode = r.ok ? 0 : 1;
|
|
76
|
+
});
|
|
69
77
|
|
|
70
78
|
program.command('explore').alias('probe').description('Explore a website: discover APIs, stores, and recommend strategies').argument('<url>').option('--site <name>').option('--goal <text>').option('--wait <s>', '', '3').option('--auto', 'Enable interactive fuzzing (simulate clicks to trigger lazy APIs)').option('--click <labels>', 'Comma-separated labels to click before fuzzing (e.g. "字幕,CC,评论")')
|
|
71
79
|
.action(async (url, opts) => { const { exploreUrl, renderExploreSummary } = await import('./explore.js'); const clickLabels = opts.click ? opts.click.split(',').map((s: string) => s.trim()) : undefined; console.log(renderExploreSummary(await exploreUrl(url, { BrowserFactory: PlaywrightMCP, site: opts.site, goal: opts.goal, waitSeconds: parseFloat(opts.wait), auto: opts.auto, clickLabels }))); });
|
package/src/output.ts
CHANGED
|
@@ -82,7 +82,8 @@ function renderCsv(data: any, opts: RenderOptions): void {
|
|
|
82
82
|
for (const row of rows) {
|
|
83
83
|
console.log(columns.map(c => {
|
|
84
84
|
const v = String(row[c] ?? '');
|
|
85
|
-
return v.includes(',') || v.includes('"')
|
|
85
|
+
return v.includes(',') || v.includes('"') || v.includes('\n')
|
|
86
|
+
? `"${v.replace(/"/g, '""')}"` : v;
|
|
86
87
|
}).join(','));
|
|
87
88
|
}
|
|
88
89
|
}
|
package/src/registry.ts
CHANGED
|
@@ -42,18 +42,11 @@ export interface InternalCliCommand extends CliCommand {
|
|
|
42
42
|
_lazy?: boolean;
|
|
43
43
|
_modulePath?: string;
|
|
44
44
|
}
|
|
45
|
-
export interface CliOptions {
|
|
45
|
+
export interface CliOptions extends Partial<Omit<CliCommand, 'args' | 'description'>> {
|
|
46
46
|
site: string;
|
|
47
47
|
name: string;
|
|
48
48
|
description?: string;
|
|
49
|
-
domain?: string;
|
|
50
|
-
strategy?: Strategy;
|
|
51
|
-
browser?: boolean;
|
|
52
49
|
args?: Arg[];
|
|
53
|
-
columns?: string[];
|
|
54
|
-
func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
|
|
55
|
-
pipeline?: any[];
|
|
56
|
-
timeoutSeconds?: number;
|
|
57
50
|
}
|
|
58
51
|
const _registry = new Map<string, CliCommand>();
|
|
59
52
|
|