headless-youtube-captions 1.4.0 → 2.0.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/CHANGELOG.md +26 -0
- package/debug-transcript.js +124 -0
- package/package.json +5 -5
- package/src/channel.js +2 -2
- package/src/comments.js +3 -4
- package/src/index.d.ts +2 -1
- package/src/index.js +65 -238
- package/src/metadata.js +1 -1
- package/src/search.js +1 -1
- package/src/utils/browser.js +22 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.1] - 2025-10-28
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **CRITICAL FIX**: Transcript extraction now works reliably with YouTube's current UI
|
|
7
|
+
- Fixed description expansion to click on container directly instead of "more" button
|
|
8
|
+
- Added visibility checks before clicking transcript button
|
|
9
|
+
- Improved scroll behavior to ensure transcript button is in viewport
|
|
10
|
+
- Increased wait times for transcript panel initialization (3s → 5s)
|
|
11
|
+
- Increased timeout for segment loading (10s → 15s)
|
|
12
|
+
- Better error logging for debugging click failures
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Updated test suite to use video with stable, reliable transcripts
|
|
16
|
+
- Improved transcript button click reliability using `page.click(selector)`
|
|
17
|
+
- Added scroll-into-view for transcript button when not visible
|
|
18
|
+
- Enhanced description expansion with more selector options
|
|
19
|
+
|
|
20
|
+
### Technical Details
|
|
21
|
+
- Updated `src/index.js` with improved UI interaction logic
|
|
22
|
+
- Updated `test/index.test.js` to use `dQw4w9WgXcQ` (stable test video)
|
|
23
|
+
- All 7 tests now passing successfully
|
|
24
|
+
- Added comprehensive documentation in `docs/` folder:
|
|
25
|
+
- `action-flow-runbook.csv` - Complete action flow with fix notes
|
|
26
|
+
- `transcript-fix-analysis.md` - Detailed technical analysis
|
|
27
|
+
- `fix-summary.md` - Summary of changes
|
|
28
|
+
|
|
3
29
|
## [1.3.0] - 2025-01-24
|
|
4
30
|
|
|
5
31
|
### Added
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import puppeteer from 'puppeteer';
|
|
2
|
+
|
|
3
|
+
const videoID = 'E_i0SOsMp5I';
|
|
4
|
+
|
|
5
|
+
async function debugTranscript() {
|
|
6
|
+
console.log('Launching browser in HEADED mode with cookie fixes...');
|
|
7
|
+
|
|
8
|
+
const browser = await puppeteer.launch({
|
|
9
|
+
headless: false,
|
|
10
|
+
args: [
|
|
11
|
+
'--no-sandbox',
|
|
12
|
+
'--disable-setuid-sandbox',
|
|
13
|
+
'--window-size=1920,1080',
|
|
14
|
+
'--disable-dev-shm-usage',
|
|
15
|
+
'--disable-features=ThirdPartyCookieBlocking,BlockThirdPartyCookies',
|
|
16
|
+
'--disable-blink-features=BlockCredentialedSubresources',
|
|
17
|
+
'--disable-site-isolation-trials',
|
|
18
|
+
],
|
|
19
|
+
defaultViewport: { width: 1920, height: 1080 }
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const page = await browser.newPage();
|
|
23
|
+
|
|
24
|
+
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36');
|
|
25
|
+
|
|
26
|
+
console.log(`Navigating to https://www.youtube.com/watch?v=${videoID}`);
|
|
27
|
+
await page.goto(`https://www.youtube.com/watch?v=${videoID}`, {
|
|
28
|
+
waitUntil: 'networkidle2',
|
|
29
|
+
timeout: 60000
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
console.log('Page loaded. Waiting for video player...');
|
|
33
|
+
await page.waitForSelector('#movie_player, video', { timeout: 30000 });
|
|
34
|
+
console.log('Video player found.');
|
|
35
|
+
|
|
36
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
37
|
+
|
|
38
|
+
// Accept cookie consent
|
|
39
|
+
try {
|
|
40
|
+
const accepted = await page.evaluate(() => {
|
|
41
|
+
const btns = document.querySelectorAll('button');
|
|
42
|
+
for (const b of btns) {
|
|
43
|
+
const txt = (b.textContent || '').trim().toLowerCase();
|
|
44
|
+
if (txt === 'accept all' || txt === 'accept the use of cookies and other data for the purposes described') {
|
|
45
|
+
b.click(); return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
});
|
|
50
|
+
if (accepted) { console.log('Accepted cookie consent'); await new Promise(r => setTimeout(r, 2000)); }
|
|
51
|
+
} catch(e) {}
|
|
52
|
+
|
|
53
|
+
// Skip ads
|
|
54
|
+
try {
|
|
55
|
+
const skipBtn = await page.$('.ytp-ad-skip-button, .ytp-skip-ad-button');
|
|
56
|
+
if (skipBtn) { await skipBtn.click(); console.log('Skipped ad'); await new Promise(r => setTimeout(r, 2000)); }
|
|
57
|
+
} catch(e) {}
|
|
58
|
+
|
|
59
|
+
console.log('Scrolling down...');
|
|
60
|
+
await page.evaluate(() => window.scrollBy(0, 800));
|
|
61
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
62
|
+
|
|
63
|
+
console.log('Expanding description...');
|
|
64
|
+
try {
|
|
65
|
+
const desc = await page.$('#description-inline-expander');
|
|
66
|
+
if (desc) { await desc.click(); await new Promise(r => setTimeout(r, 2000)); console.log('Description expanded'); }
|
|
67
|
+
} catch(e) {}
|
|
68
|
+
|
|
69
|
+
await page.evaluate(() => window.scrollBy(0, 400));
|
|
70
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
71
|
+
|
|
72
|
+
console.log('Looking for transcript button...');
|
|
73
|
+
let clicked = false;
|
|
74
|
+
const btn = await page.$('button[aria-label="Show transcript"]');
|
|
75
|
+
if (btn) {
|
|
76
|
+
await btn.evaluate(e => e.scrollIntoView({ behavior: 'smooth', block: 'center' }));
|
|
77
|
+
await new Promise(r => setTimeout(r, 500));
|
|
78
|
+
await btn.click();
|
|
79
|
+
console.log('Clicked transcript button');
|
|
80
|
+
clicked = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!clicked) {
|
|
84
|
+
const textClick = await page.evaluate(() => {
|
|
85
|
+
const btns = Array.from(document.querySelectorAll('button, yt-button-shape'));
|
|
86
|
+
for (const b of btns) {
|
|
87
|
+
if ((b.textContent || '').toLowerCase().includes('transcript') ||
|
|
88
|
+
(b.getAttribute('aria-label') || '').toLowerCase().includes('transcript')) {
|
|
89
|
+
b.click(); return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
});
|
|
94
|
+
if (textClick) { console.log('Clicked via text search'); clicked = true; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (clicked) {
|
|
98
|
+
console.log('Waiting 15s for transcript panel...');
|
|
99
|
+
await new Promise(r => setTimeout(r, 15000));
|
|
100
|
+
|
|
101
|
+
const debug = await page.evaluate(() => ({
|
|
102
|
+
segmentRenderers: document.querySelectorAll('ytd-transcript-segment-renderer').length,
|
|
103
|
+
engagementPanels: document.querySelectorAll('ytd-engagement-panel-section-list-renderer').length,
|
|
104
|
+
transcriptPanel: !!document.querySelector('ytd-engagement-panel-section-list-renderer[target-id*="transcript"]'),
|
|
105
|
+
expandedPanels: Array.from(document.querySelectorAll('ytd-engagement-panel-section-list-renderer'))
|
|
106
|
+
.filter(p => p.getAttribute('visibility') === 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED').length,
|
|
107
|
+
continuationItems: document.querySelectorAll('ytd-continuation-item-renderer').length,
|
|
108
|
+
spinners: document.querySelectorAll('tp-yt-paper-spinner, paper-spinner').length
|
|
109
|
+
}));
|
|
110
|
+
console.log('DOM state:', JSON.stringify(debug, null, 2));
|
|
111
|
+
|
|
112
|
+
if (debug.segmentRenderers > 0) {
|
|
113
|
+
console.log('SUCCESS! Transcript segments loaded!');
|
|
114
|
+
} else {
|
|
115
|
+
console.log('STILL FAILING - segments did not load');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log('\n=== Browser open for inspection. Press Ctrl+C to close. ===');
|
|
120
|
+
await new Promise(r => setTimeout(r, 300000));
|
|
121
|
+
await browser.close();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
debugTranscript().catch(e => { console.error('ERROR:', e); process.exit(1); });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "headless-youtube-captions",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Extract YouTube video transcripts, channel videos, comments, and comprehensive video metadata
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Extract YouTube video transcripts (via yt-dlp), channel videos, comments, and comprehensive video metadata",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
7
7
|
"type": "module",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"he": "^1.2.0",
|
|
28
28
|
"lodash": "^4.17.21",
|
|
29
|
-
"
|
|
29
|
+
"patchright": "^1.51.1",
|
|
30
30
|
"striptags": "^3.2.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {},
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"videos",
|
|
42
42
|
"metadata",
|
|
43
43
|
"description",
|
|
44
|
-
"
|
|
45
|
-
"
|
|
44
|
+
"yt-dlp",
|
|
45
|
+
"patchright",
|
|
46
46
|
"scraper",
|
|
47
47
|
"api",
|
|
48
48
|
"automation",
|
package/src/channel.js
CHANGED
|
@@ -23,7 +23,7 @@ export async function getChannelVideos({ channelURL, limit = 30, pageToken = nul
|
|
|
23
23
|
|
|
24
24
|
console.error(`Navigating to ${fullURL}`);
|
|
25
25
|
await page.goto(fullURL, {
|
|
26
|
-
waitUntil: '
|
|
26
|
+
waitUntil: 'networkidle',
|
|
27
27
|
timeout: 60000
|
|
28
28
|
});
|
|
29
29
|
|
|
@@ -105,7 +105,7 @@ export async function searchChannelVideos({ channelURL, query, limit = 30 }) {
|
|
|
105
105
|
|
|
106
106
|
console.error(`Navigating to ${fullURL}`);
|
|
107
107
|
await page.goto(fullURL, {
|
|
108
|
-
waitUntil: '
|
|
108
|
+
waitUntil: 'networkidle',
|
|
109
109
|
timeout: 60000
|
|
110
110
|
});
|
|
111
111
|
|
package/src/comments.js
CHANGED
|
@@ -11,7 +11,7 @@ export async function getVideoComments({ videoID, limit = 50, sortBy = 'top', pa
|
|
|
11
11
|
// Navigate to the YouTube video page
|
|
12
12
|
console.error(`Navigating to https://youtube.com/watch?v=${videoID}`);
|
|
13
13
|
await page.goto(`https://youtube.com/watch?v=${videoID}`, {
|
|
14
|
-
waitUntil: '
|
|
14
|
+
waitUntil: 'networkidle',
|
|
15
15
|
timeout: 60000
|
|
16
16
|
});
|
|
17
17
|
|
|
@@ -35,9 +35,8 @@ export async function getVideoComments({ videoID, limit = 50, sortBy = 'top', pa
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
// Wait for comment threads to load
|
|
38
|
-
await page.waitForSelector('ytd-comment-thread-renderer', {
|
|
39
|
-
timeout: 10000
|
|
40
|
-
visible: true
|
|
38
|
+
await page.waitForSelector('ytd-comment-thread-renderer', {
|
|
39
|
+
timeout: 10000
|
|
41
40
|
});
|
|
42
41
|
|
|
43
42
|
console.error('Comments section loaded');
|
package/src/index.d.ts
CHANGED
|
@@ -15,7 +15,8 @@ export interface GetSubtitlesOptions {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Extract YouTube video transcripts using
|
|
18
|
+
* Extract YouTube video transcripts using yt-dlp (json3 subtitle format).
|
|
19
|
+
* Requires yt-dlp to be installed and available in PATH.
|
|
19
20
|
* @param options - Configuration options
|
|
20
21
|
* @returns Promise that resolves to an array of subtitle segments
|
|
21
22
|
*/
|
package/src/index.js
CHANGED
|
@@ -1,258 +1,85 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { mkdtemp, readdir, readFile, rm } from 'fs/promises';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { promisify } from 'util';
|
|
2
6
|
|
|
3
|
-
|
|
4
|
-
export async function getSubtitles({ videoID, lang = 'en' }) {
|
|
5
|
-
const browser = await createBrowser();
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
6
8
|
|
|
9
|
+
// Check if yt-dlp is available
|
|
10
|
+
async function ensureYtDlp() {
|
|
7
11
|
try {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
// Wait for initial page load
|
|
18
|
-
await page.waitForSelector('#movie_player, video', { timeout: 30000 });
|
|
19
|
-
console.error('Video player loaded');
|
|
20
|
-
|
|
21
|
-
// Wait a bit more for dynamic content to load
|
|
22
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
23
|
-
|
|
24
|
-
// Handle cookie consent if present
|
|
25
|
-
await handleCookieConsent(page);
|
|
12
|
+
await execFileAsync('which', ['yt-dlp']);
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error(
|
|
15
|
+
'yt-dlp is not installed or not in PATH. Install it: https://github.com/yt-dlp/yt-dlp#installation'
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
26
19
|
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
// Export existing function
|
|
21
|
+
export async function getSubtitles({ videoID, lang = 'en' }) {
|
|
22
|
+
await ensureYtDlp();
|
|
29
23
|
|
|
30
|
-
|
|
31
|
-
await page.evaluate(() => window.scrollBy(0, 800));
|
|
32
|
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
24
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'yt-subs-'));
|
|
33
25
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (isVisible) {
|
|
56
|
-
await moreButton.click();
|
|
57
|
-
console.error('Clicked "more" button');
|
|
58
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
59
|
-
break;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
} catch (e) {
|
|
63
|
-
// Try next selector
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
} catch (e) {
|
|
67
|
-
console.error('No "more" button found or error clicking it');
|
|
26
|
+
try {
|
|
27
|
+
const url = `https://www.youtube.com/watch?v=${videoID}`;
|
|
28
|
+
const outputTemplate = join(tempDir, '%(id)s');
|
|
29
|
+
|
|
30
|
+
// Try manual subs first, fall back to auto-generated
|
|
31
|
+
await execFileAsync('yt-dlp', [
|
|
32
|
+
'--write-subs',
|
|
33
|
+
'--write-auto-subs',
|
|
34
|
+
'--sub-lang', lang,
|
|
35
|
+
'--sub-format', 'json3',
|
|
36
|
+
'--skip-download',
|
|
37
|
+
'-o', outputTemplate,
|
|
38
|
+
url,
|
|
39
|
+
], { timeout: 30000 });
|
|
40
|
+
|
|
41
|
+
// Find the json3 subtitle file
|
|
42
|
+
const files = await readdir(tempDir);
|
|
43
|
+
const subFile = files.find(f => f.endsWith('.json3'));
|
|
44
|
+
|
|
45
|
+
if (!subFile) {
|
|
46
|
+
throw new Error(`No subtitles found for video ${videoID} in language '${lang}'`);
|
|
68
47
|
}
|
|
69
48
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// Multiple strategies to find the transcript button
|
|
74
|
-
const transcriptButtonSelectors = [
|
|
75
|
-
'button[aria-label="Show transcript"]',
|
|
76
|
-
'yt-button-shape button[aria-label="Show transcript"]',
|
|
77
|
-
'button[title*="transcript" i]',
|
|
78
|
-
'button[aria-label*="transcript" i]',
|
|
79
|
-
'yt-button-shape[aria-label*="transcript" i]',
|
|
80
|
-
'#button[aria-label*="transcript" i]',
|
|
81
|
-
'ytd-button-renderer[aria-label*="transcript" i]'
|
|
82
|
-
];
|
|
49
|
+
const raw = await readFile(join(tempDir, subFile), 'utf-8');
|
|
50
|
+
const json3 = JSON.parse(raw);
|
|
83
51
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
for (const selector of transcriptButtonSelectors) {
|
|
87
|
-
try {
|
|
88
|
-
await page.waitForSelector(selector, { timeout: 3000, visible: true });
|
|
89
|
-
await page.click(selector);
|
|
90
|
-
console.error(`Clicked transcript button with selector: ${selector}`);
|
|
91
|
-
transcriptClicked = true;
|
|
92
|
-
break;
|
|
93
|
-
} catch (e) {
|
|
94
|
-
// Try next selector
|
|
95
|
-
}
|
|
52
|
+
if (!json3.events || !Array.isArray(json3.events)) {
|
|
53
|
+
throw new Error('Invalid json3 subtitle format: missing events array');
|
|
96
54
|
}
|
|
97
55
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return true;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return false;
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
if (clicked) {
|
|
115
|
-
console.error('Clicked transcript button by text search');
|
|
116
|
-
transcriptClicked = true;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (!transcriptClicked) {
|
|
121
|
-
throw new Error('Could not find or click "Show transcript" button');
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Wait for the transcript panel to load
|
|
125
|
-
console.error('Waiting for transcript panel...');
|
|
126
|
-
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
127
|
-
|
|
128
|
-
// Wait for transcript segments
|
|
129
|
-
await page.waitForSelector('ytd-transcript-segment-renderer, ytd-transcript-body-renderer', {
|
|
130
|
-
timeout: 10000,
|
|
131
|
-
visible: true
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// Extract transcript data
|
|
135
|
-
console.error('Extracting transcript content...');
|
|
136
|
-
const transcriptData = await page.evaluate(() => {
|
|
137
|
-
// Multiple selectors for transcript segments
|
|
138
|
-
const segmentSelectors = [
|
|
139
|
-
'ytd-transcript-segment-renderer',
|
|
140
|
-
'ytd-transcript-body-renderer ytd-transcript-segment-renderer',
|
|
141
|
-
'ytd-engagement-panel-section-list-renderer ytd-transcript-segment-renderer',
|
|
142
|
-
'#segments-container ytd-transcript-segment-renderer',
|
|
143
|
-
'ytd-transcript-segment-list-renderer ytd-transcript-segment-renderer'
|
|
144
|
-
];
|
|
145
|
-
|
|
146
|
-
let segments = [];
|
|
147
|
-
for (const selector of segmentSelectors) {
|
|
148
|
-
segments = document.querySelectorAll(selector);
|
|
149
|
-
if (segments.length > 0) {
|
|
150
|
-
break;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (segments.length === 0) {
|
|
155
|
-
// Try a more general approach
|
|
156
|
-
segments = document.querySelectorAll('[class*="transcript"][class*="segment"]');
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (segments.length === 0) {
|
|
160
|
-
return [];
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Extract data from each segment
|
|
164
|
-
return Array.from(segments).map((segment) => {
|
|
165
|
-
// Extract timestamp - multiple strategies
|
|
166
|
-
let timestampText = '';
|
|
167
|
-
const timestampSelectors = [
|
|
168
|
-
'.segment-timestamp',
|
|
169
|
-
'[class*="timestamp"]',
|
|
170
|
-
'.ytd-transcript-segment-renderer:first-child',
|
|
171
|
-
'div:first-child'
|
|
172
|
-
];
|
|
173
|
-
|
|
174
|
-
for (const selector of timestampSelectors) {
|
|
175
|
-
const elem = segment.querySelector(selector);
|
|
176
|
-
if (elem && elem.textContent && /\d+:\d+/.test(elem.textContent)) {
|
|
177
|
-
timestampText = elem.textContent.trim();
|
|
178
|
-
break;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Extract text content - multiple strategies
|
|
183
|
-
let text = '';
|
|
184
|
-
const textSelectors = [
|
|
185
|
-
'.segment-text',
|
|
186
|
-
'yt-formatted-string.segment-text',
|
|
187
|
-
'[class*="segment-text"]',
|
|
188
|
-
'yt-formatted-string:last-child',
|
|
189
|
-
'.ytd-transcript-segment-renderer:last-child'
|
|
190
|
-
];
|
|
191
|
-
|
|
192
|
-
for (const selector of textSelectors) {
|
|
193
|
-
const elem = segment.querySelector(selector);
|
|
194
|
-
if (elem && elem.textContent) {
|
|
195
|
-
const content = elem.textContent.trim();
|
|
196
|
-
// Make sure it's not the timestamp
|
|
197
|
-
if (content && !(/^\d+:\d+$/.test(content))) {
|
|
198
|
-
text = content;
|
|
199
|
-
break;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// If still no text, get all text and remove timestamp
|
|
205
|
-
if (!text) {
|
|
206
|
-
const fullText = segment.textContent || '';
|
|
207
|
-
text = fullText.replace(timestampText, '').trim();
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Convert timestamp to seconds
|
|
211
|
-
let startSeconds = 0;
|
|
212
|
-
if (timestampText && timestampText.includes(':')) {
|
|
213
|
-
const parts = timestampText.split(':').reverse();
|
|
214
|
-
startSeconds = parts.reduce((acc, part, idx) => {
|
|
215
|
-
return acc + (parseInt(part) || 0) * Math.pow(60, idx);
|
|
216
|
-
}, 0);
|
|
217
|
-
}
|
|
218
|
-
|
|
56
|
+
// Filter to entries with segs (skip style/aAppend-only entries)
|
|
57
|
+
const segments = json3.events
|
|
58
|
+
.filter(ev => ev.segs && !ev.aAppend)
|
|
59
|
+
.map(ev => {
|
|
60
|
+
const text = ev.segs
|
|
61
|
+
.map(s => s.utf8 || '')
|
|
62
|
+
.join('')
|
|
63
|
+
.trim();
|
|
64
|
+
const startMs = ev.tStartMs || 0;
|
|
65
|
+
const durationMs = ev.dDurationMs || 0;
|
|
219
66
|
return {
|
|
220
|
-
start:
|
|
221
|
-
dur:
|
|
222
|
-
text
|
|
223
|
-
timestamp: timestampText
|
|
67
|
+
start: (startMs / 1000).toFixed(1),
|
|
68
|
+
dur: (durationMs / 1000).toFixed(1),
|
|
69
|
+
text,
|
|
224
70
|
};
|
|
225
|
-
})
|
|
226
|
-
|
|
71
|
+
})
|
|
72
|
+
.filter(seg => seg.text.length > 0);
|
|
227
73
|
|
|
228
|
-
if (
|
|
229
|
-
throw new Error('No transcript
|
|
74
|
+
if (segments.length === 0) {
|
|
75
|
+
throw new Error('No transcript segments extracted from subtitle file');
|
|
230
76
|
}
|
|
231
77
|
|
|
232
|
-
console.error(`Successfully extracted ${
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
// Calculate proper durations
|
|
236
|
-
const processedCaptions = transcriptData.map((item, index) => {
|
|
237
|
-
const nextItem = transcriptData[index + 1];
|
|
238
|
-
const duration = nextItem
|
|
239
|
-
? (parseFloat(nextItem.start) - parseFloat(item.start)).toFixed(1)
|
|
240
|
-
: "3.0";
|
|
241
|
-
|
|
242
|
-
return {
|
|
243
|
-
start: item.start,
|
|
244
|
-
dur: duration,
|
|
245
|
-
text: item.text
|
|
246
|
-
};
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
return processedCaptions;
|
|
78
|
+
console.error(`Successfully extracted ${segments.length} transcript segments via yt-dlp`);
|
|
79
|
+
return segments;
|
|
250
80
|
|
|
251
|
-
} catch (error) {
|
|
252
|
-
console.error('Error extracting subtitles:', error);
|
|
253
|
-
throw error;
|
|
254
81
|
} finally {
|
|
255
|
-
await
|
|
82
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
256
83
|
}
|
|
257
84
|
}
|
|
258
85
|
|
|
@@ -260,4 +87,4 @@ export async function getSubtitles({ videoID, lang = 'en' }) {
|
|
|
260
87
|
export { getChannelVideos, searchChannelVideos } from './channel.js';
|
|
261
88
|
export { getVideoComments } from './comments.js';
|
|
262
89
|
export { searchYouTubeGlobal } from './search.js';
|
|
263
|
-
export { getVideoMetadata } from './metadata.js';
|
|
90
|
+
export { getVideoMetadata } from './metadata.js';
|
package/src/metadata.js
CHANGED
|
@@ -16,7 +16,7 @@ export async function getVideoMetadata({ videoID, expandDescription = true }) {
|
|
|
16
16
|
// Navigate to the YouTube video page
|
|
17
17
|
console.error(`Navigating to https://youtube.com/watch?v=${videoID}`);
|
|
18
18
|
await page.goto(`https://youtube.com/watch?v=${videoID}`, {
|
|
19
|
-
waitUntil: '
|
|
19
|
+
waitUntil: 'networkidle',
|
|
20
20
|
timeout: 60000
|
|
21
21
|
});
|
|
22
22
|
|
package/src/search.js
CHANGED
package/src/utils/browser.js
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { chromium } from 'patchright';
|
|
2
|
+
import { mkdtempSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
2
5
|
|
|
3
6
|
export async function createBrowser() {
|
|
7
|
+
const userDataDir = mkdtempSync(join(tmpdir(), 'patchright-'));
|
|
8
|
+
|
|
4
9
|
const options = {
|
|
5
|
-
|
|
6
|
-
|
|
10
|
+
channel: 'chrome',
|
|
11
|
+
headless: false,
|
|
12
|
+
viewport: null,
|
|
13
|
+
args: ['--disable-dev-shm-usage'],
|
|
7
14
|
};
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
return await puppeteer.launch(options);
|
|
15
|
+
|
|
16
|
+
// launchPersistentContext returns a BrowserContext directly (not a Browser)
|
|
17
|
+
const context = await chromium.launchPersistentContext(userDataDir, options);
|
|
18
|
+
return context;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
export async function createPage(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
|
25
|
-
|
|
26
|
-
return page;
|
|
21
|
+
export async function createPage(browserContext) {
|
|
22
|
+
// launchPersistentContext already creates a default page
|
|
23
|
+
const pages = browserContext.pages();
|
|
24
|
+
if (pages.length > 0) {
|
|
25
|
+
return pages[0];
|
|
26
|
+
}
|
|
27
|
+
return await browserContext.newPage();
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export async function handleCookieConsent(page) {
|
|
@@ -50,4 +51,4 @@ export async function skipAds(page) {
|
|
|
50
51
|
} catch (e) {
|
|
51
52
|
// No skip button
|
|
52
53
|
}
|
|
53
|
-
}
|
|
54
|
+
}
|