Keep an archive of all your YouTube notifications with thumbnails, links, exporting, editing, and deleting capabilities.
// ==UserScript==
// @name YouTube Notification Archiver
// @author someever
// @namespace https://github.com/somenever
// @license GPL-3.0-or-later
// @version 1.0.1
// @description Keep an archive of all your YouTube notifications with thumbnails, links, exporting, editing, and deleting capabilities.
// @match https://www.youtube.com/*
// @connect yt3.ggpht.com
// @connect i.ytimg.com
// @grant GM_xmlhttpRequest
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'yt_notification_archive_v1';
const PASSIVE_KEY = 'yt_notification_archive_passive';
const POPUP_WAIT_MS = 1000;
const CLOSE_AFTER_SAVE = true;
const SELECTORS = {
bellButton: 'ytd-notification-topbar-button-renderer button',
popupContainer: 'ytd-popup-container',
popup: 'ytd-popup-container tp-yt-iron-dropdown',
notificationItem: 'ytd-notification-renderer',
};
function loadArchive() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
} catch {
return [];
}
}
function saveArchive(data) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
function isPassiveEnabled() {
// Default to true if the user hasn't explicitly disabled it yet
const stored = localStorage.getItem(PASSIVE_KEY);
return stored === null ? true : stored === 'true';
}
function setPassiveEnabled(enabled) {
localStorage.setItem(PASSIVE_KEY, enabled ? 'true' : 'false');
}
function parseRelativeTime(text) {
if (!text) return null;
const now = new Date();
const t = text.toLowerCase();
if (t.includes('just now')) return now;
if (t.includes('yesterday')) {
const d = new Date(now);
d.setDate(d.getDate() - 1);
return d;
}
const match = t.match(/(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago/);
if (!match) return null;
const value = parseInt(match[1], 10);
const unit = match[2];
const d = new Date(now);
const map = {
second: 'Seconds', minute: 'Minutes', hour: 'Hours',
day: 'Date', week: 'Date', month: 'Month', year: 'FullYear'
};
if (unit === 'week') d.setDate(d.getDate() - value * 7);
else if (unit === 'month') d.setMonth(d.getMonth() - value);
else if (unit === 'year') d.setFullYear(d.getFullYear() - value);
else d[`set${map[unit]}`](d[`get${map[unit]}`]() - value);
return d;
}
function getVisibleNotifications(popup) {
if (!popup) return [];
const items = popup.querySelectorAll(SELECTORS.notificationItem);
const results = [];
items.forEach(item => {
const id = item.data?.notificationId;
// youtube has two ways of storing text in their dataset.
// some messages are stored as an array, while others are stored as a simple string.
// here we handle both to correctly extract the notification text.
const message = item.data?.shortMessage?.runs?.map((run) => run.text)?.join("") ?? item.data?.shortMessage?.simpleText;
if (!message && !id) return;
const ep = item.data.navigationEndpoint;
let url = "";
if (ep?.watchEndpoint?.videoId) {
url = `https://www.youtube.com/watch?v=${ep.watchEndpoint.videoId}`;
} else if (ep?.reelWatchEndpoint?.videoId) {
url = `https://www.youtube.com/shorts/${ep.reelWatchEndpoint.videoId}`;
} else if (ep.getCommentsFromInboxCommand?.videoId) {
const vId = ep.getCommentsFromInboxCommand.videoId;
const cId = ep.getCommentsFromInboxCommand.linkedCommentId;
url = `https://www.youtube.com/watch?v=${vId}&lc=${cId}`;
} else if (ep.getCommentsFromInboxCommand?.postId) {
const pId = ep.getCommentsFromInboxCommand.postId;
const cId = ep.getCommentsFromInboxCommand.linkedCommentId;
url = `https://www.youtube.com/post/${pId}?lc=${cId}`;
} else {
console.warn("Unknown navigation endpoint", ep);
}
const timeText = item.data.sentTimeText?.simpleText;
const parsedDate = parseRelativeTime(timeText);
const sentAt = parsedDate ? parsedDate.toISOString() : null;
const sentDate = sentAt?.slice(0, 10);
const thumbnails = item.data.thumbnail?.thumbnails ?? [];
const videoThumbnails = item.data.videoThumbnail?.thumbnails ?? [];
results.push({
id, message, timeText, sentAt, sentDate, url, thumbnails, videoThumbnails,
avatarUrl: thumbnails[0]?.url ?? "",
thumbnailUrl: videoThumbnails[0]?.url ?? "",
capturedAt: new Date().toISOString()
});
});
return results;
}
function mergeAndDedupe(existing, incoming) {
const map = new Map();
existing.forEach(item => map.set(item.id, item));
incoming.forEach(item => {
if (map.has(item.id)) {
const old = map.get(item.id);
if (!old.thumbnailUrl && item.thumbnailUrl) old.thumbnailUrl = item.thumbnailUrl;
if (!old.avatarUrl && item.avatarUrl) old.avatarUrl = item.avatarUrl;
if (!old.url && item.url) old.url = item.url;
} else {
map.set(item.id, item);
}
});
return Array.from(map.values());
}
function syncNow(popup) {
if (!popup) return;
const current = getVisibleNotifications(popup);
if (!current.length) return;
const archive = loadArchive();
const merged = mergeAndDedupe(archive, current);
saveArchive(merged);
console.info(`[YT Archiver] Synced ${current.length}. Archive size: ${merged.length}`);
}
async function autoOpenAndSync() {
const bell = document.querySelector(SELECTORS.bellButton);
if (!bell) return;
bell.click();
await new Promise(r => setTimeout(r, POPUP_WAIT_MS));
syncNow(document.querySelector(SELECTORS.popup));
if (CLOSE_AFTER_SAVE) bell.click();
}
function fetchImageAsBase64(url) {
return new Promise((resolve) => {
if (!url) return resolve(null);
GM_xmlhttpRequest({
method: "GET", url: url, responseType: "blob",
onload: function(response) {
const reader = new FileReader();
reader.onloadend = function() { resolve(reader.result); };
reader.readAsDataURL(response.response);
},
onerror: function(err) {
console.error("Failed to fetch image", url, err);
resolve(null);
}
});
});
}
const DIVISIONS = [
{ amount: 60, name: 'second' },
{ amount: 60, name: 'minute' },
{ amount: 24, name: 'hour' },
{ amount: 7, name: 'day' },
{ amount: 4.345, name: 'week' },
{ amount: 12, name: 'month' },
{ amount: Number.POSITIVE_INFINITY, name: 'year' }
];
const relFormatter = new Intl.RelativeTimeFormat("en", { numeric: 'auto' });
function getRelativeTime(timestamp) {
let diff = (timestamp - Date.now()) / 1000;
for (const division of DIVISIONS) {
if (Math.abs(diff) < division.amount) {
return relFormatter.format(Math.round(diff), division.name);
}
diff /= division.amount;
}
}
async function showArchive() {
let archive = loadArchive();
const w = window.open('about:blank', '_blank');
if (!w) {
alert("Popup blocked! Please allow popups for YouTube.");
return;
}
const render = () => {
const doc = w.document;
while (doc.body.firstChild) {
doc.body.removeChild(doc.body.firstChild);
}
doc.title = "YT Notification Archiver Dashboard";
const style = doc.createElement('style');
style.textContent = `
body { background:#0f0f0f; color:#eee; font-family: Roboto, Arial, sans-serif; margin: 0; padding: 20px; box-sizing: border-box; }
.toolbar { margin-bottom: 20px; padding: 10px; background: #1f1f1f; border-radius: 8px; display: flex; gap: 10px; align-items: center; position: sticky; top: 0; z-index: 100; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
h1 { margin: 0 20px 0 0; font-size: 18px; }
button { padding: 8px 16px; cursor:pointer; border-radius: 4px; border:none; background:#3ea6ff; color:#000; font-weight: 500; transition: filter 0.2s; }
button:hover { filter: brightness(1.1); }
button:disabled { background: #555; cursor: not-allowed; }
button.secondary { background: #333; color: #fff; }
button.danger { background: #ff4d4d; color: #fff; }
button.small { padding: 6px 12px; font-size: 12px; }
.list { display: flex; flex-direction: column; gap: 8px; max-width: 900px; margin: 0 auto; }
.item { display: grid; grid-template-columns: 48px 1fr 120px 60px; gap: 16px; padding: 12px; border-radius: 12px; background: #181818; border: 1px solid #333; align-items: start; }
.item:hover { background: #202020; border-color: #444; }
.avatar { width: 48px; height: 48px; border-radius: 50%; background: #333; object-fit: cover; }
.content { display: flex; flex-direction: column; }
.message { font-size: 14px; line-height: 1.4; color: #fff; margin-bottom: 4px; }
.meta { font-size: 12px; color: #aaa; display: flex; gap: 8px; align-items: center; margin-top: 4px; }
.badge { background: #333; padding: 2px 6px; border-radius: 4px; font-size: 10px; }
.thumbnail-link { display: block; width: 120px; height: 68px; border-radius: 8px; overflow: hidden; background: #000; }
.thumbnail { width: 100%; height: 100%; object-fit: cover; }
.actions { display: flex; justify-content: center; align-items: center; }
.status-bar { font-size: 12px; color: #aaa; margin-left: auto; }
a { text-decoration: none; color: inherit; }
.passive-toggle-container { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #eee; user-select: none; margin-left: 10px; }
.passive-toggle-container input { cursor: pointer; margin: 0; width: 16px; height: 16px; }
.tooltip-icon { display: inline-flex; align-items: center; justify-content: center; background: #444; color: #aaa; width: 16px; height: 16px; border-radius: 50%; font-size: 11px; font-weight: bold; cursor: help; position: relative; }
.tooltip-icon:hover { background: #555; color: #fff; }
.tooltip-text { visibility: hidden; position: absolute; width: 220px; background-color: #333; color: #fff; text-align: left; padding: 10px; border-radius: 6px; z-index: 110; top: 24px; left: 50%; transform: translateX(-50%); font-weight: normal; font-size: 12px; line-height: 1.4; box-shadow: 0 4px 10px rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.2s ease; pointer-events: none; }
.tooltip-icon:hover .tooltip-text { visibility: visible; opacity: 1; }
.flyout-overlay { position: fixed; top: 0; right: 0; bottom: 0; left: 0; background: rgba(0,0,0,0.7); display: flex; justify-content: flex-end; z-index: 1000; }
.flyout-panel { width: 100%; max-width: 400px; background: #1f1f1f; box-shadow: -4px 0 15px rgba(0,0,0,0.5); padding: 24px; box-sizing: border-box; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
.flyout-panel h2 { margin: 0; font-size: 18px; color: #fff; border-bottom: 1px solid #333; padding-bottom: 12px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label { font-size: 12px; color: #aaa; font-weight: bold; }
.form-group input, .form-group textarea { background: #0f0f0f; border: 1px solid #444; border-radius: 4px; color: #fff; padding: 8px; font-family: inherit; font-size: 14px; }
.form-group textarea { resize: vertical; min-height: 60px; }
.flyout-actions { display: flex; flex-direction: column; gap: 10px; margin-top: 15px; border-top: 1px solid #333; padding-top: 15px; }
.flyout-row { display: flex; gap: 10px; }
.flyout-row button { flex: 1; }
`;
doc.head.appendChild(style);
const toolbar = doc.createElement('div');
toolbar.className = 'toolbar';
const h1 = doc.createElement('h1');
h1.textContent = `Archive (${archive.length})`;
toolbar.appendChild(h1);
const exportJsonBtn = doc.createElement('button');
exportJsonBtn.className = 'secondary';
exportJsonBtn.textContent = 'Export JSON';
exportJsonBtn.onclick = () => {
const blob = new Blob([JSON.stringify(archive, null, 2)], { type: 'application/json' });
downloadBlob(blob, 'yt-notifications-lite.json');
};
const exportFullBtn = doc.createElement('button');
exportFullBtn.textContent = 'Export With Images (JSON + Base64)';
const statusBar = doc.createElement('span');
statusBar.className = 'status-bar';
exportFullBtn.onclick = async () => {
exportFullBtn.disabled = true;
exportFullBtn.textContent = 'Processing...';
const fullArchive = [];
for (let i = 0; i < archive.length; i++) {
const item = {...archive[i]};
statusBar.textContent = `Fetching images ${i + 1}/${archive.length}...`;
if (item.avatarUrl) item.avatarBase64 = await fetchImageAsBase64(item.avatarUrl);
if (item.thumbnailUrl) item.thumbnailBase64 = await fetchImageAsBase64(item.thumbnailUrl);
fullArchive.push(item);
}
downloadBlob(new Blob([JSON.stringify(fullArchive, null, 2)], { type: 'application/json' }), 'yt-notifications-full.json');
exportFullBtn.textContent = 'Done!';
exportFullBtn.disabled = false;
statusBar.textContent = 'Export complete.';
};
const passiveWrapper = doc.createElement('div');
passiveWrapper.className = 'passive-toggle-container';
const passiveCheckbox = doc.createElement('input');
passiveCheckbox.type = 'checkbox';
passiveCheckbox.id = 'passiveModeCheck';
passiveCheckbox.checked = isPassiveEnabled();
passiveCheckbox.onchange = (e) => {
setPassiveEnabled(e.target.checked);
};
const passiveLabel = doc.createElement('label');
passiveLabel.setAttribute('for', 'passiveModeCheck');
passiveLabel.textContent = 'Passive Mode';
const tooltipIcon = doc.createElement('span');
tooltipIcon.className = 'tooltip-icon';
tooltipIcon.textContent = '?';
const tooltipText = doc.createElement('span');
tooltipText.className = 'tooltip-text';
tooltipText.textContent = 'When enabled, the script will automatically archive notifications in the background whenever you open your notification bell dropdown.';
tooltipIcon.appendChild(tooltipText);
passiveWrapper.append(passiveCheckbox, passiveLabel, tooltipIcon);
toolbar.append(exportJsonBtn, exportFullBtn, passiveWrapper, statusBar);
doc.body.appendChild(toolbar);
const list = doc.createElement('div');
list.className = 'list';
archive
.sort((a, b) => (b.sentAt || b.capturedAt).localeCompare(a.sentAt || a.capturedAt))
.forEach(n => {
const item = doc.createElement('div');
item.className = 'item';
const avatar = doc.createElement('img');
avatar.className = 'avatar';
avatar.src = n.avatarBase64 || n.avatarUrl || 'https://www.gstatic.com/images/branding/product/2x/youtube_96in128dp.png';
avatar.loading = 'lazy';
const content = doc.createElement('div');
content.className = 'content';
const link = doc.createElement('a');
link.href = n.url || '#';
link.target = '_blank';
const msg = doc.createElement('div');
msg.className = 'message';
msg.textContent = n.message;
link.appendChild(msg);
const meta = doc.createElement('div');
meta.className = 'meta';
const dateSpan = doc.createElement('span');
dateSpan.textContent = n.sentDate || 'Unknown Date';
const relativeSpan = doc.createElement('span');
const timeVal = new Date(n.sentAt || n.capturedAt);
relativeSpan.textContent = ` • ${getRelativeTime(timeVal)}`;
meta.append(dateSpan, relativeSpan);
if (!n.url) {
const badge = doc.createElement('span');
badge.className = 'badge';
badge.textContent = 'No Link';
meta.appendChild(badge);
}
content.append(link, meta);
const thumbLink = doc.createElement('a');
thumbLink.className = 'thumbnail-link';
thumbLink.href = n.url || '#';
thumbLink.target = '_blank';
if (n.thumbnailUrl || n.thumbnailBase64) {
const thumbImg = doc.createElement('img');
thumbImg.className = 'thumbnail';
thumbImg.src = n.thumbnailBase64 || n.thumbnailUrl;
thumbLink.appendChild(thumbImg);
} else {
const noPrev = doc.createElement('div');
noPrev.setAttribute('style', 'color:#444;font-size:10px;text-align:center;padding-top:25px;');
noPrev.textContent = 'No Preview';
thumbLink.appendChild(noPrev);
}
const actionsDiv = doc.createElement('div');
actionsDiv.className = 'actions';
const editBtn = doc.createElement('button');
editBtn.className = 'secondary small';
editBtn.textContent = 'Edit';
editBtn.onclick = () => openEditPanel(n, doc);
actionsDiv.append(editBtn);
item.append(avatar, content, thumbLink, actionsDiv);
list.appendChild(item);
});
doc.body.appendChild(list);
};
function openEditPanel(notification, targetDoc) {
const overlay = targetDoc.createElement('div');
overlay.className = 'flyout-overlay';
const panel = targetDoc.createElement('div');
panel.className = 'flyout-panel';
const title = targetDoc.createElement('h2');
title.textContent = 'Edit Notification';
panel.appendChild(title);
const createField = (labelStr, value, isTextArea = false) => {
const group = targetDoc.createElement('div');
group.className = 'form-group';
const label = targetDoc.createElement('label');
label.textContent = labelStr;
const input = targetDoc.createElement(isTextArea ? 'textarea' : 'input');
input.value = value || '';
group.append(label, input);
panel.appendChild(group);
return input;
};
const msgInput = createField('Message text', notification.message, true);
const urlInput = createField('Notification Link URL', notification.url);
const avatarInput = createField('Avatar image URL', notification.avatarUrl);
const thumbInput = createField('Thumbnail image URL', notification.thumbnailUrl);
const flyoutActions = targetDoc.createElement('div');
flyoutActions.className = 'flyout-actions';
const standardRow = targetDoc.createElement('div');
standardRow.className = 'flyout-row';
const cancelBtn = targetDoc.createElement('button');
cancelBtn.className = 'secondary';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = () => overlay.remove();
const saveBtn = targetDoc.createElement('button');
saveBtn.textContent = 'Save Changes';
saveBtn.onclick = () => {
const index = archive.findIndex(item => item.id === notification.id);
if (index !== -1) {
archive[index].message = msgInput.value;
archive[index].url = urlInput.value;
if (archive[index].avatarUrl !== avatarInput.value) {
archive[index].avatarUrl = avatarInput.value;
delete archive[index].avatarBase64;
}
if (archive[index].thumbnailUrl !== thumbInput.value) {
archive[index].thumbnailUrl = thumbInput.value;
delete archive[index].thumbnailBase64;
}
saveArchive(archive);
}
overlay.remove();
render();
};
standardRow.append(cancelBtn, saveBtn);
const deleteBtn = targetDoc.createElement('button');
deleteBtn.className = 'danger';
deleteBtn.textContent = 'Delete Notification';
deleteBtn.onclick = () => {
archive = archive.filter(item => item.id !== notification.id);
saveArchive(archive);
overlay.remove();
render();
};
flyoutActions.append(standardRow, deleteBtn);
panel.appendChild(flyoutActions);
overlay.appendChild(panel);
targetDoc.body.appendChild(overlay);
overlay.onclick = (e) => {
if (e.target === overlay) overlay.remove();
};
}
if (w.document.readyState === 'complete') {
render();
} else {
w.onload = render;
}
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
if (isPassiveEnabled()) {
const target = document.querySelector(SELECTORS.popupContainer);
if (target) {
const observer = new MutationObserver((mutations, obs) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1 && node.tagName.toLowerCase() === 'tp-yt-iron-dropdown') {
node.addEventListener("opened-changed", () => {
if (node.opened) {
setTimeout(() => syncNow(node), POPUP_WAIT_MS);
}
})
obs.disconnect();
return
}
}
}
}
});
observer.observe(target, { childList: true });
console.info("[YT Archiver] Passive Mode active. I will archive whenever you open the notification list!");
}
}
document.addEventListener('keydown', e => {
if (e.ctrlKey && e.shiftKey && e.code === 'KeyS') {
e.preventDefault();
syncNow(document.querySelector(SELECTORS.popup));
}
if (e.ctrlKey && e.shiftKey && e.code === 'KeyV') {
e.preventDefault();
showArchive();
}
});
})();