From 2ad952ecd2b8172b54cb3ead15b72d1851fff566 Mon Sep 17 00:00:00 2001 From: Saloni Date: Tue, 28 Apr 2026 00:17:03 +0800 Subject: [PATCH 1/7] use browserless for netlify --- scripts/carousel/CarouselGenerator.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/carousel/CarouselGenerator.js b/scripts/carousel/CarouselGenerator.js index 50a0429..eac0b7c 100644 --- a/scripts/carousel/CarouselGenerator.js +++ b/scripts/carousel/CarouselGenerator.js @@ -18,8 +18,14 @@ class CarouselGenerator { await fs.ensureDir(this.outputDir); } - { - const { launch } = await import('puppeteer'); + const { connect, launch } = await import('puppeteer'); + const browserlessToken = process.env.BROWSERLESS_TOKEN; + + if (browserlessToken) { + const wsEndpoint = `wss://production-sfo.browserless.io?token=${browserlessToken}`; + console.log('Connecting to Browserless.io...'); + this.browser = await connect({ browserWSEndpoint: wsEndpoint }); + } else { this.browser = await launch({ headless: true, args: [ From f18cdb5589514af2b7dd7f09e430ad6ddcb16d55 Mon Sep 17 00:00:00 2001 From: Saloni Date: Tue, 28 Apr 2026 00:28:58 +0800 Subject: [PATCH 2/7] check --- app/api/auto-carousel/route.ts | 3 ++- scripts/carousel/CaptionExtractor.js | 39 ++++++++++++++-------------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/app/api/auto-carousel/route.ts b/app/api/auto-carousel/route.ts index 250a6c3..3c42767 100644 --- a/app/api/auto-carousel/route.ts +++ b/app/api/auto-carousel/route.ts @@ -96,7 +96,8 @@ STEP 2 — CLEAN & FORMAT EACH SEGMENT FOR SLIDES: - Clean up the transcript text for each line: • Fix grammar errors and typos (e.g. "jarens" → "jargons", "I I don't" → "I don't"). • Remove filler words (uh, um, hmm) ONLY if it doesn't change meaning. - • Break long sentences into concise clauses. Each line should be 5-20 words — punchy and readable on a phone screen. + • Break long sentences into concise clauses. Each line should be 5-20 words, punchy and readable on a phone screen. + • Do NOT use em dashes (—) in any text. Use a comma, period, or rephrase instead. • Remove pure filler ("Yeah.", "Right.", "Uh huh.") — skip those moments entirely. • Do NOT invent new content. Only clean up what's in the transcript. - topTimestamp and bottomTimestamp must be actual timestamps from the transcript (the [Xs] values). diff --git a/scripts/carousel/CaptionExtractor.js b/scripts/carousel/CaptionExtractor.js index ef62725..40dcd3b 100644 --- a/scripts/carousel/CaptionExtractor.js +++ b/scripts/carousel/CaptionExtractor.js @@ -70,30 +70,31 @@ class CaptionExtractor { const captionTracks = playerResponse?.captions?.playerCaptionsTracklistRenderer?.captionTracks; - if (!captionTracks || captionTracks.length === 0) { - throw new Error('No caption tracks found for this video'); - } + let captionData = null; - // Prefer auto-generated English - const rankedTracks = [...captionTracks].sort((a, b) => { - const score = (t) => { - let s = 0; - if (t.languageCode === 'en') s += 10; - if (t.kind === 'asr') s += 5; - return s; - }; - return score(b) - score(a); - }); + if (captionTracks && captionTracks.length > 0) { + // Prefer auto-generated English + const rankedTracks = [...captionTracks].sort((a, b) => { + const score = (t) => { + let s = 0; + if (t.languageCode === 'en') s += 10; + if (t.kind === 'asr') s += 5; + return s; + }; + return score(b) - score(a); + }); - let captionData = null; - for (const track of rankedTracks) { - console.log(` Trying track: ${track.languageCode} (${track.kind || 'manual'})`); - captionData = await this.fetchCaptionData(track.baseUrl, cookieStr, videoId, track); - if (captionData) break; + for (const track of rankedTracks) { + console.log(` Trying track: ${track.languageCode} (${track.kind || 'manual'})`); + captionData = await this.fetchCaptionData(track.baseUrl, cookieStr, videoId, track); + if (captionData) break; + } + } else { + console.log(' No caption tracks in page response (likely bot-detection) — trying InnerTube...'); } if (!captionData) { - console.log(' Page cookies failed, trying InnerTube ANDROID fallback...'); + console.log(' Trying InnerTube ANDROID/IOS fallback...'); captionData = await this.fetchViaInnertube(videoId); } From 9bfd8aa3bdeb610b1a89cc81f689de780a186f91 Mon Sep 17 00:00:00 2001 From: Saloni Date: Tue, 28 Apr 2026 00:40:41 +0800 Subject: [PATCH 3/7] try youtubei --- scripts/carousel/CaptionExtractor.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/scripts/carousel/CaptionExtractor.js b/scripts/carousel/CaptionExtractor.js index 40dcd3b..aad7df4 100644 --- a/scripts/carousel/CaptionExtractor.js +++ b/scripts/carousel/CaptionExtractor.js @@ -99,7 +99,10 @@ class CaptionExtractor { } if (!captionData) { - throw new Error('Could not fetch caption data'); + console.log(' Trying youtubei.js fallback...'); + const youtubeiSegments = await this.fetchViaYoutubei(videoId); + if (!youtubeiSegments) throw new Error('Could not fetch caption data from any source'); + return youtubeiSegments; } // Step 3: Parse all segments into a unified timed array @@ -226,6 +229,26 @@ class CaptionExtractor { return null; } + async fetchViaYoutubei(videoId) { + try { + const { Innertube } = await import('youtubei.js'); + const yt = await Innertube.create({ retrieve_player: false }); + const info = await yt.getInfo(videoId); + const transcriptData = await info.getTranscript(); + const segments = transcriptData?.transcript?.content?.body?.initial_segments; + if (!segments || segments.length === 0) return null; + + return segments.map(s => ({ + startSec: (s.start_ms ?? 0) / 1000, + endSec: (s.end_ms ?? s.start_ms ?? 0) / 1000, + text: s.snippet?.text ?? '', + })).filter(s => s.text); + } catch (e) { + console.log(` youtubei.js failed: ${e.message}`); + return null; + } + } + // Parse caption data (XML or JSON) into array of { startSec, endSec, text } parseAllSegments(captionData) { const trimmed = captionData.trimStart(); From 0b93939cae81df0ff9180b77eba8574437b0a1b8 Mon Sep 17 00:00:00 2001 From: Saloni Date: Tue, 28 Apr 2026 00:43:11 +0800 Subject: [PATCH 4/7] forgot package --- package-lock.json | 31 ++++++++++++++++++++++++++++++- package.json | 3 ++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9ba7483..52977aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,8 @@ "remotion": "^4.0.451", "sharp": "^0.33.0", "wavefile": "^11.0.0", - "youtube-transcript": "^1.3.0" + "youtube-transcript": "^1.3.0", + "youtubei.js": "^17.0.1" }, "devDependencies": { "@babel/core": "^7.29.0", @@ -1614,6 +1615,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", + "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -10457,6 +10464,15 @@ "node": ">= 8" } }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "license": "ISC", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/micromatch": { "version": "4.0.8", "dev": true, @@ -14029,6 +14045,19 @@ "node": ">=18.0.0" } }, + "node_modules/youtubei.js": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-17.0.1.tgz", + "integrity": "sha512-1lO4b8UqMDzE0oh2qEGzbBOd4UYRdxn/4PdpRM7BGTHxM6ddsEsKZTu90jp8V9FHVgC2h1UirQyqoqLiKwl+Zg==", + "funding": [ + "https://github.com/sponsors/LuanRT" + ], + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.0.0", + "meriyah": "^6.1.4" + } + }, "node_modules/zod": { "version": "4.3.6", "license": "MIT", diff --git a/package.json b/package.json index 9337208..67cc2c2 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,8 @@ "remotion": "^4.0.451", "sharp": "^0.33.0", "wavefile": "^11.0.0", - "youtube-transcript": "^1.3.0" + "youtube-transcript": "^1.3.0", + "youtubei.js": "^17.0.1" }, "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4", From 1dd4e203e7abf90ba31f7a74647676d374bc4c64 Mon Sep 17 00:00:00 2001 From: Saloni Date: Tue, 28 Apr 2026 00:48:14 +0800 Subject: [PATCH 5/7] fix --- scripts/carousel/CaptionExtractor.js | 39 +++++++++++++++++++++------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/scripts/carousel/CaptionExtractor.js b/scripts/carousel/CaptionExtractor.js index aad7df4..bf82728 100644 --- a/scripts/carousel/CaptionExtractor.js +++ b/scripts/carousel/CaptionExtractor.js @@ -232,17 +232,36 @@ class CaptionExtractor { async fetchViaYoutubei(videoId) { try { const { Innertube } = await import('youtubei.js'); - const yt = await Innertube.create({ retrieve_player: false }); + const yt = await Innertube.create(); const info = await yt.getInfo(videoId); - const transcriptData = await info.getTranscript(); - const segments = transcriptData?.transcript?.content?.body?.initial_segments; - if (!segments || segments.length === 0) return null; - - return segments.map(s => ({ - startSec: (s.start_ms ?? 0) / 1000, - endSec: (s.end_ms ?? s.start_ms ?? 0) / 1000, - text: s.snippet?.text ?? '', - })).filter(s => s.text); + + const captionTracks = info.captions?.caption_tracks; + if (!captionTracks || captionTracks.length === 0) { + console.log(' youtubei.js: no caption tracks in video info'); + return null; + } + + // Caption track URLs contain embedded auth tokens — fetchable from any IP + const track = + captionTracks.find(t => t.language_code === 'en' && t.is_autogenerated) || + captionTracks.find(t => t.language_code === 'en') || + captionTracks[0]; + + console.log(` youtubei.js: using track ${track.language_code} (${track.is_autogenerated ? 'auto' : 'manual'})`); + + for (const fmt of ['json3', 'srv3', '']) { + try { + const url = new URL(track.base_url); + if (fmt) url.searchParams.set('fmt', fmt); + const resp = await fetch(url.toString()); + if (!resp.ok) continue; + const text = await resp.text(); + if (!text || /^ Date: Tue, 28 Apr 2026 21:35:33 +0800 Subject: [PATCH 6/7] try supabase --- scripts/carousel/CaptionExtractor.js | 37 +++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/scripts/carousel/CaptionExtractor.js b/scripts/carousel/CaptionExtractor.js index bf82728..ca3a370 100644 --- a/scripts/carousel/CaptionExtractor.js +++ b/scripts/carousel/CaptionExtractor.js @@ -101,11 +101,17 @@ class CaptionExtractor { if (!captionData) { console.log(' Trying youtubei.js fallback...'); const youtubeiSegments = await this.fetchViaYoutubei(videoId); - if (!youtubeiSegments) throw new Error('Could not fetch caption data from any source'); - return youtubeiSegments; + if (youtubeiSegments) return youtubeiSegments; } - // Step 3: Parse all segments into a unified timed array + if (process.env.SUPADATA_API_KEY) { + console.log(' Trying Supadata API fallback...'); + const supadata = await this.fetchViaSupadata(videoId); + if (supadata) return supadata; + } + + if (!captionData) throw new Error('Could not fetch caption data from any source'); + return this.parseAllSegments(captionData); } @@ -186,6 +192,31 @@ class CaptionExtractor { return null; } + async fetchViaSupadata(videoId) { + try { + const resp = await fetch( + `https://api.supadata.ai/v1/youtube/transcript?videoId=${videoId}&lang=en`, + { headers: { 'x-api-key': process.env.SUPADATA_API_KEY } } + ); + if (!resp.ok) { + console.log(` Supadata failed: ${resp.status}`); + return null; + } + const data = await resp.json(); + const content = data?.content; + if (!content || content.length === 0) return null; + console.log(` Supadata succeeded with ${content.length} segments`); + return content.map(s => ({ + startSec: (s.offset ?? 0) / 1000, + endSec: ((s.offset ?? 0) + (s.duration ?? 0)) / 1000, + text: s.text ?? '', + })).filter(s => s.text); + } catch (e) { + console.log(` Supadata error: ${e.message}`); + return null; + } + } + async fetchViaInnertube(videoId) { const clients = [ { clientName: 'ANDROID', clientVersion: '19.09.37', apiKey: 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w', ua: 'com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip' }, From 23a5bfec8aa152293708cd42c8bbe625f1231166 Mon Sep 17 00:00:00 2001 From: Saloni Date: Tue, 28 Apr 2026 21:39:46 +0800 Subject: [PATCH 7/7] reove spaces --- scripts/carousel/CaptionExtractor.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/carousel/CaptionExtractor.js b/scripts/carousel/CaptionExtractor.js index ca3a370..6438ecc 100644 --- a/scripts/carousel/CaptionExtractor.js +++ b/scripts/carousel/CaptionExtractor.js @@ -199,13 +199,13 @@ class CaptionExtractor { { headers: { 'x-api-key': process.env.SUPADATA_API_KEY } } ); if (!resp.ok) { - console.log(` Supadata failed: ${resp.status}`); + console.log(`Supadata failed: ${resp.status}`); return null; } const data = await resp.json(); const content = data?.content; if (!content || content.length === 0) return null; - console.log(` Supadata succeeded with ${content.length} segments`); + console.log(`Supadata succeeded with ${content.length} segments`); return content.map(s => ({ startSec: (s.offset ?? 0) / 1000, endSec: ((s.offset ?? 0) + (s.duration ?? 0)) / 1000,