|
| 1 | +// npm run update-yt |
| 2 | +// Updates YouTube video descriptions according to description generated by from json data. |
| 3 | + |
| 4 | +// =========================================================== |
| 5 | +// HOW TO SETUP GOOGLE OAuth2 CREDENTIALS |
| 6 | +// =========================================================== |
| 7 | +// 1. Go to https://console.developers.google.com/ |
| 8 | +// 2. Click on the dropdown on the top left. Create a new project / select an existing project. |
| 9 | +// 3. Enable YouTube Data API v3 in this project by going to APIs & Services > Enable APIs and Services > Search for 'YouTube Data API v3' > Click Enable. |
| 10 | +// 4. Go to Credentials > Create Credentials > OAuth client ID. Here you might be asked to CONFIGURE CONSENT SCREEN. (If not, skip to step 5). Click on this button |
| 11 | +// and fill in the required fields. (User type: 'External', App name, User support email, Developer contact information as required) |
| 12 | +// Scopes: Add 'https://www.googleapis.com/auth/youtube'. On the summary page, scroll below to "Test Users" and add your email address. (This must be a Google account |
| 13 | +// which has write access to the Coding Train YouTube channel.) |
| 14 | +// 5. Go to Credentials > Create Credentials > OAuth client ID > Application type: Desktop app > Create. Here, click on "DOWNLOAD JSON" to download the credentials file. |
| 15 | +// Save this file as `google-credentials/client_secret.json` in this repo. |
| 16 | +// =========================================================== |
| 17 | +// |
| 18 | +// |
| 19 | +// =========================================================== |
| 20 | +// RUNNING THE SCRIPT |
| 21 | +// =========================================================== |
| 22 | +// 1. Run `npm run update-yt` |
| 23 | +// 2. If running the script for the first time, you will be asked to visit a URL to authenticate the app. Open this URL in your browser, |
| 24 | +// and login with the Google account which has write access to the Coding Train YouTube channel. You will be asked to grant permissions to the app. |
| 25 | +// After granting permissions, you will be redirected to a localhost page. Copy the `code` query param from the URL and paste it in the terminal. |
| 26 | +// This will store the auth token and a refresh token in `google-credentials/credentials.json`, which will be used for subsequent runs. |
| 27 | +// 3. For updating the description of a video, it is required to first generate the descriptions using the `yt-desc` script. |
| 28 | +// =========================================================== |
| 29 | + |
| 30 | +import fs from 'fs'; |
| 31 | +import { createInterface } from 'readline'; |
| 32 | +import { google, youtube_v3 } from 'googleapis'; |
| 33 | +import inquirer from 'inquirer'; |
| 34 | + |
| 35 | +const SCOPES = ['https://www.googleapis.com/auth/youtube']; |
| 36 | +const TOKEN_DIR = 'google-credentials/'; |
| 37 | +const TOKEN_PATH = TOKEN_DIR + 'credentials.json'; |
| 38 | +const CLIENT_PATH = TOKEN_DIR + 'client_secret.json'; |
| 39 | +const OAuth2 = google.auth.OAuth2; |
| 40 | + |
| 41 | +/** |
| 42 | + * Create an OAuth2 client with the given credentials. |
| 43 | + * |
| 44 | + * @param {Object} credentials The authorization client credentials. |
| 45 | + */ |
| 46 | +async function authorize(credentials) { |
| 47 | + const clientSecret = credentials.installed.client_secret; |
| 48 | + const clientId = credentials.installed.client_id; |
| 49 | + const redirectUrl = credentials.installed.redirect_uris[0]; |
| 50 | + const oauth2Client = new OAuth2(clientId, clientSecret, redirectUrl); |
| 51 | + |
| 52 | + // Check if we have previously stored a token. |
| 53 | + try { |
| 54 | + const token = await fs.promises.readFile(TOKEN_PATH); |
| 55 | + oauth2Client.credentials = JSON.parse(token); |
| 56 | + } catch (err) { |
| 57 | + await getNewToken(oauth2Client); |
| 58 | + } |
| 59 | + |
| 60 | + return oauth2Client; |
| 61 | +} |
| 62 | + |
| 63 | +/** |
| 64 | + * Get and store new token after prompting for user authorization. |
| 65 | + * |
| 66 | + * @param {google.auth.OAuth2} oauth2Client The OAuth2 client to get token for. |
| 67 | + */ |
| 68 | +async function getNewToken(oauth2Client) { |
| 69 | + const authUrl = oauth2Client.generateAuthUrl({ |
| 70 | + access_type: 'offline', |
| 71 | + scope: SCOPES |
| 72 | + }); |
| 73 | + console.log('Authorize this app by visiting this url: ', authUrl); |
| 74 | + const rl = createInterface({ |
| 75 | + input: process.stdin, |
| 76 | + output: process.stdout |
| 77 | + }); |
| 78 | + |
| 79 | + return new Promise((res, rej) => { |
| 80 | + rl.question('Enter the code from that page here: ', function (code) { |
| 81 | + rl.close(); |
| 82 | + oauth2Client.getToken(code, function (err, token) { |
| 83 | + if (err) { |
| 84 | + console.log('Error while trying to retrieve access token', err); |
| 85 | + return rej(); |
| 86 | + } |
| 87 | + oauth2Client.credentials = token; |
| 88 | + storeToken(token, res); |
| 89 | + }); |
| 90 | + }); |
| 91 | + }); |
| 92 | +} |
| 93 | + |
| 94 | +function storeToken(token, callback) { |
| 95 | + try { |
| 96 | + fs.mkdirSync(TOKEN_DIR); |
| 97 | + } catch (err) { |
| 98 | + if (err.code != 'EEXIST') { |
| 99 | + throw err; |
| 100 | + } |
| 101 | + } |
| 102 | + fs.writeFile(TOKEN_PATH, JSON.stringify(token), (err) => { |
| 103 | + if (err) throw err; |
| 104 | + console.log('Token stored to ' + TOKEN_PATH); |
| 105 | + callback(); |
| 106 | + }); |
| 107 | +} |
| 108 | + |
| 109 | +/** |
| 110 | + * Updates the description of a YouTube video. |
| 111 | + * @param {string} videoId youtube video id |
| 112 | + * @param {string} newDescription new description to update |
| 113 | + * @param {youtube_v3.Youtube} service youtube service |
| 114 | + */ |
| 115 | +async function updateYTDesc(videoId, newDescription, service) { |
| 116 | + // YouTube Data API v3: |
| 117 | + // videos.update |
| 118 | + // ⚠️ Quota impact: A call to this method has a quota cost of 50 units. |
| 119 | + |
| 120 | + try { |
| 121 | + const res = await service.videos.list({ |
| 122 | + part: ['snippet'], |
| 123 | + id: videoId |
| 124 | + }); |
| 125 | + const video = res.data.items[0]; |
| 126 | + |
| 127 | + // diff old and new description |
| 128 | + const oldDescription = video.snippet.description; |
| 129 | + if (oldDescription === newDescription) { |
| 130 | + console.log('Description is already up to date.'); |
| 131 | + return; |
| 132 | + } |
| 133 | + |
| 134 | + const res2 = await service.videos.update({ |
| 135 | + part: ['snippet'], |
| 136 | + requestBody: { |
| 137 | + id: videoId, |
| 138 | + snippet: { |
| 139 | + title: video.snippet.title, |
| 140 | + description: newDescription, |
| 141 | + categoryId: video.snippet.categoryId |
| 142 | + } |
| 143 | + } |
| 144 | + }); |
| 145 | + |
| 146 | + console.log('Updated video description.'); |
| 147 | + } catch (err) { |
| 148 | + console.error('The API returned an error: ' + err); |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +// Load client secrets from a local file. |
| 153 | +async function main() { |
| 154 | + let credentials; |
| 155 | + try { |
| 156 | + credentials = await fs.promises.readFile(CLIENT_PATH); |
| 157 | + } catch (err) { |
| 158 | + console.log('Error loading client secret file: ' + err); |
| 159 | + return; |
| 160 | + } |
| 161 | + const auth = await authorize(JSON.parse(credentials)); |
| 162 | + |
| 163 | + const service = google.youtube({ |
| 164 | + version: 'v3', |
| 165 | + auth |
| 166 | + }); |
| 167 | + |
| 168 | + if ( |
| 169 | + !fs.existsSync('_descriptions') || |
| 170 | + fs.readdirSync('_descriptions').length === 0 |
| 171 | + ) { |
| 172 | + console.log( |
| 173 | + 'No generated descriptions available. Try generating them first by using the yt-desc script.' |
| 174 | + ); |
| 175 | + return; |
| 176 | + } |
| 177 | + |
| 178 | + const videoIds = fs |
| 179 | + .readdirSync('_descriptions') |
| 180 | + .filter((f) => !f.endsWith('json')) |
| 181 | + .map((f) => f.split('.')[0].split('_').slice(1).join('_')); |
| 182 | + const metadata = JSON.parse( |
| 183 | + fs.readFileSync('_descriptions/metadata.json', 'utf8') |
| 184 | + ); |
| 185 | + |
| 186 | + const videos = metadata.videos.filter((x) => videoIds.includes(x.videoId)); |
| 187 | + const tracks = metadata.tracks |
| 188 | + .map((track) => { |
| 189 | + track.videos = videos.filter( |
| 190 | + (video) => video.canonicalTrack === track.slug |
| 191 | + ); |
| 192 | + return track; |
| 193 | + }) |
| 194 | + .filter((track) => track.videos.length > 0); |
| 195 | + const challengeVideos = videos.filter((video) => |
| 196 | + video.canonicalURL.startsWith('challenges') |
| 197 | + ); |
| 198 | + if (challengeVideos.length > 0) { |
| 199 | + tracks.push({ |
| 200 | + slug: 'challenges', |
| 201 | + title: 'Coding Challenges', |
| 202 | + videos: challengeVideos |
| 203 | + }); |
| 204 | + } |
| 205 | + |
| 206 | + const { trackSlug } = await inquirer.prompt([ |
| 207 | + { |
| 208 | + type: 'list', |
| 209 | + name: 'trackSlug', |
| 210 | + message: 'Select a track to update:', |
| 211 | + choices: tracks.map((track) => ({ |
| 212 | + name: track.title, |
| 213 | + value: track.slug |
| 214 | + })) |
| 215 | + } |
| 216 | + ]); |
| 217 | + const track = tracks.find((x) => x.slug === trackSlug); |
| 218 | + const { videoId } = await inquirer.prompt([ |
| 219 | + { |
| 220 | + type: 'list', |
| 221 | + name: 'videoId', |
| 222 | + message: 'Select a video to update:', |
| 223 | + choices: track.videos.map((video) => ({ |
| 224 | + name: video.title + ' (' + video.videoId + ')', |
| 225 | + value: video.videoId |
| 226 | + })) |
| 227 | + } |
| 228 | + ]); |
| 229 | + const video = track.videos.find((video) => video.videoId === videoId); |
| 230 | + |
| 231 | + console.log( |
| 232 | + 'Updating description for video...', |
| 233 | + video.title, |
| 234 | + `(${video.videoId})` |
| 235 | + ); |
| 236 | + |
| 237 | + let newDescription = fs.readFileSync( |
| 238 | + `_descriptions/${video.slug}_${video.videoId}.txt`, |
| 239 | + 'utf8' |
| 240 | + ); |
| 241 | + |
| 242 | + updateYTDesc(video.videoId, newDescription, service); |
| 243 | +} |
| 244 | + |
| 245 | +main(); |
0 commit comments