Skip to content

Commit

Permalink
Auto update YT Descriptions using the YouTube API (#1621)
Browse files Browse the repository at this point in the history
* add yt-update script

* rename var

---------

Co-authored-by: Daniel Shiffman <[email protected]>
  • Loading branch information
dipamsen and shiffman authored Dec 2, 2024
1 parent ea7c01d commit 8b9cb00
Show file tree
Hide file tree
Showing 4 changed files with 4,052 additions and 2,964 deletions.
245 changes: 245 additions & 0 deletions node-scripts/update-yt.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
// npm run update-yt
// Updates YouTube video descriptions according to description generated by from json data.

// ===========================================================
// HOW TO SETUP GOOGLE OAuth2 CREDENTIALS
// ===========================================================
// 1. Go to https://console.developers.google.com/
// 2. Click on the dropdown on the top left. Create a new project / select an existing project.
// 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.
// 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
// and fill in the required fields. (User type: 'External', App name, User support email, Developer contact information as required)
// 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
// which has write access to the Coding Train YouTube channel.)
// 5. Go to Credentials > Create Credentials > OAuth client ID > Application type: Desktop app > Create. Here, click on "DOWNLOAD JSON" to download the credentials file.
// Save this file as `google-credentials/client_secret.json` in this repo.
// ===========================================================
//
//
// ===========================================================
// RUNNING THE SCRIPT
// ===========================================================
// 1. Run `npm run update-yt`
// 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,
// 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.
// 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.
// This will store the auth token and a refresh token in `google-credentials/credentials.json`, which will be used for subsequent runs.
// 3. For updating the description of a video, it is required to first generate the descriptions using the `yt-desc` script.
// ===========================================================

import fs from 'fs';
import { createInterface } from 'readline';
import { google, youtube_v3 } from 'googleapis';
import inquirer from 'inquirer';

const SCOPES = ['https://www.googleapis.com/auth/youtube'];
const TOKEN_DIR = 'google-credentials/';
const TOKEN_PATH = TOKEN_DIR + 'credentials.json';
const CLIENT_PATH = TOKEN_DIR + 'client_secret.json';
const OAuth2 = google.auth.OAuth2;

/**
* Create an OAuth2 client with the given credentials.
*
* @param {Object} credentials The authorization client credentials.
*/
async function authorize(credentials) {
const clientSecret = credentials.installed.client_secret;
const clientId = credentials.installed.client_id;
const redirectUrl = credentials.installed.redirect_uris[0];
const oauth2Client = new OAuth2(clientId, clientSecret, redirectUrl);

// Check if we have previously stored a token.
try {
const token = await fs.promises.readFile(TOKEN_PATH);
oauth2Client.credentials = JSON.parse(token);
} catch (err) {
await getNewToken(oauth2Client);
}

return oauth2Client;
}

/**
* Get and store new token after prompting for user authorization.
*
* @param {google.auth.OAuth2} oauth2Client The OAuth2 client to get token for.
*/
async function getNewToken(oauth2Client) {
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES
});
console.log('Authorize this app by visiting this url: ', authUrl);
const rl = createInterface({
input: process.stdin,
output: process.stdout
});

return new Promise((res, rej) => {
rl.question('Enter the code from that page here: ', function (code) {
rl.close();
oauth2Client.getToken(code, function (err, token) {
if (err) {
console.log('Error while trying to retrieve access token', err);
return rej();
}
oauth2Client.credentials = token;
storeToken(token, res);
});
});
});
}

function storeToken(token, callback) {
try {
fs.mkdirSync(TOKEN_DIR);
} catch (err) {
if (err.code != 'EEXIST') {
throw err;
}
}
fs.writeFile(TOKEN_PATH, JSON.stringify(token), (err) => {
if (err) throw err;
console.log('Token stored to ' + TOKEN_PATH);
callback();
});
}

/**
* Updates the description of a YouTube video.
* @param {string} videoId youtube video id
* @param {string} newDescription new description to update
* @param {youtube_v3.Youtube} service youtube service
*/
async function updateYTDesc(videoId, newDescription, service) {
// YouTube Data API v3:
// videos.update
// ⚠️ Quota impact: A call to this method has a quota cost of 50 units.

try {
const res = await service.videos.list({
part: ['snippet'],
id: videoId
});
const video = res.data.items[0];

// diff old and new description
const oldDescription = video.snippet.description;
if (oldDescription === newDescription) {
console.log('Description is already up to date.');
return;
}

const res2 = await service.videos.update({
part: ['snippet'],
requestBody: {
id: videoId,
snippet: {
title: video.snippet.title,
description: newDescription,
categoryId: video.snippet.categoryId
}
}
});

console.log('Updated video description.');
} catch (err) {
console.error('The API returned an error: ' + err);
}
}

