Created
January 29, 2026 00:04
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /************************************** | |
| * 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 { | |
| "&": "&", | |
| "<": "<", | |
| ">": ">", | |
| '"': """, | |
| "'": "'", | |
| }[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