@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.
- package/README.md +2 -1
- package/README.zh-CN.md +1 -0
- package/SKILL.md +3 -0
- 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/package.json +1 -1
- 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/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
|
+
});
|
|
@@ -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 }}
|