// Load client secrets from a local file.
async function main() {
let credentials;
try {
credentials = await fs.promises.readFile(CLIENT_PATH);
} catch (err) {
console.log('Error loading client secret file: ' + err);
return;
}
const auth = await authorize(JSON.parse(credentials));

const service = google.youtube({
version: 'v3',
auth
});

if (
!fs.existsSync('_descriptions') ||
fs.readdirSync('_descriptions').length === 0
) {
console.log(
'No generated descriptions available. Try generating them first by using the yt-desc script.'
);
return;
}

const videoIds = fs
.readdirSync('_descriptions')
.filter((f) => !f.endsWith('json'))
.map((f) => f.split('.')[0].split('_').slice(1).join('_'));
const metadata = JSON.parse(
fs.readFileSync('_descriptions/metadata.json', 'utf8')
);

const videos = metadata.videos.filter((x) => videoIds.includes(x.videoId));
const tracks = metadata.tracks
.map((track) => {
track.videos = videos.filter(
(video) => video.canonicalTrack === track.slug
);
return track;
})
.filter((track) => track.videos.length > 0);
const challengeVideos = videos.filter((video) =>
video.canonicalURL.startsWith('challenges')
);
if (challengeVideos.length > 0) {
tracks.push({
slug: 'challenges',
title: 'Coding Challenges',
videos: challengeVideos
});
}

const { trackSlug } = await inquirer.prompt([
{
type: 'list',
name: 'trackSlug',
message: 'Select a track to update:',
choices: tracks.map((track) => ({
name: track.title,
value: track.slug
}))
}
]);
const track = tracks.find((x) => x.slug === trackSlug);
const { videoId } = await inquirer.prompt([
{
type: 'list',
name: 'videoId',
message: 'Select a video to update:',
choices: track.videos.map((video) => ({
name: video.title + ' (' + video.videoId + ')',
value: video.videoId
}))
}
]);
const video = track.videos.find((video) => video.videoId === videoId);

console.log(
'Updating description for video...',
video.title,
`(${video.videoId})`
);

let newDescription = fs.readFileSync(
`_descriptions/${video.slug}_${video.videoId}.txt`,
'utf8'
);

updateYTDesc(video.videoId, newDescription, service);
}

main();
22 changes: 19 additions & 3 deletions node-scripts/yt-description.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
// npm run yt-desc
// npm run yt-desc https://thecodingtrain.com/path/to/video/page
// npm run yt-desc https://youtube.com/watch?v=videoId
// npm run yt-desc ./path/to/index.json
// npm run yt-desc ./path/to/index.json -- -c # copy to clipboard
// npm run yt-desc path/to/index.json # path starts with content/videos
// npm run yt-desc path/to/index.json -- -c # copy to clipboard

// Output files are saved to `./_descriptions` directory

Expand Down Expand Up @@ -63,7 +63,6 @@ class Video {
/**
* Searches for `index.json` files in a given directory and returns an array of parsed files.
* @param {string} dir Name of directory to search for files
* @param {?any[]} arrayOfFiles Array to store the parsed JSON files
* @returns {any[]}
*/
function findContentFilesRecursive(dir) {
Expand Down Expand Up @@ -661,5 +660,22 @@ const allTracks = [...mainTracks, ...sideTracks];
} else {
videos.forEach(writeDescription);
}
const metadata = {
videos: videos.map((v) => ({
title: v.data.title,
videoId: v.data.videoId,
slug: v.slug,
canonicalTrack: v.canonicalTrack,
canonicalURL: v.canonicalURL
})),
tracks: allTracks.map((t) => ({
slug: t.trackName,
title: t.data.title
}))
};
fs.writeFileSync(
'./_descriptions/metadata.json',
JSON.stringify(metadata, null, 2)
);
console.log('\n✅ Wrote descriptions to ./_descriptions/');
})();
Loading

0 comments on commit 8b9cb00

Please sign in to comment.