diff --git a/app.mjs b/app.mjs new file mode 100644 index 0000000..e57a2e5 --- /dev/null +++ b/app.mjs @@ -0,0 +1,18 @@ +import express from 'express'; +import path from 'path'; +import cookieParser from 'cookie-parser'; +import logger from 'morgan'; +import { fileURLToPath } from 'url'; + +const thisdir = path.dirname(fileURLToPath(import.meta.url)); +const app = express(); +export default app; + +app.use(logger('dev')); +app.use(express.json()); +app.use(express.urlencoded({ extended: false })); +app.use(cookieParser()); +app.use(express.static(path.join(thisdir, 'public'))); + +import mazes from './routes/mazes.mjs'; +app.use('/mazes', mazes); diff --git a/bin/www.mjs b/bin/www.mjs new file mode 100755 index 0000000..e9fb415 --- /dev/null +++ b/bin/www.mjs @@ -0,0 +1,69 @@ +import http from 'http'; +import debug from 'debug'; + +const log = debug('youtube-maze:server'); + +/** + * Normalize a port into a number, string, or false. + */ +const normalizePort = val => +{ + const port = parseInt(val, 10); + + // Named pipe + if (isNaN(port)) + { + return val; + } + + // Port number + if (port >= 0) + { + return port; + } + + return false; +}; + +// Instantiate app +import app from '../app.mjs'; +const port = normalizePort(process.env.PORT || '3000'); +app.set('port', port); + +// Create HTTP server +const server = http.createServer(app); +server.listen(port); +server.keepAliveTimeout = 60000; + +server.on('error', error => { + if (error.syscall !== 'listen') + { + throw error; + } + + const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}`; + + // Handle specific listen errors with friendly messages + switch (error.code) + { + case 'EACCES': + console.error(`${bind} requires elevated privileges`); + process.exit(1); + + case 'EADDRINUSE': + console.error(`${bind} is already in use`); + process.exit(1); + + default: + throw error; + } +}); + +server.on('listening', () => { + const addr = server.address(); + const bind = typeof addr === 'string' + ? `pipe ${addr}` + : `port ${addr.port}`; + + log(`Listening on ${bind}`); +}); diff --git a/explore/graph.mjs b/explore/graph.mjs deleted file mode 100644 index c0dd115..0000000 --- a/explore/graph.mjs +++ /dev/null @@ -1,44 +0,0 @@ -'use strict'; - -import util from 'util'; - -const YOUTUBE_WATCH = 'https://youtu.be/%s'; -const GRAPH_NODE = ' "%s" [label="%s", URL="%s", fontcolor=blue]'; -const GRAPH_LINK = ' "%s" -> "%s"'; - -/** - * Escape double quotes in a string. - */ -const escapeQuotes = str => str.replace(/"/g, '\\"'); - -/** - * Convert a graph of videos to the DOT format. - * - * @param nodes Nodes of the graph. - * @param next For each node, list of next neighbors. - * @param [title=identity] Function giving the name of each node. - * @param [url=none] Function given the URL of each node, or an empty string - * if a node has no URL. - * @return DOT representation of the graph. - */ -export const graphToDOT = (nodes, next) => -{ - // Convert nodes - const nodesStr = Object.entries(nodes).map(([id, {title}]) => - { - const url = util.format(YOUTUBE_WATCH, id); - return util.format(GRAPH_NODE, id, escapeQuotes(title), url); - }).join('\n'); - - // Convert edges - const nextStr = Object.entries(next).map(([id, neighbors]) => - Array.from(neighbors) - .map(neighbor => util.format(GRAPH_LINK, id, neighbor)) - .join('\n') - ).join('\n'); - - return `digraph youtube { - ${nodesStr} - ${nextStr} - }`; -}; diff --git a/explore/index.mjs b/explore/index.mjs deleted file mode 100644 index f3e5958..0000000 --- a/explore/index.mjs +++ /dev/null @@ -1,63 +0,0 @@ -import fs from 'fs'; -import util from 'util'; - -import * as api from './api.mjs'; -import {graphToDOT} from './graph.mjs'; - -// Fetch the output path from command line -if (process.argv.length !== 4) -{ - console.error(`Usage: ${process.argv[1]} ROOT DEST -Explore videos linked from ROOT and write the resulting graph to DEST.`); - process.exit(1); -} - -const root = process.argv[2]; -const dest = process.argv[3]; - -// Store metadata about each visited video -const videosNodes = Object.create(null); - -// List of videos linked from each video either through a card or an -// endscreen item -const nextVideos = Object.create(null); - -/** - * Recursively explore a video and the video linked from it to fill - * the video graph. - * - * @param videoId Source video identifier. - */ -const exploreVideo = async videoId => -{ - // Make sure we don’t explore the same video twice - if (videoId in videosNodes) - { - return Promise.resolve(); - } - - videosNodes[videoId] = {}; - - const playerConfig = await api.getPlayerConfig(videoId); - videosNodes[videoId] = api.getVideoMeta(playerConfig); - nextVideos[videoId] = new Set(); - - // Add links between this video and the linked ones - api.getEndScreenVideos(playerConfig) - .forEach(nextId => nextVideos[videoId].add(nextId)); - api.getCardVideos(playerConfig) - .forEach(nextId => nextVideos[videoId].add(nextId)); - - // Recurse on linked videos - return Promise.all( - Array.from(nextVideos[videoId]) - .map(id => exploreVideo(id)) - ); -}; - -console.log('Starting to explore!'); -exploreVideo(root).then(() => -{ - fs.writeFileSync(dest, graphToDOT(videosNodes, nextVideos)); - console.log(`Finished. Result in ${dest}`); -}).catch(console.error); diff --git a/explore/api.mjs b/lib/api.mjs similarity index 50% rename from explore/api.mjs rename to lib/api.mjs index ffa6532..c9d0723 100644 --- a/explore/api.mjs +++ b/lib/api.mjs @@ -1,77 +1,90 @@ +import debug from 'debug'; import util from 'util'; import fetch from 'node-fetch'; -const YOUTUBE_BASE = 'https://www.youtube.com/%s'; -const WATCH_BASE = util.format(YOUTUBE_BASE, 'watch?v=%s'); -const PLAYER_CONFIG_REGEX = /ytplayer\.config = (\{.*?\});/; +import { WATCH_BASE } from './util.mjs'; +import { TemporaryError } from './retry.mjs'; -/** - * Make a promise that is resolved in a point in the future. - * - * @param Number time Time to wait before resolving the promise (ms). - * @return null - */ -const sleep = time => new Promise(resolve => setTimeout(resolve, time)); +const log = debug('youtube-maze:api'); +const PLAYER_RESP_REGEX = /window\["ytInitialPlayerResponse"\] = (\{.*?\});/; +const PLAYER_CONFIG_REGEX = /ytplayer\.config = (\{.*?\});/; /** * Fetch the `ytplayer.config` object for a YouTube video. * * @async * @param String videoId Identifier of the video to fetch. - * @param Number retries Allowed number of retries after fetch errors. - * @param Number cooldown Time to wait before retrying (ms). * @return Object The player configuration object. */ -export const getPlayerConfig = async (videoId, retries = 3, cooldown = 1000) => +export const getPlayerConfig = async (videoId) => { - const url = util.format(WATCH_BASE, videoId); + const url = util.format(WATCH_BASE, encodeURIComponent(videoId)); + log(`Fetching ${videoId} (${url})`); + const res = await fetch(url); + debugger; const body = await res.text(); + // Look for the initial player response object to check whether the + // video was found + const responseSearch = body.match(PLAYER_RESP_REGEX); + + if (responseSearch === null) + { + throw new TemporaryError(`Invalid YouTube response for video ${videoId}`); + } + + const response = JSON.parse(responseSearch[1]); + + if (response.playabilityStatus.status !== 'OK') + { + throw new Error(`Video ${videoId} is not available; \ +status: ${response.playabilityStatus.status}; \ +reason: "${response.playabilityStatus.reason}"`); + } + // Look for the definition of ytplayer.config and unserialize it const configSearch = body.match(PLAYER_CONFIG_REGEX); if (configSearch === null) { - if (retries > 0) - { - console.info(`Error while fetching ${videoId}, retrying in ${cooldown} ms`); - await sleep(cooldown); - return getPlayerConfig(videoId, retries - 1, cooldown * 2); - } - else - { - throw new Error('Unable to find player configuration'); - } + 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) + { + throw new Error(`Video ${videoId} has actual id ${actualId}`); + } + return config; }; /** * Get metadata about a YouTube video. * - * @param playerConfig The `ytplayer.config` object corresponding to the source + * @param config The `ytplayer.config` object corresponding to the source * YouTube video, as obtained from `getPlayerConfig`. * @return Object containing the video metadata. */ -export const getVideoMeta = playerConfig => ({ - videoId: playerConfig.args.player_response.videoDetails.videoId, - title: playerConfig.args.player_response.videoDetails.title, +export const getVideoMeta = config => ({ + videoId: config.args.player_response.videoDetails.videoId, + title: config.args.player_response.videoDetails.title, }); /** * Find videos linked from the endscreen of a YouTube video. * - * @param playerConfig The `ytplayer.config` object corresponding to the source + * @param config The `ytplayer.config` object corresponding to the source * YouTube video, as obtained from `getPlayerConfig`. * @return List of identifiers of linked videos. */ -export const getEndScreenVideos = playerConfig => +export const getEndScreenVideos = config => { - const response = playerConfig.args.player_response; + const response = config.args.player_response; if (!('endscreen' in response)) { @@ -87,13 +100,13 @@ export const getEndScreenVideos = playerConfig => /** * Find videos linked from as cards from a YouTube video. * - * @param playerConfig The `ytplayer.config` object corresponding to the source + * @param config The `ytplayer.config` object corresponding to the source * YouTube video, as obtained from `getPlayerConfig`. * @return List of identifiers of linked videos. */ -export const getCardVideos = playerConfig => +export const getCardVideos = config => { - const response = playerConfig.args.player_response; + const response = config.args.player_response; if (!('cards' in response)) { diff --git a/lib/explore.mjs b/lib/explore.mjs new file mode 100644 index 0000000..a473d54 --- /dev/null +++ b/lib/explore.mjs @@ -0,0 +1,87 @@ +import util from 'util'; +import * as api from './api.mjs'; +import { WATCH_BASE, PromiseQueue, escapeQuotes } from './util.mjs'; +import { retryable } from '../lib/retry.mjs'; + +const GRAPH_NODE = ' "%s" [label="%s", URL="%s", fontcolor=blue]'; +const GRAPH_LINK = ' "%s" -> "%s"'; + +const retryPlayerConfig = retryable(api.getPlayerConfig); + +/** + * Explore the video graph starting from the given root. + * + * @async + * @param string videoId Source video identifier. + * @return Array. Nodes and set of neighbors of each node. + */ +export const exploreVideos = async videoId => +{ + // Store metadata about each visited video + const videosNodes = Object.create(null); + videosNodes[videoId] = {}; + + // List of videos linked from each video either through a card or an + // endscreen item + const nextVideos = Object.create(null); + nextVideos[videoId] = new Set(); + + // Pending video requests + const queue = new PromiseQueue(); + queue.add(retryPlayerConfig(videoId)); + + while (!queue.empty()) + { + const config = await queue.next(); + const meta = api.getVideoMeta(config); + + videosNodes[meta.videoId] = meta; + + // Add links between this video and the linked ones + api.getEndScreenVideos(config) + .forEach(nextId => nextVideos[meta.videoId].add(nextId)); + api.getCardVideos(config) + .forEach(nextId => nextVideos[meta.videoId].add(nextId)); + + for (let nextId of nextVideos[meta.videoId]) + { + if (!(nextId in videosNodes)) + { + videosNodes[nextId] = {}; + nextVideos[nextId] = new Set(); + queue.add(retryPlayerConfig(nextId)); + } + } + } + + return [videosNodes, nextVideos]; +}; + +/** + * Convert a video graph to the DOT format. + * + * @param Object videosNodes Videos of the graph. + * @param Object nextVideos For each video, list of next videos. + * @return string DOT representation of the graph. + */ +export const graphToDOT = (videosNodes, nextVideos) => +{ + // Convert videosNodes + const nodesStr = Object.entries(videosNodes).map(([id, {title}]) => + { + const url = util.format(WATCH_BASE, id); + return util.format(GRAPH_NODE, id, escapeQuotes(title), url); + }).join('\n'); + + // Convert edges + const nextStr = Object.entries(nextVideos).map(([id, neighbors]) => + Array.from(neighbors) + .map(neighbor => util.format(GRAPH_LINK, id, neighbor)) + .join('\n') + ).join('\n'); + + return `digraph maze { +${nodesStr} + +${nextStr}}`; +}; diff --git a/lib/retry.mjs b/lib/retry.mjs new file mode 100644 index 0000000..3ff4d3e --- /dev/null +++ b/lib/retry.mjs @@ -0,0 +1,64 @@ +import debug from 'debug'; +import { sleep } from './util.mjs'; + +const log = debug('youtube-maze:retry'); + +/** + * An error that is expected to be temporary such that the initial action + * may be retried. + */ +export class TemporaryError extends Error +{ + constructor(message) + { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +/** + * Make an async function retry-able. + * + * When the underlying function throws a TemporaryError, the initial + * call will be repeated, under the limits set below. + * + * @param function func Async function to call. + * @param number retries Allowed number of retries before failing. + * @param number cooldown Time to wait before retrying (ms). + * @return function New function that is retryable. + */ +export const retryable = (func, retries = 3, cooldown = 1000) => +{ + return async (...args) => + { + while (true) + { + let remRetries = retries; + let curCooldown = cooldown; + + try + { + const result = await func(...args); + return result; + } + catch (err) + { + if (err instanceof TemporaryError && remRetries > 0) + { + log(`\ +${func.name}(${args}) failed with error "${err.message}" +Retrying in ${curCooldown} ms (${remRetries} retries remaining)`); + + await sleep(curCooldown); + remRetries -= 1; + curCooldown *= 2; + } + else + { + throw err; + } + } + } + }; +}; diff --git a/lib/util.mjs b/lib/util.mjs new file mode 100644 index 0000000..49d9ec4 --- /dev/null +++ b/lib/util.mjs @@ -0,0 +1,63 @@ +import util from 'util'; + +export const YOUTUBE_BASE = 'https://www.youtube.com/%s'; +export const WATCH_BASE = util.format(YOUTUBE_BASE, 'watch?v=%s'); + +/** + * Hold a queue of promises from which results can be extracted. + */ +export class PromiseQueue +{ + constructor() + { + this.pending = new Set(); + } + + /** + * Add a new promise to the queue. + * + * @param Promise promise Promise to be added. + */ + add(promise) + { + const wrapped = promise.then(res => { + this.pending.delete(wrapped); + return res; + }); + + this.pending.add(wrapped); + } + + /** + * Check whether there is no promise pending in the queue. + * + * @return boolean + */ + empty() + { + return this.pending.size === 0; + } + + /** + * Extract the next available result from the promise queue. + * + * @return any Next result. + */ + next() + { + return Promise.race(this.pending); + } +} + +/** + * Escape double quotes in a string. + */ +export const escapeQuotes = str => str.replace(/"/g, '\\"'); + +/** + * Make a promise that is resolved in a point in the future. + * + * @param number time Time to wait before resolving the promise (ms). + * @return null + */ +export const sleep = time => new Promise(resolve => setTimeout(resolve, time)); diff --git a/package-lock.json b/package-lock.json index 7731cd3..88ccb56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,404 @@ { - "name": "epenser-maze", - "version": "0.2.0", + "name": "server", + "version": "0.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "requires": { + "safe-buffer": "5.1.2" + } + }, + "body-parser": { + "version": "1.18.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.3.tgz", + "integrity": "sha1-WykhmP/dVTs6DyDe0FkrlWlVyLQ=", + "requires": { + "bytes": "3.0.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "~1.6.3", + "iconv-lite": "0.4.23", + "on-finished": "~2.3.0", + "qs": "6.5.2", + "raw-body": "2.3.3", + "type-is": "~1.6.16" + } + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=" + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-parser": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.5.tgz", + "integrity": "sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==", + "requires": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6" + } + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "express": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/express/-/express-4.16.4.tgz", + "integrity": "sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg==", + "requires": { + "accepts": "~1.3.5", + "array-flatten": "1.1.1", + "body-parser": "1.18.3", + "content-disposition": "0.5.2", + "content-type": "~1.0.4", + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.1.1", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.4", + "qs": "6.5.2", + "range-parser": "~1.2.0", + "safe-buffer": "5.1.2", + "send": "0.16.2", + "serve-static": "1.13.2", + "setprototypeof": "1.1.0", + "statuses": "~1.4.0", + "type-is": "~1.6.16", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" + } + } + }, + "finalhandler": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "statuses": "~1.4.0", + "unpipe": "~1.0.0" + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "iconv-lite": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", + "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" + }, + "mime-db": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", + "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" + }, + "mime-types": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", + "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "requires": { + "mime-db": "1.44.0" + } + }, + "morgan": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.1.tgz", + "integrity": "sha512-HQStPIV4y3afTiCYVxirakhlCfGkI161c76kKFca7Fk1JusM//Qeo1ej2XaMniiNeaZklMVrh3vTtIzpzwbpmA==", + "requires": { + "basic-auth": "~2.0.0", + "debug": "2.6.9", + "depd": "~1.1.2", + "on-finished": "~2.3.0", + "on-headers": "~1.0.1" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, "node-fetch": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "proxy-addr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", + "integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.9.1" + } + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.3.tgz", + "integrity": "sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw==", + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.3", + "iconv-lite": "0.4.23", + "unpipe": "1.0.0" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "send": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", + "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "~2.3.0", + "range-parser": "~1.2.0", + "statuses": "~1.4.0" + } + }, + "serve-static": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.2", + "send": "0.16.2" + } + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" } } } diff --git a/package.json b/package.json index dcd9aec..4c2a763 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { "name": "youtube-maze", - "private": true, "version": "0.3.0", - "main": "src/index.js", - "license": "CC0", + "private": true, "scripts": { - "epenser": "mkdir -p build && node explore/index.mjs EZGra6O8ClQ build/epenser.dot && dot -Tsvg build/epenser.dot -o build/epenser.svg", - "defakator": "mkdir -p build && node explore/index.mjs XM1ssJ8yxdg build/defakator.dot && dot -Tsvg build/defakator.dot -o build/defakator.svg" + "start": "node ./bin/www.mjs" }, "dependencies": { + "cookie-parser": "~1.4.4", + "debug": "~2.6.9", + "express": "~4.16.1", + "morgan": "~1.9.1", "node-fetch": "^2.6.1" } } diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..f34978f --- /dev/null +++ b/public/index.html @@ -0,0 +1,14 @@ + + + + + YouTube Maze + + + +

YouTube Maze

+ +

e-penser

+

Defakator

+ + diff --git a/src/index.html b/public/index2.html similarity index 99% rename from src/index.html rename to public/index2.html index 0b80a22..e206e9d 100644 --- a/src/index.html +++ b/public/index2.html @@ -3,7 +3,7 @@ Exploration du labyrinthe du 1er avril d’e-penser - +

Exploration du labyrinthe du 1er avril d’e-penser

diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..9453385 --- /dev/null +++ b/public/style.css @@ -0,0 +1,8 @@ +body { + padding: 50px; + font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; +} + +a { + color: #00B7FF; +} diff --git a/src/style.css b/public/style2.css similarity index 100% rename from src/style.css rename to public/style2.css diff --git a/routes/mazes.mjs b/routes/mazes.mjs new file mode 100644 index 0000000..ffb2413 --- /dev/null +++ b/routes/mazes.mjs @@ -0,0 +1,22 @@ +import express from 'express'; +import { exploreVideos, graphToDOT } from '../lib/explore.mjs'; +import { retryable } from '../lib/retry.mjs'; + +const router = express.Router(); +export default router; + +const retryExploreVideos = retryable(exploreVideos); + +router.get('/:videoId', async (req, res) => { + try + { + const graph = await retryExploreVideos(req.params.videoId); + const dot = graphToDOT(...graph); + res.send(dot); + } + catch (err) + { + console.error(err); + res.status(500).send('Error'); + } +});