Allow returning page if transcript extraction failed

777b2518bdb0153f194db79fb651c558e196fc3d

Cohee <18619528+Cohee1207@users.noreply.github.com>

1 files changed, +75 -57Ignore whitespace
src/endpoints/search.js+75 -57
@@ -22,6 +22,72 @@ const visitHeaders = {
2222 'Sec-Fetch-User': '?1',
2323};
2424
25+/**
26+ * Extract the transcript of a YouTube video
27+ * @param {string} videoPageBody HTML of the video page
28+ * @param {string} lang Language code
29+ * @returns {Promise<string>} Transcript text
30+ */
31+async function extractTranscript(videoPageBody, lang) {
32+ const he = require('he');
33+ const RE_XML_TRANSCRIPT = /<text start="([^"]*)" dur="([^"]*)">([^<]*)<\/text>/g;
34+ const splittedHTML = videoPageBody.split('"captions":');
35+
36+ if (splittedHTML.length <= 1) {
37+ if (videoPageBody.includes('class="g-recaptcha"')) {
38+ throw new Error('Too many requests');
39+ }
40+ if (!videoPageBody.includes('"playabilityStatus":')) {
41+ throw new Error('Video is not available');
42+ }
43+ throw new Error('Transcript not available');
44+ }
45+
46+ const captions = (() => {
47+ try {
48+ return JSON.parse(splittedHTML[1].split(',"videoDetails')[0].replace('\n', ''));
49+ } catch (e) {
50+ return undefined;
51+ }
52+ })()?.['playerCaptionsTracklistRenderer'];
53+
54+ if (!captions) {
55+ throw new Error('Transcript disabled');
56+ }
57+
58+ if (!('captionTracks' in captions)) {
59+ throw new Error('Transcript not available');
60+ }
61+
62+ if (lang && !captions.captionTracks.some(track => track.languageCode === lang)) {
63+ throw new Error('Transcript not available in this language');
64+ }
65+
66+ const transcriptURL = (lang ? captions.captionTracks.find(track => track.languageCode === lang) : captions.captionTracks[0]).baseUrl;
67+ const transcriptResponse = await fetch(transcriptURL, {
68+ headers: {
69+ ...(lang && { 'Accept-Language': lang }),
70+ 'User-Agent': visitHeaders['User-Agent'],
71+ },
72+ });
73+
74+ if (!transcriptResponse.ok) {
75+ throw new Error('Transcript request failed');
76+ }
77+
78+ const transcriptBody = await transcriptResponse.text();
79+ const results = [...transcriptBody.matchAll(RE_XML_TRANSCRIPT)];
80+ const transcript = results.map((result) => ({
81+ text: result[3],
82+ duration: parseFloat(result[2]),
83+ offset: parseFloat(result[1]),
84+ lang: lang ?? captions.captionTracks[0].languageCode,
85+ }));
86+ // The text is double-encoded
87+ const transcriptText = transcript.map((line) => he.decode(he.decode(line.text))).join(' ');
88+ return transcriptText;
89+}
90+
2591router.post('/serpapi', jsonParser, async (request, response) => {
2692 try {
2793 const key = readSecret(request.user.directories, SECRET_KEYS.SERPAPI);
@@ -56,8 +122,6 @@ router.post('/serpapi', jsonParser, async (request, response) => {
56122 */
57123router.post('/transcript', jsonParser, async (request, response) => {
58124 try {
59- const he = require('he');
60- const RE_XML_TRANSCRIPT = /<text start="([^"]*)" dur="([^"]*)">([^<]*)<\/text>/g;
61125 const id = request.body.id;
62126 const lang = request.body.lang;
63127 const json = request.body.json;
@@ -75,64 +139,18 @@ router.post('/transcript', jsonParser, async (request, response) => {
75139 });
76140
77141 const videoPageBody = await videoPageResponse.text();
78- const splittedHTML = videoPageBody.split('"captions":');
79142
80- if (splittedHTML.length <= 1) {
143+ try {
81- if (videoPageBody.includes('class="g-recaptcha"')) {
144+ const transcriptText = await extractTranscript(videoPageBody, lang);
82- throw new Error('Too many requests');
145+ return json
83- }
146+ ? response.json({ transcript: transcriptText, html: videoPageBody })
84- if (!videoPageBody.includes('"playabilityStatus":')) {
147+ : response.send(transcriptText);
85- throw new Error('Video is not available');
148+ } catch (error) {
86- }
149+ if (json) {
87- throw new Error('Transcript not available');
150+ return response.json({ html: videoPageBody, transcript: '' });
88- }
89-
90- const captions = (() => {
91- try {
92- return JSON.parse(splittedHTML[1].split(',"videoDetails')[0].replace('\n', ''));
93- } catch (e) {
94- return undefined;
95151 }
96- })()?.['playerCaptionsTracklistRenderer'];
152+ throw error;
97-
98- if (!captions) {
99- throw new Error('Transcript disabled');
100- }
101-
102- if (!('captionTracks' in captions)) {
103- throw new Error('Transcript not available');
104- }
105-
106- if (lang && !captions.captionTracks.some(track => track.languageCode === lang)) {
107- throw new Error('Transcript not available in this language');
108153 }
109-
110- const transcriptURL = (lang ? captions.captionTracks.find(track => track.languageCode === lang) : captions.captionTracks[0]).baseUrl;
111- const transcriptResponse = await fetch(transcriptURL, {
112- headers: {
113- ...(lang && { 'Accept-Language': lang }),
114- 'User-Agent': visitHeaders['User-Agent'],
115- },
116- });
117-
118- if (!transcriptResponse.ok) {
119- throw new Error('Transcript request failed');
120- }
121-
122- const transcriptBody = await transcriptResponse.text();
123- const results = [...transcriptBody.matchAll(RE_XML_TRANSCRIPT)];
124- const transcript = results.map((result) => ({
125- text: result[3],
126- duration: parseFloat(result[2]),
127- offset: parseFloat(result[1]),
128- lang: lang ?? captions.captionTracks[0].languageCode,
129- }));
130- // The text is double-encoded
131- const transcriptText = transcript.map((line) => he.decode(he.decode(line.text))).join(' ');
132-
133- return json
134- ? response.json({ transcript: transcriptText, html: videoPageBody })
135- : response.send(transcriptText);
136154 } catch (error) {
137155 console.log(error);
138156 return response.sendStatus(500);