-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathyoutube_video_stats.js
303 lines (261 loc) · 10.5 KB
/
youtube_video_stats.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
let maxYoutubeResults = 50; // Maximum number of videos to fetch in one API call
let maxAirtableWrites = 50; // Maximum number of records to update in one batch
let settings = input.config({
title: "YouTube URLs to Airtable Data",
description: `This magic script takes YouTube video URLs and queries the YouTube Data API for video metadata (e.g. number of likes & views), then stores that data in the specified fields.
You will need a [YouTube Data API v3 key](https://developers.google.com/youtube/v3/getting-started). If needed, see [YouTube's documentation](https://developers.google.com/youtube/v3/docs/videos#resource-representation).
- 📡 = data you'll send to the YouTube API.
- 💾 = what you'll get back in return.
---
`,
items: [
// Sending field
input.config.text("youtubeKey", {
label: "📡 Your YouTube Data API v3 key",
description: "Warning: the API key will be visible to everyone who can view this base.",
}),
input.config.table("table", { label: "📡 Which table are your videos in?" }),
input.config.field("videoField", {
parentTable: "table",
label: "📡 Which field has your YouTube video URLs?",
}),
// Receiving fields
input.config.field("viewCountField", {
parentTable: "table",
label: "💾 View count",
}),
input.config.field("likeCountField", {
parentTable: "table",
label: "💾 Like count",
}),
input.config.field("commentCountField", {
parentTable: "table",
label: "💾 Comment count",
}),
input.config.field("publishedAtField", {
parentTable: "table",
label: "💾 Publish date",
}),
input.config.field("channelIdField", {
parentTable: "table",
label: "💾 Channel ID (link to Channel table)",
}),
input.config.field("titleField", {
parentTable: "table",
label: "💾 Video title",
}),
input.config.field("descriptionField", {
parentTable: "table",
label: "💾 Video description",
}),
input.config.field("thumbnailField", {
parentTable: "table",
label: "💾 Video thumbnail URL",
}),
input.config.field("defaultAudioLanguageField", {
parentTable: "table",
label: "💾 Video's default language",
}),
input.config.table("channelTable", { label: "Channel table for relational linking" }),
input.config.field("channelField", {
parentTable: "channelTable",
label: "💾 Channel ID field in Channel table",
}),
],
});
let description = `
# Capture YouTube Analytics
For each record in a given table which contains a link to a video on YouTube.com, fetch some metadata describing the video and store the information in specified fields.
- [YouTube Data API Overview](https://developers.google.com/youtube/v3/getting-started) - for details on configuring a YouTube account and retrieving an API key
- [YouTube Video Resource Representation](https://developers.google.com/youtube/v3/docs/videos#resource-representation) - for details on the available data, including the valid options for metadata fields
`;
function parseId(url) {
let host, searchParams;
if (!url) {
return null;
}
try {
({ host, searchParams } = new URL(url));
} catch (e) {
output.text(`Error parsing URL "${url}": ${e}`);
return null;
}
if (!/(^|.)youtube.com$/i.test(host)) {
return null;
}
return searchParams.get("v") || null;
}
async function fetchVideoData(key, items) {
let ids = items.map((item) => item.videoId);
let urlString =
"https://www.googleapis.com/youtube/v3/videos" +
`?key=${key}&id=${ids.join(",")}&part=status,statistics,contentDetails,snippet`;
output.text(`Fetching from URL: ${urlString}`);
let response = await fetch(urlString);
if (!response.ok) {
let errorText = await response.text();
output.text(`Error fetching data: ${errorText}`);
throw new Error(errorText);
}
return (await response.json()).items.map((item, index) => ({
...items[index],
privacyStatus: item.status.privacyStatus,
viewCount: Number(item.statistics.viewCount),
likeCount: Number(item.statistics.likeCount),
commentCount: Number(item.statistics.commentCount),
publishedAt: item.snippet.publishedAt,
channelId: item.snippet.channelId,
title: item.snippet.title,
description: item.snippet.description,
thumbnail: item.snippet.thumbnails.maxres?.url || item.snippet.thumbnails.standard?.url || item.snippet.thumbnails.high?.url || item.snippet.thumbnails.medium?.url || item.snippet.thumbnails.default?.url,
defaultAudioLanguage: item.snippet.defaultAudioLanguage,
}));
}
async function findOrCreateChannelRecord(channelTable, channelField, channelId) {
let existingRecords = await channelTable.selectRecordsAsync({ fields: [channelField.id] });
let existingRecord = existingRecords.records.find(record => record.getCellValueAsString(channelField.id) === channelId);
if (existingRecord) {
return existingRecord.id;
} else {
let createRecord = await channelTable.createRecordAsync({
[channelField.id]: channelId
});
return createRecord;
}
}
output.markdown(description);
let { youtubeKey, table, videoField, viewCountField, likeCountField, commentCountField, publishedAtField, channelIdField, titleField, descriptionField, thumbnailField, defaultAudioLanguageField, channelTable, channelField } = settings;
output.text("Configuration loaded successfully.");
let skipAlreadySet = await input.buttonsAsync(
"Skip attachment entries that already have files?",
[
{ label: "Yes", value: true },
{ label: "No", value: false },
]
);
let query;
try {
query = await table.selectRecordsAsync({ fields: [videoField.id, thumbnailField.id] });
output.text("Query executed successfully.");
} catch (e) {
output.text(`Error executing query: ${e}`);
}
let bareItems;
try {
bareItems = query.records
.map((record) => {
let url = record.getCellValueAsString(videoField.id);
let videoId = parseId(url);
if (!videoId) {
output.text(`Invalid URL: ${url}`);
}
let existingAttachments = record.getCellValue(thumbnailField) || [];
if (skipAlreadySet && existingAttachments.length > 0) {
return null;
}
return {
record: record,
videoId: videoId
};
})
.filter((item) => item && item.videoId);
output.text(`Total number of records: ${query.records.length}`);
output.text(`Number of records with valid URLs: ${bareItems.length}`);
} catch (e) {
output.text(`Error processing records: ${e}`);
}
let annotatedItems = [];
while (bareItems.length) {
let workingSet = bareItems.splice(0, maxYoutubeResults);
output.text(`Fetching metadata for ${workingSet.length} videos...`);
try {
annotatedItems.push(
...(await fetchVideoData(youtubeKey, workingSet))
);
} catch (e) {
output.text(`Error fetching video data: ${e}`);
}
}
while (annotatedItems.length) {
let workingSet = annotatedItems.splice(0, maxAirtableWrites);
output.text(`Updating ${workingSet.length} records...`);
// Update number fields
let numberRecords = workingSet.map((item) => ({
id: item.record.id,
fields: {
[viewCountField.id]: item.viewCount,
[likeCountField.id]: item.likeCount,
[commentCountField.id]: item.commentCount,
},
}));
try {
output.text(`Updating number fields: ${JSON.stringify(numberRecords, null, 2)}`);
await table.updateRecordsAsync(numberRecords);
output.text(`Successfully updated number fields.`);
} catch (e) {
output.text(`Error updating number fields: ${e}`);
}
// Update text fields
let textRecords = workingSet.map((item) => ({
id: item.record.id,
fields: {
[titleField.id]: item.title,
[descriptionField.id]: item.description,
[defaultAudioLanguageField.id]: item.defaultAudioLanguage,
},
}));
try {
output.text(`Updating text fields: ${JSON.stringify(textRecords, null, 2)}`);
await table.updateRecordsAsync(textRecords);
output.text(`Successfully updated text fields.`);
} catch (e) {
output.text(`Error updating text fields: ${e}`);
}
// Update date field
let dateRecords = workingSet.map((item) => ({
id: item.record.id,
fields: {
[publishedAtField.id]: new Date(item.publishedAt).toISOString(),
},
}));
try {
output.text(`Updating date field: ${JSON.stringify(dateRecords, null, 2)}`);
await table.updateRecordsAsync(dateRecords);
output.text(`Successfully updated date field.`);
} catch (e) {
output.text(`Error updating date field: ${e}`);
}
// Update attachment fields
let attachmentRecords = workingSet.map((item) => ({
id: item.record.id,
fields: {
[thumbnailField.id]: [{ url: item.thumbnail }],
},
}));
try {
output.text(`Updating attachment fields: ${JSON.stringify(attachmentRecords, null, 2)}`);
await table.updateRecordsAsync(attachmentRecords);
output.text(`Successfully updated attachment fields.`);
} catch (e) {
output.text(`Error updating attachment fields: ${e}`);
}
// Update channel ID field
for (let item of workingSet) {
let channelId = item.channelId;
let channelRecordId = await findOrCreateChannelRecord(channelTable, channelField, channelId);
let channelRecords = [{
id: item.record.id,
fields: {
[channelIdField.id]: [{ id: channelRecordId }]
},
}];
try {
output.text(`Updating channel ID field: ${JSON.stringify(channelRecords, null, 2)}`);
await table.updateRecordsAsync(channelRecords);
output.text(`Successfully updated channel ID field.`);
} catch (e) {
output.text(`Error updating channel ID field: ${e}`);
}
}
}
output.text("Operation complete.");