File size: 4,921 Bytes
972ea65 fa2f8d4 972ea65 fa2f8d4 972ea65 fa2f8d4 972ea65 fa2f8d4 972ea65 fa2f8d4 972ea65 fa2f8d4 972ea65 fa2f8d4 972ea65 fa2f8d4 972ea65 fa2f8d4 972ea65 fa2f8d4 972ea65 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | import http from "node:http";
import { URL } from "node:url";
import { fetchTranscript, listLanguages } from "youtube-transcript-plus";
const PORT = Number(process.env.TRANSCRIPT_PORT || 8765);
const HOST = process.env.TRANSCRIPT_HOST || "127.0.0.1";
const ALLOW_ORIGIN = process.env.ALLOW_ORIGIN || "*";
const USER_AGENT = process.env.YT_USER_AGENT || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36";
function json(res, status, payload) {
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": ALLOW_ORIGIN,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "no-store",
});
res.end(JSON.stringify(payload));
}
function extractVideoId(value) {
const s = String(value || "").trim();
if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s;
try {
const u = new URL(s);
if (u.hostname === "youtu.be") {
const id = u.pathname.split("/").filter(Boolean)[0];
return /^[A-Za-z0-9_-]{11}$/.test(id || "") ? id : null;
}
const v = u.searchParams.get("v");
if (v && /^[A-Za-z0-9_-]{11}$/.test(v)) return v;
const parts = u.pathname.split("/").filter(Boolean);
if (parts.length >= 2 && ["embed", "shorts", "live"].includes(parts[0])) {
return /^[A-Za-z0-9_-]{11}$/.test(parts[1]) ? parts[1] : null;
}
} catch {}
return null;
}
function normalize(items) {
return (Array.isArray(items) ? items : []).map((x, i) => ({
index: i + 1,
start: Number(x.offset ?? x.start ?? x.startTime ?? 0),
duration: Number(x.duration ?? 0),
text: String(x.text ?? x.content ?? "").replace(/\s+/g, " ").trim(),
})).filter(x => x.text);
}
async function fetchWithFallback(video, lang) {
const base = {
...(lang ? { lang } : {}),
userAgent: USER_AGENT,
retries: 2,
retryDelay: 700,
};
let firstError = null;
try {
return await fetchTranscript(video, base);
} catch (error) {
firstError = error;
}
// Some hosted environments have TLS/HTTPS problems reaching YouTube.
// The package explicitly supports an HTTP fallback; use it only after HTTPS fails.
try {
return await fetchTranscript(video, { ...base, disableHttps: true });
} catch (secondError) {
const e = new Error(secondError?.message || String(secondError));
e.firstError = firstError;
throw e;
}
}
const server = http.createServer(async (req, res) => {
if (req.method === "OPTIONS") return json(res, 204, {});
const u = new URL(req.url, `http://${req.headers.host || "localhost"}`);
if (u.pathname === "/health") return json(res, 200, { ok: true, service: "english-lab-transcript-backend" });
if (u.pathname === "/languages") {
const video = extractVideoId(u.searchParams.get("video") || u.searchParams.get("url"));
if (!video) return json(res, 400, { ok: false, error: "invalid_youtube_video_id" });
try {
const languages = await listLanguages(video, { userAgent: USER_AGENT, retries: 2, retryDelay: 700 });
return json(res, 200, { ok: true, videoId: video, languages });
} catch (error) {
return json(res, 502, { ok: false, videoId: video, error: "languages_fetch_failed", message: String(error?.message || error) });
}
}
if (u.pathname !== "/transcript") return json(res, 404, { ok: false, error: "not_found" });
const video = extractVideoId(u.searchParams.get("video") || u.searchParams.get("url"));
const requestedLang = u.searchParams.get("lang") || "en";
if (!video) return json(res, 400, { ok: false, error: "invalid_youtube_video_id" });
try {
let raw;
let usedLang = requestedLang;
try {
raw = await fetchWithFallback(video, requestedLang === "auto" ? undefined : requestedLang);
} catch (primaryError) {
if (requestedLang !== "auto") {
// If the requested language does not exist, retry without forcing a language.
raw = await fetchWithFallback(video, undefined);
usedLang = "auto";
} else {
throw primaryError;
}
}
const segments = normalize(raw);
return json(res, 200, {
ok: true,
videoId: video,
language: usedLang,
count: segments.length,
segments,
});
} catch (error) {
return json(res, 502, {
ok: false,
videoId: video,
error: "transcript_fetch_failed",
message: String(error?.message || error),
firstError: error?.firstError ? String(error.firstError?.message || error.firstError) : undefined,
hint: "YouTube may be blocking the Space outbound request, the video may have no captions, or the requested language may be unavailable. Try /languages or import SRT/VTT manually.",
});
}
});
server.listen(PORT, HOST, () => {
console.log(`English Lab transcript backend listening on http://${HOST}:${PORT}`);
});
|