YouTube Notification Archiver

Keep an archive of all your YouTube notifications with thumbnails, links, exporting, editing, and deleting capabilities.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

Advertisement:

// ==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();
    }
  });
})();