G-Obstructore

Reducing DOM size by archiving old ChatGPT & DeepSeek messages

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         G-Obstructore
// @namespace    https://chatgpt.com/
// @version      2.2
// @description  Reducing DOM size by archiving old ChatGPT & DeepSeek messages
// @author       lucz
// @match        https://chatgpt.com/*
// @match        https://chat.deepseek.com/*
// @grant        GM_addStyle
// @icon         https://lois.media/images/nosyropgang.svg
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  const GO_KEY = 'gocfg';
  const DEFAULTS = Object.freeze({
    KEEP_OPEN: 5,
    TICK_MS: 900,
    NEAR_BOTTOM_PX: 260,
    ENABLED: true,
    PANEL_OPEN: false,
  });

  const ADAPTERS = {
    chatgpt: {
      match: () => location.hostname.includes('chatgpt.com'),
      MSG_SEL: '[data-message-author-role]',
      COMPOSER_SEL: '#prompt-textarea, [data-testid="prompt-textarea"], .ProseMirror, [contenteditable="true"]',
      getRole: (node) => node.getAttribute('data-message-author-role') || 'assistant',
      labelUser: '👤 Вы',
      labelBot: '🤖 ChatGPT',
      botName: 'ChatGPT',
      isValidMsg: () => true,
    },
    deepseek: {
      match: () => location.hostname.includes('chat.deepseek.com'),
      MSG_SEL: null,
      COMPOSER_SEL: '#chat-input, [contenteditable="true"]',
      getRole: (node) => node.querySelector('div.ds-markdown') ? 'assistant' : 'user',
      labelUser: '👤 Вы',
      labelBot: '🤖 DeepSeek',
      botName: 'DeepSeek',

      isValidMsg: (node) => node.children.length > 0 && node.offsetHeight > 20,
    },
  };

  const SITE = Object.values(ADAPTERS).find(a => a.match()) || ADAPTERS.chatgpt;
  const IS_DEEPSEEK = SITE === ADAPTERS.deepseek;

  let _dsContainer = null;

  function findDeepSeekContainer() {
    if (_dsContainer && document.contains(_dsContainer)) return _dsContainer;

    _dsContainer = document.querySelector('.dad65929');
    if (_dsContainer) return _dsContainer;

    for (const el of document.querySelectorAll('div[class]')) {
      if (el.children.length >= 2 && el.querySelector('div.ds-markdown')) {
        _dsContainer = el;
        return _dsContainer;
      }
    }

    return null;
  }

  function getMessages() {
    if (IS_DEEPSEEK) {
      const container = findDeepSeekContainer();
      if (!container) return [];
      return Array.from(container.children).filter(SITE.isValidMsg);
    }
    return Array.from(document.querySelectorAll(SITE.MSG_SEL));
  }

  function loadCfg() {
    try {
      const raw = localStorage.getItem(GO_KEY);
      return { ...DEFAULTS, ...(raw ? JSON.parse(raw) : {}) };
    } catch {
      return { ...DEFAULTS };
    }
  }

  function saveCfg() {
    try { localStorage.setItem(GO_KEY, JSON.stringify(cfg)); } catch {}
  }

  let cfg = loadCfg();
  let enabled = cfg.ENABLED;

  const archived = [];
  let uiRefs = null;
  let tickTimer = null;
  let tickPaused = false;
  let lastMsgCount = 0;
  let dirtyLimit = false;
  let totalSavedBytes = 0;

  const css = `
    html, body {
      scroll-behavior: auto !important;
      overflow-anchor: none !important;
    }

    [data-message-author-role],
    .dad65929 > div {
      content-visibility: auto;
      contain: layout style paint;
      contain-intrinsic-size: auto 400px;
    }

    .cg-min * {
      animation: none !important;
      transition: none !important;
    }

    .cg-placeholder {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 8px;
      padding: 6px 14px;
      margin: 4px 0;
      border-radius: 12px;
      cursor: pointer;
      font: 500 12px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      color: rgba(255,255,255,.7);
      background: linear-gradient(135deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
      border: 1px dashed rgba(255,255,255,.15);
      backdrop-filter: blur(8px);
      -webkit-backdrop-filter: blur(8px);
      transition: background .15s, border-color .15s, color .15s;
      user-select: none;
    }

    .cg-placeholder:hover {
      background: linear-gradient(135deg, rgba(255,255,255,.12), rgba(255,255,255,.05));
      border-color: rgba(255,255,255,.28);
      color: rgba(255,255,255,.92);
    }

    .cg-placeholder .cg-ph-icon { font-size: 14px; }

    .cg-fab {
      position: fixed;
      right: 14px;
      bottom: 18px;
      z-index: 2147483647;
      font: 600 12px/1.3 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      color: rgba(255,255,255,.95);
      user-select: none;
      -webkit-user-select: none;
    }

    .cg-fab * { box-sizing: border-box; }

    .cg-pill {
      position: relative;
      display: flex;
      align-items: center;
      gap: 10px;
      padding: 8px 10px;
      border-radius: 24px;
      cursor: pointer;
      overflow: hidden;
      background: linear-gradient(135deg, rgba(255,255,255,.16), rgba(255,255,255,.06));
      border: 1px solid rgba(255,255,255,.20);
      box-shadow:
        0 10px 30px rgba(0,0,0,.20),
        inset 0 1px 0 rgba(255,255,255,.25),
        inset 0 -1px 0 rgba(255,255,255,.05);
      backdrop-filter: blur(18px) saturate(160%);
      -webkit-backdrop-filter: blur(18px) saturate(160%);
    }

    .cg-pill::before {
      content: "";
      position: absolute;
      inset: 0;
      pointer-events: none;
      border-radius: inherit;
      background:
        radial-gradient(circle at top left, rgba(255,255,255,.28), transparent 42%),
        linear-gradient(180deg, rgba(255,255,255,.12), transparent 40%);
      opacity: .95;
    }

    .cg-icon {
      position: relative;
      z-index: 1;
      width: 26px;
      height: 26px;
      min-width: 26px;
      border-radius: 999px;
      display: grid;
      place-items: center;
      font-size: 16px;
      line-height: 1;
      background: rgba(255,255,255,.08);
      border: 1px solid rgba(255,255,255,.14);
      box-shadow:
        inset 0 1px 0 rgba(255,255,255,.20),
        0 4px 14px rgba(255,255,255,.06);
      text-shadow: 0 1px 2px rgba(0,0,0,.22), 0 0 10px rgba(255,255,255,.10);
      backdrop-filter: blur(10px);
      -webkit-backdrop-filter: blur(10px);
    }

    .cg-panel {
      position: relative;
      z-index: 1;
      display: none;
      flex-direction: column;
      gap: 6px;
      padding-right: 2px;
      min-width: 160px;
    }

    .cg-fab.open .cg-panel { display: flex; }

    .cg-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
      white-space: nowrap;
    }

    .cg-count-label { opacity: .78; }
    .cg-label { letter-spacing: .01em; }

    .cg-sw {
      position: relative;
      width: 36px;
      height: 20px;
      border-radius: 999px;
      border: 1px solid rgba(255,255,255,.14);
      padding: 2px;
      background: rgba(255,255,255,.10);
      display: inline-flex;
      align-items: center;
      cursor: pointer;
      box-shadow: inset 0 1px 0 rgba(255,255,255,.18), 0 2px 10px rgba(0,0,0,.12);
      backdrop-filter: blur(8px);
      -webkit-backdrop-filter: blur(8px);
    }

    .cg-sw::after {
      content: '';
      width: 14px;
      height: 14px;
      border-radius: 50%;
      background: rgba(255,255,255,.92);
      box-shadow: 0 1px 4px rgba(0,0,0,.25), inset 0 1px 0 rgba(255,255,255,.85);
      transition: transform .18s ease;
    }

    .cg-fab[data-enabled="true"] .cg-sw {
      background: rgba(255,255,255,.18);
      border-color: rgba(255,255,255,.22);
    }

    .cg-fab[data-enabled="true"] .cg-sw::after { transform: translateX(16px); }
    .cg-fab[data-enabled="false"] .cg-pill { opacity: .74; }
    .cg-fab[data-enabled="false"] .cg-icon { filter: grayscale(.12); opacity: .75; }

    .cg-stepper { display: inline-flex; align-items: center; gap: 4px; }

    .cg-stepper button {
      width: 20px;
      height: 20px;
      border-radius: 6px;
      border: 1px solid rgba(255,255,255,.18);
      background: rgba(255,255,255,.08);
      color: rgba(255,255,255,.85);
      font: 700 13px/1 system-ui;
      cursor: pointer;
      display: grid;
      place-items: center;
      padding: 0;
      backdrop-filter: blur(6px);
      -webkit-backdrop-filter: blur(6px);
    }

    .cg-stepper button:hover { background: rgba(255,255,255,.16); }

    .cg-stepper-val {
      min-width: 18px;
      text-align: center;
      font-variant-numeric: tabular-nums;
    }

    .cg-unfocused {
      font-size: 11px;
      opacity: .65;
      font-variant-numeric: tabular-nums;
    }
  `;

  try {
    GM_addStyle(css);
  } catch {
    const s = document.createElement('style');
    s.textContent = css;
    document.head.appendChild(s);
  }

  const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));

  function isNearBottom() {
    const el = document.scrollingElement || document.documentElement;
    return (el.scrollHeight - el.clientHeight) - el.scrollTop < cfg.NEAR_BOTTOM_PX;
  }

  function estimateNodeBytes(node) {
    const htmlBytes = new Blob([node.outerHTML]).size;
    const nodeCount = node.querySelectorAll('*').length + 1;
    return htmlBytes + nodeCount * 1300;
  }

  function formatBytes(bytes) {
    if (bytes < 1024) return bytes + ' B';
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
    return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
  }

  let composerDone = false;

  function lightenComposer() {
    if (composerDone) return;
    const el = document.querySelector(SITE.COMPOSER_SEL);
    if (!el) return;
    for (const [k, v] of [
      ['autocomplete', 'off'], ['autocorrect', 'off'],
      ['autocapitalize', 'off'], ['spellcheck', 'false'], ['inputmode', 'text'],
    ]) el.setAttribute(k, v);
    composerDone = true;
  }

  function createPlaceholder(entry) {
    const label = entry.role === 'user' ? SITE.labelUser : SITE.labelBot;
    const ph = document.createElement('div');
    ph.className = 'cg-placeholder';
    ph.innerHTML = `<span class="cg-ph-icon">👁</span> <span>Показать: ${label}</span>`;
    ph.addEventListener('click', () => restoreOne(entry));
    return ph;
  }

  function archiveOld() {
    const nodes = getMessages();

    if (!dirtyLimit && nodes.length === lastMsgCount && archived.length > 0) return;
    dirtyLimit = false;
    lastMsgCount = nodes.length;

    const limit = Math.max(0, nodes.length - cfg.KEEP_OPEN);
    if (limit <= 0 || !isNearBottom()) return;

    let changed = false;

    for (let i = 0; i < limit; i++) {
      const n = nodes[i];
      if (n.dataset.cgArc === '1' || n.dataset.cgPinned === '1') continue;

      const byteSize = estimateNodeBytes(n);
      n.dataset.cgArc = '1';

      const role = SITE.getRole(n);
      const entry = { node: n, placeholder: null, htmlSize: byteSize, role };
      entry.placeholder = createPlaceholder(entry);

      n.parentNode.insertBefore(entry.placeholder, n);
      n.remove();

      archived.push(entry);
      totalSavedBytes += byteSize;
      changed = true;
    }

    if (changed) updateCount();
  }

  function restoreOne(entry) {
    const idx = archived.indexOf(entry);
    if (idx === -1) return;

    entry.node.dataset.cgArc = '0';
    entry.node.dataset.cgPinned = '1';
    entry.placeholder.parentNode.insertBefore(entry.node, entry.placeholder);
    entry.placeholder.remove();

    totalSavedBytes = Math.max(0, totalSavedBytes - entry.htmlSize);
    archived.splice(idx, 1);
    entry.node = null;
    entry.placeholder = null;

    updateCount();
  }

  function restoreAll() {
    if (!archived.length) return;
    for (const entry of [...archived]) {
      if (!entry.node) continue;
      entry.node.dataset.cgArc = '0';
      delete entry.node.dataset.cgPinned;
      if (entry.placeholder?.parentNode) {
        entry.placeholder.parentNode.insertBefore(entry.node, entry.placeholder);
        entry.placeholder.remove();
      }
      entry.node = null;
      entry.placeholder = null;
    }
    archived.length = 0;
    totalSavedBytes = 0;
    updateCount();
  }

  let lastPath = location.pathname;

  function checkNavigation() {
    if (location.pathname !== lastPath) {
      lastPath = location.pathname;

      $$('[data-cg-pinned]').forEach(n => delete n.dataset.cgPinned);

      if (IS_DEEPSEEK) _dsContainer = null;

      archived.length = 0;
      totalSavedBytes = 0;
      lastMsgCount = 0;
      dirtyLimit = false;
      composerDone = false;
      $$('.cg-placeholder').forEach(ph => ph.remove());
      updateCount();
    }
  }

  function ensureUI() {
    if (uiRefs) return;

    const root = document.createElement('div');
    root.className = 'cg-fab' + (cfg.PANEL_OPEN ? ' open' : '');
    root.setAttribute('data-enabled', String(enabled));

    root.innerHTML = `
      <div class="cg-pill">
        <div class="cg-icon" title="G-Obstructore for ${SITE.botName}">🧹</div>
        <div class="cg-panel">
          <div class="cg-row">
            <span class="cg-label">G-Obstructore</span>
            <button class="cg-sw" type="button" aria-label="Tumbler"></button>
          </div>
          <div class="cg-row">
            <span class="cg-count-label">Показывать:</span>
            <div class="cg-stepper">
              <button class="cg-step-down" type="button">−</button>
              <span class="cg-stepper-val">${cfg.KEEP_OPEN}</span>
              <button class="cg-step-up" type="button">+</button>
            </div>
          </div>
          <div class="cg-row">
            <span class="cg-unfocused">Скрыто: <span class="cg-tab-val">0</span></span>
          </div>
          <div class="cg-row">
            <span class="cg-unfocused">Сэкономлено: <span class="cg-ram-val">0 B</span></span>
          </div>
        </div>
      </div>`;

    root.addEventListener('click', (ev) => {
      if (ev.target.closest('.cg-sw')) {
        ev.stopPropagation();
        enabled = !enabled;
        cfg.ENABLED = enabled;
        saveCfg();
        applyState();
        return;
      }
      if (ev.target.closest('.cg-step-down')) {
        ev.stopPropagation();
        cfg.KEEP_OPEN = Math.max(1, cfg.KEEP_OPEN - 1);
        saveCfg();
        root.querySelector('.cg-stepper-val').textContent = cfg.KEEP_OPEN;
        dirtyLimit = true;
        return;
      }
      if (ev.target.closest('.cg-step-up')) {
        ev.stopPropagation();
        cfg.KEEP_OPEN = Math.min(50, cfg.KEEP_OPEN + 1);
        saveCfg();
        root.querySelector('.cg-stepper-val').textContent = cfg.KEEP_OPEN;
        dirtyLimit = true;
        return;
      }
      if (ev.target.closest('.cg-pill')) {
        root.classList.toggle('open');
        cfg.PANEL_OPEN = root.classList.contains('open');
        saveCfg();
      }
    });

    document.documentElement.appendChild(root);
    uiRefs = {
      root,
      countEl: root.querySelector('.cg-tab-val'),
      ramEl: root.querySelector('.cg-ram-val'),
    };

    applyState();
  }

  function updateCount() {
    if (uiRefs?.countEl) uiRefs.countEl.textContent = archived.length;
    if (uiRefs?.ramEl) uiRefs.ramEl.textContent = formatBytes(Math.max(0, totalSavedBytes));
  }

  function applyState() {
    if (!uiRefs) return;
    uiRefs.root.setAttribute('data-enabled', String(enabled));
    document.documentElement.classList.toggle('cg-min', enabled);
    if (!enabled) restoreAll();
    updateCount();
  }

  function tick() {
    ensureUI();
    lightenComposer();
    checkNavigation();

    if (enabled) {
      if (typeof requestIdleCallback === 'function') {
        requestIdleCallback(() => archiveOld(), { timeout: 800 });
      } else {
        archiveOld();
      }
    }

    if (!tickPaused) {
      tickTimer = setTimeout(tick, cfg.TICK_MS);
    }
  }

  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      tickPaused = true;
      clearTimeout(tickTimer);
    } else {
      tickPaused = false;
      tick();
    }
  });

  window.addEventListener('pagehide', () => {
    tickPaused = true;
    clearTimeout(tickTimer);
  });

  saveCfg();
  setTimeout(tick, 400);
})();