Skip to content

Instantly share code, notes, and snippets.

@ShrootBuck
Created January 29, 2026 00:04
Show Gist options
  • Select an option

  • Save ShrootBuck/e6a323b1fe1cd6a59a66360a94a47c1a to your computer and use it in GitHub Desktop.

Select an option

Save ShrootBuck/e6a323b1fe1cd6a59a66360a94a47c1a to your computer and use it in GitHub Desktop.
A Google Sheets AppScript to send me new YouTube uploads in a specific email folder. Except shorts.
/**************************************
* YT Watcher
* Sheet columns: input | channel_id | channel_title | last_seen_video_id | last_seen_published_at
* Requires: Advanced Service "YouTube Data API v3" enabled in Apps Script.
**************************************/
const CONFIG = {
RECEIVER_EMAIL: "",
TIMEZONE: "America/Phoenix",
MAX_ITEMS_PER_FEED: 10,
EMAIL_SENDER_NAME: "YT Watcher",
RETRY_ATTEMPTS: 3,
RETRY_BASE_MS: 1500,
};
function init() {
const ss = SpreadsheetApp.getActive();
const sh = ss.getSheetByName("Channels") || ss.insertSheet("Channels");
const header = [
"input (url/handle/id)",
"channel_id",
"channel_title",
"last_seen_video_id",
"last_seen_published_at",
];
const hasHeader =
sh.getLastRow() >= 1 &&
sh.getRange(1, 1, 1, header.length).getValues()[0].some(String);
if (!hasHeader) {
sh.getRange(1, 1, 1, header.length).setValues([header]);
}
}
function createCheckTriggerHourly() {
ScriptApp.getProjectTriggers().forEach((trigger) => {
if (trigger.getHandlerFunction() === "checkUploads") {
ScriptApp.deleteTrigger(trigger);
}
});
ScriptApp.newTrigger("checkUploads").timeBased().everyHours(1).create();
}
function createSyncTriggerDaily() {
ScriptApp.getProjectTriggers().forEach((trigger) => {
if (trigger.getHandlerFunction() === "syncSubscriptions") {
ScriptApp.deleteTrigger(trigger);
}
});
ScriptApp.newTrigger("syncSubscriptions")
.timeBased()
.atHour(6)
.everyDays(1)
.create();
}
function createAllTriggers() {
createCheckTriggerHourly();
createSyncTriggerDaily();
}
function seedLastSeen() {
const sh = getChannelsSheet_();
const rows = sh.getDataRange().getValues();
const NS = getNamespaces_();
for (let i = 1; i < rows.length; i++) {
const input = rows[i][0];
const channelId = ensureChannelId_(input, rows[i][1], i, sh);
if (!channelId) continue;
const doc = fetchFeed_(channelId);
if (!doc) continue;
const entries = doc.getRootElement().getChildren("entry", NS.atom);
if (!entries || !entries.length) continue;
const e0 = entries[0];
const vid = childTextNS_(e0, "videoId", NS.yt);
const pub = childTextNS_(e0, "published", NS.atom);
if (vid) sh.getRange(i + 1, 4).setValue(vid);
if (pub) sh.getRange(i + 1, 5).setValue(new Date(pub).toISOString());
if (!rows[i][2]) {
const author =
childTextNS_(e0.getChild("author", NS.atom), "name", NS.atom) || "";
if (author) sh.getRange(i + 1, 3).setValue(author);
}
}
}
function checkUploads() {
const lock = LockService.getScriptLock();
lock.waitLock(20 * 1000);
try {
const sh = getChannelsSheet_();
const NS = getNamespaces_();
const values = sh.getDataRange().getValues();
for (let i = 1; i < values.length; i++) {
try {
const input = values[i][0];
const channelId = ensureChannelId_(input, values[i][1], i, sh);
if (!channelId) continue;
const doc = fetchFeed_(channelId);
if (!doc) continue;
const entries =
doc.getRootElement().getChildren("entry", NS.atom) || [];
const window = entries
.slice(0, CONFIG.MAX_ITEMS_PER_FEED)
.map((entry) => parseEntry_(entry, NS, values[i][2]))
.filter(Boolean)
// --- THE FIX IS HERE ---
.filter((item) => !item.link.includes('/shorts/'))
// -----------------------
.sort((a, b) => a.published - b.published);
if (!window.length) continue;
let channelTitle = values[i][2];
if (!channelTitle) {
channelTitle = safeGetChannelTitle_(entries, NS);
if (channelTitle) {
sh.getRange(i + 1, 3).setValue(channelTitle);
values[i][2] = channelTitle;
}
}
const lastSeenVid = values[i][3];
const lastSeenPubISO = values[i][4];
const lastSeenPub = lastSeenPubISO ? new Date(lastSeenPubISO) : null;
const fresh = determineFreshItems_(window, lastSeenVid, lastSeenPub);
fresh.forEach((item) => sendEmail_(item));
const newest = window[window.length - 1];
if (newest) {
sh.getRange(i + 1, 4).setValue(newest.videoId);
sh.getRange(i + 1, 5).setValue(newest.published.toISOString());
values[i][3] = newest.videoId;
values[i][4] = newest.published.toISOString();
}
} catch (error) {
logError_(
"checkUploads row=" + (i + 1),
error,
values[i] && values[i][1],
);
}
}
} finally {
lock.releaseLock();
}
}
function determineFreshItems_(items, lastSeenVid, lastSeenPub) {
if (!items.length) return [];
if (lastSeenVid) {
const foundIdx = items.findIndex((item) => item.videoId === lastSeenVid);
if (foundIdx >= 0 && foundIdx < items.length - 1) {
return items.slice(foundIdx + 1);
}
if (foundIdx === -1 && lastSeenPub) {
return items.filter((item) => item.published > lastSeenPub);
}
return [];
}
if (lastSeenPub) {
return items.filter((item) => item.published > lastSeenPub);
}
return [];
}
function importMySubscriptions() {
const sh = getChannelsSheet_();
const idxById = indexRowsById_(sh);
let token;
let upserts = 0;
do {
const resp = YouTube.Subscriptions.list("snippet", {
mine: true,
maxResults: 50,
pageToken: token,
});
(resp.items || []).forEach((item) => {
const id = item.snippet.resourceId.channelId;
const title = item.snippet.title;
const canonical = `https://www.youtube.com/channel/${id}`;
if (idxById[id] != null) {
const row = idxById[id] + 1;
sh.getRange(row, 1).setValue(canonical);
const currTitle = sh.getRange(row, 3).getValue();
if (currTitle !== title) {
sh.getRange(row, 3).setValue(title);
}
} else {
sh.appendRow([canonical, id, title, "", ""]);
idxById[id] = sh.getLastRow() - 1;
}
upserts++;
});
token = resp.nextPageToken;
} while (token);
Logger.log("Upserted " + upserts + " subscriptions.");
}
function syncSubscriptions() {
const sh = getChannelsSheet_();
const existing = readSheetRows_(sh);
const byIdRow = {};
for (let i = 1; i < existing.length; i++) {
const id = existing[i][1];
if (id) {
byIdRow[id] = i;
}
}
const seenIds = {};
let token;
let count = 0;
do {
const resp = YouTube.Subscriptions.list("snippet", {
mine: true,
maxResults: 50,
pageToken: token,
});
(resp.items || []).forEach((item) => {
const id = item.snippet.resourceId.channelId;
const title = item.snippet.title;
seenIds[id] = title;
count++;
});
token = resp.nextPageToken;
} while (token);
Object.keys(seenIds).forEach((id) => {
const title = seenIds[id];
const canonical = `https://www.youtube.com/channel/${id}`;
if (byIdRow[id] != null) {
const row = byIdRow[id] + 1;
sh.getRange(row, 1).setValue(canonical);
const currTitle = sh.getRange(row, 3).getValue();
if (currTitle !== title) {
sh.getRange(row, 3).setValue(title);
}
} else {
sh.appendRow([canonical, id, title, "", ""]);
byIdRow[id] = sh.getLastRow() - 1;
}
});
const toDelete = [];
for (let i = 1; i < existing.length; i++) {
const id = existing[i][1];
if (id && !seenIds[id]) toDelete.push(i + 1);
}
toDelete.sort((a, b) => b - a).forEach((row) => sh.deleteRow(row));
Logger.log(
"Sync complete. Subscriptions: " +
count +
". Deleted rows: " +
toDelete.length,
);
}
function getChannelsSheet_() {
const ss = SpreadsheetApp.getActive();
return ss.getSheetByName("Channels") || ss.insertSheet("Channels");
}
function getLogSheet_() {
const ss = SpreadsheetApp.getActive();
let sh = ss.getSheetByName("Log");
if (!sh) {
sh = ss.insertSheet("Log");
sh.getRange(1, 1, 1, 4).setValues([
["timestamp", "channel_id", "context", "error"],
]);
}
return sh;
}
function logError_(context, err, channelId) {
const sh = getLogSheet_();
const ts = Utilities.formatDate(
new Date(),
CONFIG.TIMEZONE,
"yyyy-MM-dd HH:mm:ss z",
);
const msg = err && err.stack ? err.stack : String(err);
sh.appendRow([ts, channelId || "", context || "", msg]);
}
function indexRowsById_(sh) {
const values = sh.getDataRange().getValues();
const byId = {};
for (let i = 1; i < values.length; i++) {
const id = values[i][1];
if (id && byId[id] == null) {
byId[id] = i;
}
}
return byId;
}
function readSheetRows_(sh) {
return sh.getDataRange().getValues();
}
function ensureChannelId_(input, channelId, rowIndex, sh) {
if (channelId && /^UC[0-9A-Za-z_-]{22}$/.test(channelId)) return channelId;
if (!input) return null;
let id = parseChannelIdFromInput_(input);
if (!id) {
id = parseHandleFromInput_(input);
}
if (id) {
sh.getRange(rowIndex + 1, 2).setValue(id);
const currentInput = String(sh.getRange(rowIndex + 1, 1).getValue());
if (!/\/channel\//.test(currentInput)) {
sh.getRange(rowIndex + 1, 1).setValue(
"https://www.youtube.com/channel/" + id,
);
}
return id;
}
return null;
}
function parseChannelIdFromInput_(input) {
const normalized = String(input).trim();
if (/^UC[0-9A-Za-z_-]{22}$/.test(normalized)) return normalized;
const channelMatch = normalized.match(/\/channel\/(UC[0-9A-Za-z_-]{22})/);
if (channelMatch) return channelMatch[1];
return null;
}
function parseHandleFromInput_(input) {
const str = String(input).trim();
let handle = null;
if (str.charAt(0) === "@") handle = str;
const handleMatch = str.match(/youtube\.com\/(@[A-Za-z0-9_.-]+)/i);
if (handleMatch) handle = handleMatch[1];
if (!handle) return null;
try {
const url = "https://www.youtube.com/" + handle;
const html = fetchWithRetry_(url, {
muteHttpExceptions: true,
followRedirects: true,
}).getContentText();
const rx = /"channelId":"(UC[0-9A-Za-z_-]{22})"/;
const found = html.match(rx);
return found ? found[1] : null;
} catch (error) {
logError_("ensureChannelId fetch handle " + handle, error, null);
return null;
}
}
function fetchFeed_(channelId) {
const url =
"https://www.youtube.com/feeds/videos.xml?channel_id=" + channelId;
try {
const res = fetchWithRetry_(url, { muteHttpExceptions: true });
if (res.getResponseCode() >= 200 && res.getResponseCode() < 300) {
return XmlService.parse(res.getContentText());
}
} catch (error) {
logError_("fetchFeed channel=" + channelId, error, channelId);
}
return null;
}
function fetchWithRetry_(url, options) {
let attempt = 0;
let lastErr = null;
let backoff = CONFIG.RETRY_BASE_MS;
while (attempt < CONFIG.RETRY_ATTEMPTS) {
try {
const res = UrlFetchApp.fetch(url, options || {});
const code = res.getResponseCode();
if (code === 429 || code >= 500) throw new Error("HTTP " + code);
return res;
} catch (error) {
lastErr = error;
attempt++;
if (attempt >= CONFIG.RETRY_ATTEMPTS) break;
Utilities.sleep(backoff);
backoff *= 2;
}
}
throw lastErr || new Error("fetch failed");
}
function getNamespaces_() {
return {
atom: XmlService.getNamespace("http://www.w3.org/2005/Atom"),
yt: XmlService.getNamespace("http://www.youtube.com/xml/schemas/2015"),
media: XmlService.getNamespace("http://search.yahoo.com/mrss/"),
};
}
function childTextNS_(parent, name, ns) {
if (!parent) return "";
const el = parent.getChild(name, ns);
return el ? el.getText() : "";
}
function parseEntry_(entry, NS, fallbackChannelTitle) {
try {
const title = childTextNS_(entry, "title", NS.atom);
const links = entry.getChildren("link", NS.atom) || [];
let link = null;
for (let i = 0; i < links.length; i++) {
const relAttr = links[i].getAttribute("rel");
if (!relAttr || relAttr.getValue() === "alternate") {
link = links[i].getAttribute("href").getValue();
break;
}
}
const videoId = childTextNS_(entry, "videoId", NS.yt);
const published = new Date(childTextNS_(entry, "published", NS.atom));
let authorName = fallbackChannelTitle;
if (!authorName) {
const authorEl = entry.getChild("author", NS.atom);
authorName = childTextNS_(authorEl, "name", NS.atom);
}
const mg = entry.getChild("group", NS.media);
let thumbnail = null;
if (mg) {
const tnodes = mg.getChildren("thumbnail", NS.media) || [];
if (tnodes.length) {
const biggest = tnodes.reduce((previous, current) => {
const prevWidth = Number(
previous.getAttribute("width")
? previous.getAttribute("width").getValue()
: 0,
);
const currWidth = Number(
current.getAttribute("width")
? current.getAttribute("width").getValue()
: 0,
);
return currWidth > prevWidth ? current : previous;
});
thumbnail = biggest.getAttribute("url").getValue();
}
}
return {
title,
link,
videoId,
published,
author: authorName,
thumbnail,
};
} catch (err) {
logError_("parseEntry", err, null);
return null;
}
}
function safeGetChannelTitle_(entries, NS) {
try {
if (!entries.length) return "";
const entry = entries[0];
const authorEl = entry.getChild("author", NS.atom);
return childTextNS_(authorEl, "name", NS.atom) || "";
} catch (_) {
return "";
}
}
function sendEmail_(item) {
const when = Utilities.formatDate(
item.published,
CONFIG.TIMEZONE,
"EEE, MMM d yyyy HH:mm z",
);
const subject = item.title;
const plain =
item.title +
"\n" +
item.link +
"\n" +
"Channel: " +
item.author +
"\n" +
"Published: " +
when +
"\n";
const html =
"<div>" +
'<p><b><a href="' +
esc_(item.link) +
'">' +
esc_(item.title) +
"</a></b></p>" +
(item.thumbnail
? '<p><a href="' +
esc_(item.link) +
'"><img src="' +
esc_(item.thumbnail) +
'" alt="thumbnail" /></a></p>'
: "") +
"<p>Channel: " +
esc_(item.author) +
"</p>" +
"<p>Published: " +
esc_(when) +
"</p>" +
"</div>";
GmailApp.sendEmail(CONFIG.RECEIVER_EMAIL, subject, plain, {
name: item.author || CONFIG.EMAIL_SENDER_NAME,
htmlBody: html,
});
}
function esc_(s) {
return String(s).replace(/[&<>"']/g, (c) => {
return {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[c];
});
}
function deDupeChannels() {
const sh = getChannelsSheet_();
const rows = sh.getDataRange().getValues();
if (rows.length <= 2) return;
const seen = {};
const toDelete = [];
for (let r = 2; r <= rows.length; r++) {
const id = sh.getRange(r, 2).getValue();
if (!id) continue;
if (seen[id]) {
toDelete.push(r);
} else {
seen[id] = r;
}
}
toDelete.sort((a, b) => b - a).forEach((row) => sh.deleteRow(row));
Logger.log("Deleted " + toDelete.length + " duplicate rows.");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment