@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,264 @@
1
+ /**
2
+ * YouTube transcript — uses InnerTube player API with Android client context.
3
+ *
4
+ * The Web client's caption URLs require a PoToken (proof of origin) generated
5
+ * by BotGuard at runtime. The Android client returns caption URLs that work
6
+ * without PoToken — same approach used by youtube-transcript-api (Python).
7
+ *
8
+ * Modes:
9
+ * --mode grouped (default): sentences merged, speaker detection, chapters
10
+ * --mode raw: every caption segment as-is with precise timestamps
11
+ */
12
+ import { cli, Strategy } from '../../registry.js';
13
+ import { parseVideoId } from './utils.js';
14
+ import { groupTranscriptSegments, formatGroupedTranscript, } from './transcript-group.js';
15
+ cli({
16
+ site: 'youtube',
17
+ name: 'transcript',
18
+ description: 'Get YouTube video transcript/subtitles',
19
+ domain: 'www.youtube.com',
20
+ strategy: Strategy.COOKIE,
21
+ args: [
22
+ { name: 'url', required: true, help: 'YouTube video URL or video ID' },
23
+ { name: 'lang', required: false, help: 'Language code (e.g. en, zh-Hans). Omit to auto-select' },
24
+ { name: 'mode', required: false, default: 'grouped', help: 'Output mode: grouped (readable paragraphs) or raw (every segment)' },
25
+ ],
26
+ // columns intentionally omitted — raw and grouped modes return different schemas,
27
+ // so we let the renderer auto-detect columns from the data keys.
28
+ func: async (page, kwargs) => {
29
+ const videoId = parseVideoId(kwargs.url);
30
+ const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
31
+ await page.goto(videoUrl);
32
+ await page.wait(3);
33
+ const lang = kwargs.lang || '';
34
+ const mode = kwargs.mode || 'grouped';
35
+ // Step 1: Get caption track URL via Android InnerTube API
36
+ const captionData = await page.evaluate(`
37
+ (async () => {
38
+ const cfg = window.ytcfg?.data_ || {};
39
+ const apiKey = cfg.INNERTUBE_API_KEY;
40
+ if (!apiKey) return { error: 'INNERTUBE_API_KEY not found on page' };
41
+
42
+ const resp = await fetch('/youtubei/v1/player?key=' + apiKey + '&prettyPrint=false', {
43
+ method: 'POST',
44
+ credentials: 'include',
45
+ headers: { 'Content-Type': 'application/json' },
46
+ body: JSON.stringify({
47
+ context: { client: { clientName: 'ANDROID', clientVersion: '20.10.38' } },
48
+ videoId: ${JSON.stringify(videoId)}
49
+ })
50
+ });
51
+
52
+ if (!resp.ok) return { error: 'InnerTube player API returned HTTP ' + resp.status };
53
+ const data = await resp.json();
54
+
55
+ const renderer = data.captions?.playerCaptionsTracklistRenderer;
56
+ if (!renderer?.captionTracks?.length) {
57
+ return { error: 'No captions available for this video' };
58
+ }
59
+
60
+ const tracks = renderer.captionTracks;
61
+ const available = tracks.map(t => t.languageCode + (t.kind === 'asr' ? ' (auto)' : ''));
62
+
63
+ const langPref = ${JSON.stringify(lang)};
64
+ let track = null;
65
+ if (langPref) {
66
+ track = tracks.find(t => t.languageCode === langPref)
67
+ || tracks.find(t => t.languageCode.startsWith(langPref));
68
+ }
69
+ if (!track) {
70
+ track = tracks.find(t => t.kind !== 'asr') || tracks[0];
71
+ }
72
+
73
+ return {
74
+ captionUrl: track.baseUrl,
75
+ language: track.languageCode,
76
+ kind: track.kind || 'manual',
77
+ available,
78
+ requestedLang: langPref || null,
79
+ langMatched: !!(langPref && track.languageCode === langPref),
80
+ langPrefixMatched: !!(langPref && track.languageCode !== langPref && track.languageCode.startsWith(langPref))
81
+ };
82
+ })()
83
+ `);
84
+ if (!captionData || typeof captionData === 'string') {
85
+ throw new Error(`Failed to get caption info: ${typeof captionData === 'string' ? captionData : 'null response'}`);
86
+ }
87
+ if (captionData.error) {
88
+ throw new Error(`${captionData.error}${captionData.available ? ' (available: ' + captionData.available.join(', ') + ')' : ''}`);
89
+ }
90
+ // Warn if --lang was specified but not matched
91
+ if (captionData.requestedLang && !captionData.langMatched && !captionData.langPrefixMatched) {
92
+ console.error(`Warning: --lang "${captionData.requestedLang}" not found. Using "${captionData.language}" instead. Available: ${captionData.available.join(', ')}`);
93
+ }
94
+ // Step 2: Fetch caption XML and parse segments
95
+ const segments = await page.evaluate(`
96
+ (async () => {
97
+ const resp = await fetch(${JSON.stringify(captionData.captionUrl)});
98
+ const xml = await resp.text();
99
+
100
+ if (!xml?.length) {
101
+ return { error: 'Caption URL returned empty response' };
102
+ }
103
+
104
+ function getAttr(tag, name) {
105
+ const needle = name + '="';
106
+ const idx = tag.indexOf(needle);
107
+ if (idx === -1) return '';
108
+ const valStart = idx + needle.length;
109
+ const valEnd = tag.indexOf('"', valStart);
110
+ if (valEnd === -1) return '';
111
+ return tag.substring(valStart, valEnd);
112
+ }
113
+
114
+ function decodeEntities(s) {
115
+ return s
116
+ .replaceAll('&', '&')
117
+ .replaceAll('&lt;', '<')
118
+ .replaceAll('&gt;', '>')
119
+ .replaceAll('&quot;', '"')
120
+ .replaceAll('&#39;', "'");
121
+ }
122
+
123
+ const isFormat3 = xml.includes('<p t="');
124
+ const marker = isFormat3 ? '<p ' : '<text ';
125
+ const endMarker = isFormat3 ? '</p>' : '</text>';
126
+ const results = [];
127
+ let pos = 0;
128
+
129
+ while (true) {
130
+ const tagStart = xml.indexOf(marker, pos);
131
+ if (tagStart === -1) break;
132
+ let contentStart = xml.indexOf('>', tagStart);
133
+ if (contentStart === -1) break;
134
+ contentStart += 1;
135
+ const tagEnd = xml.indexOf(endMarker, contentStart);
136
+ if (tagEnd === -1) break;
137
+
138
+ const attrStr = xml.substring(tagStart + marker.length, contentStart - 1);
139
+ const content = xml.substring(contentStart, tagEnd);
140
+
141
+ let startSec, durSec;
142
+ if (isFormat3) {
143
+ startSec = (parseFloat(getAttr(attrStr, 't')) || 0) / 1000;
144
+ durSec = (parseFloat(getAttr(attrStr, 'd')) || 0) / 1000;
145
+ } else {
146
+ startSec = parseFloat(getAttr(attrStr, 'start')) || 0;
147
+ durSec = parseFloat(getAttr(attrStr, 'dur')) || 0;
148
+ }
149
+
150
+ // Strip inner tags (e.g. <s> in srv3 format) and decode entities
151
+ const text = decodeEntities(content.replace(/<[^>]+>/g, '')).split('\\\\n').join(' ').trim();
152
+ if (text) {
153
+ results.push({ start: startSec, end: startSec + durSec, text });
154
+ }
155
+
156
+ pos = tagEnd + endMarker.length;
157
+ }
158
+
159
+ if (results.length === 0) {
160
+ return { error: 'Parsed 0 segments from caption XML' };
161
+ }
162
+
163
+ return results;
164
+ })()
165
+ `);
166
+ if (!Array.isArray(segments)) {
167
+ throw new Error(segments?.error || 'Failed to parse caption segments');
168
+ }
169
+ if (segments.length === 0) {
170
+ throw new Error('No caption segments found');
171
+ }
172
+ // Step 3: Fetch chapters (for grouped mode)
173
+ let chapters = [];
174
+ if (mode === 'grouped') {
175
+ try {
176
+ const chapterData = await page.evaluate(`
177
+ (async () => {
178
+ const cfg = window.ytcfg?.data_ || {};
179
+ const apiKey = cfg.INNERTUBE_API_KEY;
180
+ if (!apiKey) return [];
181
+
182
+ const resp = await fetch('/youtubei/v1/next?key=' + apiKey + '&prettyPrint=false', {
183
+ method: 'POST',
184
+ credentials: 'include',
185
+ headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({
187
+ context: { client: { clientName: 'WEB', clientVersion: '2.20240101.00.00' } },
188
+ videoId: ${JSON.stringify(videoId)}
189
+ })
190
+ });
191
+ if (!resp.ok) return [];
192
+ const data = await resp.json();
193
+
194
+ const chapters = [];
195
+
196
+ // Try chapterRenderer from player bar
197
+ const panels = data.playerOverlays?.playerOverlayRenderer
198
+ ?.decoratedPlayerBarRenderer?.decoratedPlayerBarRenderer
199
+ ?.playerBar?.multiMarkersPlayerBarRenderer?.markersMap;
200
+
201
+ if (Array.isArray(panels)) {
202
+ for (const panel of panels) {
203
+ const markers = panel.value?.chapters;
204
+ if (!Array.isArray(markers)) continue;
205
+ for (const marker of markers) {
206
+ const ch = marker.chapterRenderer;
207
+ if (!ch) continue;
208
+ const title = ch.title?.simpleText || '';
209
+ const startMs = ch.timeRangeStartMillis;
210
+ if (title && typeof startMs === 'number') {
211
+ chapters.push({ title, start: startMs / 1000 });
212
+ }
213
+ }
214
+ }
215
+ }
216
+ if (chapters.length > 0) return chapters;
217
+
218
+ // Fallback: macroMarkersListItemRenderer from engagement panels
219
+ const engPanels = data.engagementPanels;
220
+ if (!Array.isArray(engPanels)) return [];
221
+ for (const ep of engPanels) {
222
+ const content = ep.engagementPanelSectionListRenderer?.content;
223
+ const items = content?.macroMarkersListRenderer?.contents;
224
+ if (!Array.isArray(items)) continue;
225
+ for (const item of items) {
226
+ const renderer = item.macroMarkersListItemRenderer;
227
+ if (!renderer) continue;
228
+ const t = renderer.title?.simpleText || '';
229
+ const ts = renderer.timeDescription?.simpleText || '';
230
+ if (!t || !ts) continue;
231
+ const parts = ts.split(':').map(Number);
232
+ let secs = null;
233
+ if (parts.length === 3 && parts.every(n => !isNaN(n))) secs = parts[0]*3600 + parts[1]*60 + parts[2];
234
+ else if (parts.length === 2 && parts.every(n => !isNaN(n))) secs = parts[0]*60 + parts[1];
235
+ if (secs !== null) chapters.push({ title: t, start: secs });
236
+ }
237
+ }
238
+ return chapters;
239
+ })()
240
+ `);
241
+ if (Array.isArray(chapterData)) {
242
+ chapters = chapterData;
243
+ }
244
+ }
245
+ catch {
246
+ // Chapters are optional — proceed without them
247
+ }
248
+ }
249
+ // Step 4: Format output based on mode
250
+ if (mode === 'raw') {
251
+ // Precise timestamps in seconds with decimals, matching bilibili/subtitle format
252
+ return segments.map((seg, i) => ({
253
+ index: i + 1,
254
+ start: Number(seg.start).toFixed(2) + 's',
255
+ end: Number(seg.end).toFixed(2) + 's',
256
+ text: seg.text,
257
+ }));
258
+ }
259
+ // Grouped mode: merge sentences, detect speakers, insert chapters
260
+ const grouped = groupTranscriptSegments(segments.map(s => ({ start: s.start, text: s.text })));
261
+ const { rows } = formatGroupedTranscript(grouped, chapters);
262
+ return rows;
263
+ },
264
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Shared YouTube utilities — URL parsing, video ID extraction, etc.
3
+ */
4
+ /**
5
+ * Extract a YouTube video ID from a URL or bare video ID string.
6
+ * Supports: watch?v=, youtu.be/, /shorts/, /embed/, /live/, /v/
7
+ */
8
+ export declare function parseVideoId(input: string): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared YouTube utilities — URL parsing, video ID extraction, etc.
3
+ */
4
+ /**
5
+ * Extract a YouTube video ID from a URL or bare video ID string.
6
+ * Supports: watch?v=, youtu.be/, /shorts/, /embed/, /live/, /v/
7
+ */
8
+ export function parseVideoId(input) {
9
+ if (!input.startsWith('http'))
10
+ return input;
11
+ try {
12
+ const parsed = new URL(input);
13
+ if (parsed.searchParams.has('v')) {
14
+ return parsed.searchParams.get('v');
15
+ }
16
+ if (parsed.hostname === 'youtu.be') {
17
+ return parsed.pathname.slice(1).split('/')[0];
18
+ }
19
+ // Handle /shorts/xxx, /embed/xxx, /live/xxx, /v/xxx
20
+ const pathMatch = parsed.pathname.match(/^\/(shorts|embed|live|v)\/([^/?]+)/);
21
+ if (pathMatch)
22
+ return pathMatch[2];
23
+ }
24
+ catch {
25
+ // Not a valid URL — treat entire input as video ID
26
+ }
27
+ return input;
28
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,114 @@
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
+ cli({
7
+ site: 'youtube',
8
+ name: 'video',
9
+ description: 'Get YouTube video metadata (title, views, description, etc.)',
10
+ domain: 'www.youtube.com',
11
+ strategy: Strategy.COOKIE,
12
+ args: [
13
+ { name: 'url', required: true, help: 'YouTube video URL or video ID' },
14
+ ],
15
+ columns: ['field', 'value'],
16
+ func: async (page, kwargs) => {
17
+ const videoId = parseVideoId(kwargs.url);
18
+ const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
19
+ await page.goto(videoUrl);
20
+ await page.wait(3);
21
+ const data = await page.evaluate(`
22
+ (async () => {
23
+ const player = window.ytInitialPlayerResponse;
24
+ const yt = window.ytInitialData;
25
+ if (!player) return { error: 'ytInitialPlayerResponse not found' };
26
+
27
+ const details = player.videoDetails || {};
28
+ const microformat = player.microformat?.playerMicroformatRenderer || {};
29
+
30
+ // Try to get full description from ytInitialData
31
+ let fullDescription = details.shortDescription || '';
32
+ try {
33
+ const contents = yt?.contents?.twoColumnWatchNextResults
34
+ ?.results?.results?.contents;
35
+ if (contents) {
36
+ for (const c of contents) {
37
+ const desc = c.videoSecondaryInfoRenderer?.attributedDescription?.content;
38
+ if (desc) { fullDescription = desc; break; }
39
+ }
40
+ }
41
+ } catch {}
42
+
43
+ // Get like count if available
44
+ let likes = '';
45
+ try {
46
+ const contents = yt?.contents?.twoColumnWatchNextResults
47
+ ?.results?.results?.contents;
48
+ if (contents) {
49
+ for (const c of contents) {
50
+ const buttons = c.videoPrimaryInfoRenderer?.videoActions
51
+ ?.menuRenderer?.topLevelButtons;
52
+ if (buttons) {
53
+ for (const b of buttons) {
54
+ const toggle = b.segmentedLikeDislikeButtonViewModel
55
+ ?.likeButtonViewModel?.likeButtonViewModel?.toggleButtonViewModel
56
+ ?.toggleButtonViewModel?.defaultButtonViewModel?.buttonViewModel;
57
+ if (toggle?.title) { likes = toggle.title; break; }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ } catch {}
63
+
64
+ // Get publish date
65
+ const publishDate = microformat.publishDate
66
+ || microformat.uploadDate
67
+ || details.publishDate || '';
68
+
69
+ // Get category
70
+ const category = microformat.category || '';
71
+
72
+ // Get channel subscriber count if available
73
+ let subscribers = '';
74
+ try {
75
+ const contents = yt?.contents?.twoColumnWatchNextResults
76
+ ?.results?.results?.contents;
77
+ if (contents) {
78
+ for (const c of contents) {
79
+ const owner = c.videoSecondaryInfoRenderer?.owner
80
+ ?.videoOwnerRenderer?.subscriberCountText?.simpleText;
81
+ if (owner) { subscribers = owner; break; }
82
+ }
83
+ }
84
+ } catch {}
85
+
86
+ return {
87
+ title: details.title || '',
88
+ channel: details.author || '',
89
+ channelId: details.channelId || '',
90
+ videoId: details.videoId || '',
91
+ views: details.viewCount || '',
92
+ likes,
93
+ subscribers,
94
+ duration: details.lengthSeconds ? details.lengthSeconds + 's' : '',
95
+ publishDate,
96
+ category,
97
+ description: fullDescription,
98
+ keywords: (details.keywords || []).join(', '),
99
+ isLive: details.isLiveContent || false,
100
+ thumbnail: details.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
101
+ };
102
+ })()
103
+ `);
104
+ if (!data || typeof data !== 'object')
105
+ throw new Error('Failed to extract video metadata from page');
106
+ if (data.error)
107
+ throw new Error(data.error);
108
+ // Return as field/value pairs for table display
109
+ return Object.entries(data).map(([field, value]) => ({
110
+ field,
111
+ value: String(value),
112
+ }));
113
+ },
114
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },