@jackwener/opencli 0.7.2 → 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.
@@ -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
+ });
@@ -1,76 +0,0 @@
1
- site: reddit
2
- name: read
3
- description: Read a Reddit post and its comments
4
- domain: reddit.com
5
- strategy: cookie
6
- browser: true
7
-
8
- args:
9
- post_id:
10
- type: string
11
- required: true
12
- description: "Post ID (e.g. 1abc123) or full URL"
13
- sort:
14
- type: string
15
- default: best
16
- description: "Comment sort: best, top, new, controversial, old, qa"
17
- limit:
18
- type: int
19
- default: 25
20
- description: Number of top-level comments to fetch
21
-
22
- columns: [type, author, score, text]
23
-
24
- pipeline:
25
- - navigate: https://www.reddit.com
26
- - evaluate: |
27
- (async () => {
28
- let postId = ${{ args.post_id | json }};
29
- const urlMatch = postId.match(/comments\/([a-z0-9]+)/);
30
- if (urlMatch) postId = urlMatch[1];
31
-
32
- const sort = ${{ args.sort | json }};
33
- const limit = ${{ args.limit }};
34
- const res = await fetch('/comments/' + postId + '.json?sort=' + sort + '&limit=' + limit + '&raw_json=1', {
35
- credentials: 'include'
36
- });
37
- const data = await res.json();
38
- if (!Array.isArray(data) || data.length < 1) return [];
39
-
40
- const results = [];
41
-
42
- // First element: post itself
43
- const post = data[0]?.data?.children?.[0]?.data;
44
- if (post) {
45
- let body = post.selftext || '';
46
- if (body.length > 2000) body = body.slice(0, 2000) + '\n... [truncated]';
47
- results.push({
48
- type: '📰 POST',
49
- author: post.author,
50
- score: post.score,
51
- text: post.title + (body ? '\n\n' + body : '') + (post.url && !post.is_self ? '\n🔗 ' + post.url : ''),
52
- });
53
- }
54
-
55
- // Second element: comments
56
- const comments = data[1]?.data?.children || [];
57
- for (const c of comments) {
58
- if (c.kind !== 't1') continue;
59
- const d = c.data;
60
- let body = d.body || '';
61
- if (body.length > 500) body = body.slice(0, 500) + '...';
62
- results.push({
63
- type: '💬 COMMENT',
64
- author: d.author || '[deleted]',
65
- score: d.score || 0,
66
- text: body,
67
- });
68
- }
69
-
70
- return results;
71
- })()
72
- - map:
73
- type: ${{ item.type }}
74
- author: ${{ item.author }}
75
- score: ${{ item.score }}
76
- text: ${{ item.text }}
@@ -1,76 +0,0 @@
1
- site: reddit
2
- name: read
3
- description: Read a Reddit post and its comments
4
- domain: reddit.com
5
- strategy: cookie
6
- browser: true
7
-
8
- args:
9
- post_id:
10
- type: string
11
- required: true
12
- description: "Post ID (e.g. 1abc123) or full URL"
13
- sort:
14
- type: string
15
- default: best
16
- description: "Comment sort: best, top, new, controversial, old, qa"
17
- limit:
18
- type: int
19
- default: 25
20
- description: Number of top-level comments to fetch
21
-
22
- columns: [type, author, score, text]
23
-
24
- pipeline:
25
- - navigate: https://www.reddit.com
26
- - evaluate: |
27
- (async () => {
28
- let postId = ${{ args.post_id | json }};
29
- const urlMatch = postId.match(/comments\/([a-z0-9]+)/);
30
- if (urlMatch) postId = urlMatch[1];
31
-
32
- const sort = ${{ args.sort | json }};
33
- const limit = ${{ args.limit }};
34
- const res = await fetch('/comments/' + postId + '.json?sort=' + sort + '&limit=' + limit + '&raw_json=1', {
35
- credentials: 'include'
36
- });
37
- const data = await res.json();
38
- if (!Array.isArray(data) || data.length < 1) return [];
39
-
40
- const results = [];
41
-
42
- // First element: post itself
43
- const post = data[0]?.data?.children?.[0]?.data;
44
- if (post) {
45
- let body = post.selftext || '';
46
- if (body.length > 2000) body = body.slice(0, 2000) + '\n... [truncated]';
47
- results.push({
48
- type: '📰 POST',
49
- author: post.author,
50
- score: post.score,
51
- text: post.title + (body ? '\n\n' + body : '') + (post.url && !post.is_self ? '\n🔗 ' + post.url : ''),
52
- });
53
- }
54
-
55
- // Second element: comments
56
- const comments = data[1]?.data?.children || [];
57
- for (const c of comments) {
58
- if (c.kind !== 't1') continue;
59
- const d = c.data;
60
- let body = d.body || '';
61
- if (body.length > 500) body = body.slice(0, 500) + '...';
62
- results.push({
63
- type: '💬 COMMENT',
64
- author: d.author || '[deleted]',
65
- score: d.score || 0,
66
- text: body,
67
- });
68
- }
69
-
70
- return results;
71
- })()
72
- - map:
73
- type: ${{ item.type }}
74
- author: ${{ item.author }}
75
- score: ${{ item.score }}
76
- text: ${{ item.text }}