Compare commits

...

1 Commits

Author SHA1 Message Date
Mattéo Delabre c79d106eb6
Support new YouTube HTML output 2021-01-04 20:55:57 +01:00
2 changed files with 41 additions and 57 deletions

View File

@ -1,23 +1,26 @@
import debug from 'debug'; import debug from 'debug';
import util from 'util'; import util from 'util';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import fs from 'fs';
import { WATCH_BASE } from './util.mjs'; import { WATCH_BASE } from './util.mjs';
import { retryable, exclusive } from './retry.mjs'; import { retryable, exclusive } from './retry.mjs';
import { TemporaryError } from './retry.mjs'; import { TemporaryError } from './retry.mjs';
const log = debug('youtube-maze:api'); const log = debug('youtube-maze:api');
const PLAYER_RESP_REGEX = /window\["ytInitialPlayerResponse"\] = (\{.*?\});/; const METADATA_REGEXES = [
const PLAYER_CONFIG_REGEX = /ytplayer\.config = (\{.*?\});/; /window\["ytInitialPlayerResponse"\] = (\{.*?\});/,
/var ytInitialPlayerResponse = (\{.*?\});/,
];
/** /**
* Fetch the `ytplayer.config` object for a YouTube video. * Fetch metadata about a YouTube video.
* *
* @async * @async
* @param String videoId Identifier of the video to fetch. * @param String videoId Identifier of the video to fetch.
* @return Object The player configuration object. * @return Object The video metadata object.
*/ */
const bareGetPlayerConfig = async (videoId) => const bareGetMetadata = async (videoId) =>
{ {
const url = util.format(WATCH_BASE, encodeURIComponent(videoId)); const url = util.format(WATCH_BASE, encodeURIComponent(videoId));
log(`Fetching ${videoId} (${url})`); log(`Fetching ${videoId} (${url})`);
@ -33,36 +36,24 @@ const bareGetPlayerConfig = async (videoId) =>
} }
const body = await res.text(); const body = await res.text();
const searchResults = METADATA_REGEXES.map(regex => body.match(regex));
const searchResult = searchResults.find(item => item !== null);
// Look for the initial player response object to check whether the if (searchResult === null)
// video was found
const responseSearch = body.match(PLAYER_RESP_REGEX);
if (responseSearch === null)
{ {
throw new TemporaryError(`Invalid YouTube response for video ${videoId}`); throw new TemporaryError(`Incomplete YouTube response for video ${videoId}`);
} }
const response = JSON.parse(responseSearch[1]); const metadata = JSON.parse(searchResult[1]);
if (response.playabilityStatus.status !== 'OK') if (metadata.playabilityStatus.status !== 'OK')
{ {
throw new Error(`Video ${videoId} is not available; \ throw new Error(`Video ${videoId} is not available; \
status: ${response.playabilityStatus.status}; \ status: ${metadata.playabilityStatus.status}; \
reason: "${response.playabilityStatus.reason}"`); reason: "${metadata.playabilityStatus.reason}"`);
} }
// Look for the definition of ytplayer.config and unserialize it const actualId = metadata.videoDetails.videoId;
const configSearch = body.match(PLAYER_CONFIG_REGEX);
if (configSearch === null)
{
throw new TemporaryError(`Unable to find player for video ${videoId}`);
}
const config = JSON.parse(configSearch[1]);
config.args.player_response = JSON.parse(config.args.player_response);
const actualId = config.args.player_response.videoDetails.videoId;
if (videoId !== actualId) if (videoId !== actualId)
{ {
@ -70,40 +61,36 @@ reason: "${response.playabilityStatus.reason}"`);
} }
log(`Done fetching ${videoId}`); log(`Done fetching ${videoId}`);
return config; return metadata;
}; };
export const getPlayerConfig = exclusive(retryable(bareGetPlayerConfig)); export const getMetadata = exclusive(retryable(bareGetMetadata));
/** /**
* Get metadata about a YouTube video. * Get video ID and title.
* *
* @param config The `ytplayer.config` object corresponding to the source * @param Object metadata The video metadata object from `getMetadata`.
* YouTube video, as obtained from `getPlayerConfig`. * @return Object Containing the video ID and title.
* @return Object containing the video metadata.
*/ */
export const getVideoMeta = config => ({ export const getVideoInfo = metadata => ({
videoId: config.args.player_response.videoDetails.videoId, videoId: metadata.videoDetails.videoId,
title: config.args.player_response.videoDetails.title, title: metadata.videoDetails.title,
}); });
/** /**
* Find videos linked from the endscreen of a YouTube video. * Find videos linked from the endscreen of a YouTube video.
* *
* @param config The `ytplayer.config` object corresponding to the source * @param Object metadata The video metadata object from `getMetadata`.
* YouTube video, as obtained from `getPlayerConfig`.
* @return List of identifiers of linked videos. * @return List of identifiers of linked videos.
*/ */
export const getEndScreenVideos = config => export const getEndScreenVideos = metadata =>
{ {
const response = config.args.player_response; if (!('endscreen' in metadata))
if (!('endscreen' in response))
{ {
return []; return [];
} }
return response.endscreen.endscreenRenderer.elements return metadata.endscreen.endscreenRenderer.elements
.map(elt => elt.endscreenElementRenderer) .map(elt => elt.endscreenElementRenderer)
.filter(rdr => 'watchEndpoint' in rdr.endpoint) .filter(rdr => 'watchEndpoint' in rdr.endpoint)
.map(rdr => rdr.endpoint.watchEndpoint.videoId); .map(rdr => rdr.endpoint.watchEndpoint.videoId);
@ -112,20 +99,17 @@ export const getEndScreenVideos = config =>
/** /**
* Find videos linked from as cards from a YouTube video. * Find videos linked from as cards from a YouTube video.
* *
* @param config The `ytplayer.config` object corresponding to the source * @param Object metadata The video metadata object from `getMetadata`.
* YouTube video, as obtained from `getPlayerConfig`.
* @return List of identifiers of linked videos. * @return List of identifiers of linked videos.
*/ */
export const getCardVideos = config => export const getCardVideos = metadata =>
{ {
const response = config.args.player_response; if (!('cards' in metadata))
if (!('cards' in response))
{ {
return []; return [];
} }
return response.cards.cardCollectionRenderer.cards return metadata.cards.cardCollectionRenderer.cards
.map(card => card.cardRenderer.content) .map(card => card.cardRenderer.content)
.filter(content => 'videoInfoCardContentRenderer' in content) .filter(content => 'videoInfoCardContentRenderer' in content)
.map(content => content.videoInfoCardContentRenderer) .map(content => content.videoInfoCardContentRenderer)

View File

@ -31,19 +31,19 @@ export const exploreVideos = async (videoId, onUpdate) =>
if (!(currentId in videosNodes)) if (!(currentId in videosNodes))
{ {
const config = await api.getPlayerConfig(currentId); const metadata = await api.getMetadata(currentId);
const meta = api.getVideoMeta(config); const info = api.getVideoInfo(metadata);
videosNodes[currentId] = meta; videosNodes[currentId] = info;
nextVideos[currentId] = new Set(); nextVideos[currentId] = new Set();
// Add links between this video and the linked ones // Add links between this video and the linked ones
api.getEndScreenVideos(config) api.getEndScreenVideos(metadata)
.forEach(nextId => nextVideos[meta.videoId].add(nextId)); .forEach(nextId => nextVideos[info.videoId].add(nextId));
api.getCardVideos(config) api.getCardVideos(metadata)
.forEach(nextId => nextVideos[meta.videoId].add(nextId)); .forEach(nextId => nextVideos[info.videoId].add(nextId));
for (let nextId of nextVideos[meta.videoId]) for (let nextId of nextVideos[info.videoId])
{ {
queue.push(nextId); queue.push(nextId);
} }