Skip to content

Commit 8b9cb00

Browse files
dipamsenshiffman
andauthored
Auto update YT Descriptions using the YouTube API (#1621)
* add yt-update script * rename var --------- Co-authored-by: Daniel Shiffman <[email protected]>
1 parent ea7c01d commit 8b9cb00

File tree

4 files changed

+4052
-2964
lines changed

4 files changed

+4052
-2964
lines changed

node-scripts/update-yt.mjs

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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();

node-scripts/yt-description.mjs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
// npm run yt-desc
55
// npm run yt-desc https://thecodingtrain.com/path/to/video/page
66
// npm run yt-desc https://youtube.com/watch?v=videoId
7-
// npm run yt-desc ./path/to/index.json
8-
// npm run yt-desc ./path/to/index.json -- -c # copy to clipboard
7+
// npm run yt-desc path/to/index.json # path starts with content/videos
8+
// npm run yt-desc path/to/index.json -- -c # copy to clipboard
99

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

@@ -63,7 +63,6 @@ class Video {
6363
/**
6464
* Searches for `index.json` files in a given directory and returns an array of parsed files.
6565
* @param {string} dir Name of directory to search for files
66-
* @param {?any[]} arrayOfFiles Array to store the parsed JSON files
6766
* @returns {any[]}
6867
*/
6968
function findContentFilesRecursive(dir) {
@@ -661,5 +660,22 @@ const allTracks = [...mainTracks, ...sideTracks];
661660
} else {
662661
videos.forEach(writeDescription);
663662
}
663+
const metadata = {
664+
videos: videos.map((v) => ({
665+
title: v.data.title,
666+
videoId: v.data.videoId,
667+
slug: v.slug,
668+
canonicalTrack: v.canonicalTrack,
669+
canonicalURL: v.canonicalURL
670+
})),
671+
tracks: allTracks.map((t) => ({
672+
slug: t.trackName,
673+
title: t.data.title
674+
}))
675+
};
676+
fs.writeFileSync(
677+
'./_descriptions/metadata.json',
678+
JSON.stringify(metadata, null, 2)
679+
);
664680
console.log('\n✅ Wrote descriptions to ./_descriptions/');
665681
})();

0 commit comments

Comments
 (0)