|
| 1 | +import http from "node:http"; |
| 2 | +import { URL } from "node:url"; |
| 3 | + |
| 4 | +const PORT = parseInt(process.env.PORT || "4005", 10); |
| 5 | +const AUTH_TOKEN = process.env.BRIDGE_AUTH_TOKEN || "e06fdac10c7c18aed27e47e3d18860121a0ca378288a3e04"; |
| 6 | + |
| 7 | +function decodeHtmlEntities(text) { |
| 8 | + return text |
| 9 | + .replace(/&/g, "&") |
| 10 | + .replace(/</g, "<") |
| 11 | + .replace(/>/g, ">") |
| 12 | + .replace(/"/g, '"') |
| 13 | + .replace(/'/g, "'") |
| 14 | + .replace(/'/g, "'") |
| 15 | + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => |
| 16 | + String.fromCodePoint(parseInt(hex, 16)), |
| 17 | + ) |
| 18 | + .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10))) |
| 19 | + .replace(/\s+/g, " ") |
| 20 | + .trim(); |
| 21 | +} |
| 22 | + |
| 23 | +async function fetchYouTubeCaptions(videoId) { |
| 24 | + const INNERTUBE_API_URL = "https://www.youtube.com/youtubei/v1/player?prettyPrint=false"; |
| 25 | + const INNERTUBE_CLIENT_VERSION = "20.10.38"; |
| 26 | + |
| 27 | + try { |
| 28 | + let visitorData; |
| 29 | + try { |
| 30 | + const pageResp = await fetch(`https://www.youtube.com/watch?v=${videoId}`, { |
| 31 | + headers: { |
| 32 | + "User-Agent": |
| 33 | + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", |
| 34 | + "Accept-Language": "en-US,en;q=0.9", |
| 35 | + }, |
| 36 | + }); |
| 37 | + if (pageResp.ok) { |
| 38 | + const html = await pageResp.text(); |
| 39 | + const match = html.match(/"visitorData":"([^"]+)"/); |
| 40 | + if (match) { |
| 41 | + visitorData = match[1]; |
| 42 | + } |
| 43 | + } |
| 44 | + } catch {} |
| 45 | + |
| 46 | + const requestHeaders = { |
| 47 | + "Content-Type": "application/json", |
| 48 | + "User-Agent": `com.google.android.youtube/${INNERTUBE_CLIENT_VERSION} (Linux; U; Android 14)`, |
| 49 | + }; |
| 50 | + if (visitorData) { |
| 51 | + requestHeaders["X-Goog-Visitor-Id"] = visitorData; |
| 52 | + } |
| 53 | + |
| 54 | + const response = await fetch(INNERTUBE_API_URL, { |
| 55 | + method: "POST", |
| 56 | + headers: requestHeaders, |
| 57 | + body: JSON.stringify({ |
| 58 | + context: { |
| 59 | + client: { |
| 60 | + clientName: "ANDROID", |
| 61 | + clientVersion: INNERTUBE_CLIENT_VERSION, |
| 62 | + ...(visitorData ? { visitorData } : {}), |
| 63 | + }, |
| 64 | + }, |
| 65 | + videoId, |
| 66 | + }), |
| 67 | + }); |
| 68 | + |
| 69 | + if (!response.ok) { |
| 70 | + return { cues: [], fullText: "", status: "no_caption_available", error: `innertube_http_${response.status}` }; |
| 71 | + } |
| 72 | + |
| 73 | + const data = await response.json(); |
| 74 | + const playability = data?.playabilityStatus?.status; |
| 75 | + const captionTracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks; |
| 76 | + |
| 77 | + if (!Array.isArray(captionTracks) || captionTracks.length === 0) { |
| 78 | + return { cues: [], fullText: "", status: "no_caption_available", error: `no_tracks_status_${playability}` }; |
| 79 | + } |
| 80 | + |
| 81 | + const track = |
| 82 | + captionTracks.find( |
| 83 | + (t) => |
| 84 | + t?.languageCode?.toLowerCase().startsWith("en") || |
| 85 | + t?.name?.runs?.[0]?.text?.toLowerCase().includes("english"), |
| 86 | + ) || captionTracks[0]; |
| 87 | + |
| 88 | + if (!track?.baseUrl) { |
| 89 | + return { cues: [], fullText: "", status: "no_caption_available", error: "missing_track_baseUrl" }; |
| 90 | + } |
| 91 | + |
| 92 | + const captionResp = await fetch(track.baseUrl); |
| 93 | + if (!captionResp.ok) { |
| 94 | + return { cues: [], fullText: "", status: "no_caption_available", error: `caption_fetch_http_${captionResp.status}` }; |
| 95 | + } |
| 96 | + |
| 97 | + const xml = await captionResp.text(); |
| 98 | + const cues = []; |
| 99 | + |
| 100 | + const pRegex = /<p\s+t="(\d+)"(?:\s+d="(\d+)")?[^>]*>([\s\S]*?)<\/p>/g; |
| 101 | + let match; |
| 102 | + |
| 103 | + while ((match = pRegex.exec(xml)) !== null) { |
| 104 | + const startMs = parseInt(match[1], 10); |
| 105 | + const durMs = match[2] ? parseInt(match[2], 10) : 3000; |
| 106 | + const inner = match[3]; |
| 107 | + |
| 108 | + let text = ""; |
| 109 | + const sRegex = /<s[^>]*>([^<]*)<\/s>/g; |
| 110 | + let sMatch; |
| 111 | + while ((sMatch = sRegex.exec(inner)) !== null) { |
| 112 | + text += sMatch[1]; |
| 113 | + } |
| 114 | + if (!text) { |
| 115 | + text = inner.replace(/<[^>]+>/g, ""); |
| 116 | + } |
| 117 | + |
| 118 | + text = decodeHtmlEntities(text); |
| 119 | + if (text && !text.startsWith("[")) { |
| 120 | + cues.push({ |
| 121 | + start: Math.round((startMs / 1000) * 100) / 100, |
| 122 | + duration: Math.round((durMs / 1000) * 100) / 100, |
| 123 | + text, |
| 124 | + }); |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + if (cues.length === 0) { |
| 129 | + const textRegex = /<text\s+start="([\d.]+)"(?:\s+dur="([\d.]+)")?[^>]*>([\s\S]*?)<\/text>/g; |
| 130 | + while ((match = textRegex.exec(xml)) !== null) { |
| 131 | + const start = parseFloat(match[1]); |
| 132 | + const dur = match[2] ? parseFloat(match[2]) : 3.0; |
| 133 | + const text = decodeHtmlEntities(match[3].replace(/<[^>]+>/g, "")); |
| 134 | + if (text && !text.startsWith("[")) { |
| 135 | + cues.push({ |
| 136 | + start: Math.round(start * 100) / 100, |
| 137 | + duration: Math.round(dur * 100) / 100, |
| 138 | + text, |
| 139 | + }); |
| 140 | + } |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + cues.sort((a, b) => a.start - b.start); |
| 145 | + const fullText = cues.map((c) => c.text).join(" "); |
| 146 | + |
| 147 | + return { |
| 148 | + cues, |
| 149 | + fullText, |
| 150 | + status: cues.length > 0 ? "completed" : "no_caption_available", |
| 151 | + }; |
| 152 | + } catch (err) { |
| 153 | + return { |
| 154 | + cues: [], |
| 155 | + fullText: "", |
| 156 | + status: "no_caption_available", |
| 157 | + error: err?.message || String(err), |
| 158 | + }; |
| 159 | + } |
| 160 | +} |
| 161 | + |
| 162 | +const server = http.createServer(async (req, res) => { |
| 163 | + const reqUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); |
| 164 | + |
| 165 | + if (reqUrl.pathname === "/health") { |
| 166 | + res.writeHead(200, { "Content-Type": "application/json" }); |
| 167 | + res.end(JSON.stringify({ status: "healthy", timestamp: new Date().toISOString() })); |
| 168 | + return; |
| 169 | + } |
| 170 | + |
| 171 | + if (reqUrl.pathname === "/transcript") { |
| 172 | + const auth = req.headers["authorization"] || req.headers["x-bridge-token"]; |
| 173 | + const token = typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : ""; |
| 174 | + |
| 175 | + if (AUTH_TOKEN && token !== AUTH_TOKEN) { |
| 176 | + res.writeHead(401, { "Content-Type": "application/json" }); |
| 177 | + res.end(JSON.stringify({ error: "Unauthorized" })); |
| 178 | + return; |
| 179 | + } |
| 180 | + |
| 181 | + const videoId = reqUrl.searchParams.get("videoId") || reqUrl.searchParams.get("v"); |
| 182 | + if (!videoId) { |
| 183 | + res.writeHead(400, { "Content-Type": "application/json" }); |
| 184 | + res.end(JSON.stringify({ error: "Missing videoId parameter" })); |
| 185 | + return; |
| 186 | + } |
| 187 | + |
| 188 | + const result = await fetchYouTubeCaptions(videoId); |
| 189 | + res.writeHead(200, { |
| 190 | + "Content-Type": "application/json", |
| 191 | + "Cache-Control": "public, max-age=86400", |
| 192 | + }); |
| 193 | + res.end(JSON.stringify(result)); |
| 194 | + return; |
| 195 | + } |
| 196 | + |
| 197 | + res.writeHead(404, { "Content-Type": "application/json" }); |
| 198 | + res.end(JSON.stringify({ error: "Not found" })); |
| 199 | +}); |
| 200 | + |
| 201 | +server.listen(PORT, "127.0.0.1", () => { |
| 202 | + console.log(`[Transcript Bridge] Listening on http://127.0.0.1:${PORT}`); |
| 203 | +}); |
0 commit comments