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 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": "1.4.0",
4
- "description": "Extract YouTube video transcripts, channel videos, comments, and comprehensive video metadata using headless browser automation",
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
- "puppeteer": "^24.10.0",
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
- "puppeteer",
45
- "headless",
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: 'networkidle2',
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: 'networkidle2',
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: 'networkidle2',
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 headless browser automation
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 { createBrowser, createPage, handleCookieConsent, skipAds } from './utils/browser.js';
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
- // Export existing function
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
- const page = await createPage(browser);
9
-
10
- // Navigate to the YouTube video page
11
- console.error(`Navigating to https://youtube.com/watch?v=${videoID}`);
12
- await page.goto(`https://youtube.com/watch?v=${videoID}`, {
13
- waitUntil: 'networkidle2',
14
- timeout: 60000
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
- // Skip ads if present
28
- await skipAds(page);
20
+ // Export existing function
21
+ export async function getSubtitles({ videoID, lang = 'en' }) {
22
+ await ensureYtDlp();
29
23
 
30
- // Scroll down to load more content
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
- // Click on "more" to expand description if needed
35
- try {
36
- // Multiple selectors for the more button
37
- const moreSelectors = [
38
- 'tp-yt-paper-button#expand',
39
- 'tp-yt-paper-button[id="expand"]',
40
- '#expand',
41
- '#more',
42
- 'yt-formatted-string:has-text("...more")',
43
- '[aria-label*="more"]'
44
- ];
45
-
46
- for (const selector of moreSelectors) {
47
- try {
48
- const moreButton = await page.$(selector);
49
- if (moreButton) {
50
- const isVisible = await moreButton.evaluate(el => {
51
- const rect = el.getBoundingClientRect();
52
- return rect.width > 0 && rect.height > 0;
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
- // Look for and click the "Show transcript" button
71
- console.error('Looking for "Show transcript" button...');
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
- let transcriptClicked = false;
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
- if (!transcriptClicked) {
99
- // Try finding by text content
100
- console.error('Trying to find transcript button by text...');
101
- const clicked = await page.evaluate(() => {
102
- const buttons = Array.from(document.querySelectorAll('button, yt-button-shape'));
103
- for (const button of buttons) {
104
- const text = button.textContent || '';
105
- const ariaLabel = button.getAttribute('aria-label') || '';
106
- if (text.toLowerCase().includes('transcript') || ariaLabel.toLowerCase().includes('transcript')) {
107
- button.click();
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: startSeconds.toString(),
221
- dur: "3",
222
- text: text,
223
- timestamp: timestampText
67
+ start: (startMs / 1000).toFixed(1),
68
+ dur: (durationMs / 1000).toFixed(1),
69
+ text,
224
70
  };
225
- }).filter(item => item.text && item.text.trim() && item.text.length > 0);
226
- });
71
+ })
72
+ .filter(seg => seg.text.length > 0);
227
73
 
228
- if (!transcriptData || transcriptData.length === 0) {
229
- throw new Error('No transcript data extracted');
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 ${transcriptData.length} transcript segments`);
233
- console.error('First segment:', transcriptData[0]);
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 browser.close();
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: 'networkidle2',
19
+ waitUntil: 'networkidle',
20
20
  timeout: 60000
21
21
  });
22
22
 
package/src/search.js CHANGED
@@ -28,7 +28,7 @@ export async function searchYouTubeGlobal({ query, maxResults = 10, resultTypes
28
28
  console.error(`Navigating to ${searchUrl}`);
29
29
 
30
30
  await page.goto(searchUrl, {
31
- waitUntil: 'networkidle2',
31
+ waitUntil: 'networkidle',
32
32
  timeout: 60000
33
33
  });
34
34
 
@@ -1,29 +1,30 @@
1
- import puppeteer from 'puppeteer';
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
- headless: true,
6
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1920,1080', '--disable-dev-shm-usage']
10
+ channel: 'chrome',
11
+ headless: false,
12
+ viewport: null,
13
+ args: ['--disable-dev-shm-usage'],
7
14
  };
8
-
9
- // Add executablePath if environment variable is set
10
- if (process.env.PUPPETEER_EXECUTABLE_PATH) {
11
- options.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
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(browser) {
18
- const page = await browser.newPage();
19
-
20
- // Set viewport to a standard desktop size
21
- await page.setViewport({ width: 1920, height: 1080 });
22
-
23
- // Set a realistic user agent
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
+ }