YouTube Notification Archiver

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Advertisement:

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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