YouTube Notification Archiver

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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