Gemini DumpChat

Export Gemini chat history to TXT/JSON with precise element injection.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         Gemini DumpChat
// @namespace    https://github.com/miniyu157/gemini-dumpchat
// @version      1.0.0
// @description  Export Gemini chat history to TXT/JSON with precise element injection.
// @author       Yumeka
// @license      MIT
// @match        https://gemini.google.com/*
// @grant        GM_addStyle
// @run-at       document-end
// ==/UserScript==

/**
 * ----------------------------------------------------------------------------
 * "THE BEER-WARE LICENSE" (Revision 42):
 * Yumeka <[email protected]> wrote this file. As long as you retain this notice you
 * can do whatever you want with this stuff. If we meet some day, and you think
 * this stuff is worth it, you can buy me a beer in return. Yumeka
 * ----------------------------------------------------------------------------
 * * [ Attribution ]
 * This script includes core scrolling logic derived from:
 * "gemini_chat_export" by Sxuan-Coder (https://github.com/Sxuan-Coder/gemini_chat_export)
 * Licensed under Apache License 2.0.
 * ----------------------------------------------------------------------------
 */

(function () {
  "use strict";

  // --- Configuration ---
  const META = {
    repo: "https://github.com/miniyu157/gemini-dumpchat",
    licenseUrl:
      "https://github.com/miniyu157/gemini-dumpchat/blob/main/LICENSE",
    newlineToken: " <\\n> ",
  };

  const SELECTORS = {
    disclaimer: 'p[data-test-id="disclaimer"]',
    userQuery: "user-query .query-text",
    modelResponse: "model-response",
    responseParts: ".model-response-text, .markdown",
  };

  // --- Elegant Styles (Dark/Glass) ---
  GM_addStyle(`
        :root {
            --dump-bg: rgba(20, 20, 20, 0.9);
            --dump-border: rgba(255, 255, 255, 0.15);
            --dump-text: #e3e3e3;
            --dump-accent: #a8c7fa;
        }
        .dump-link {
            margin-left: 12px; cursor: pointer; text-decoration: none;
            color: inherit; opacity: 0.6; font-size: 0.9em; vertical-align: baseline;
            transition: opacity 0.2s;
        }
        .dump-link:hover { opacity: 1; }
        
        .dump-menu {
            position: fixed; z-index: 10000;
            background: var(--dump-bg); border: 1px solid var(--dump-border);
            border-radius: 6px; padding: 4px; display: none; flex-direction: column;
            box-shadow: 0 4px 12px rgba(0,0,0,0.5); backdrop-filter: blur(8px);
            font-family: 'JetBrains Mono', Consolas, monospace; min-width: 100px;
        }
        .dump-menu.active { display: flex; animation: dumpFadeIn 0.15s ease-out; }
        
        .dump-item {
            padding: 8px 12px; cursor: pointer; color: var(--dump-text);
            font-size: 12px; text-align: left; border-radius: 4px;
            transition: background 0.1s; user-select: none;
        }
        .dump-item:hover { background: rgba(255,255,255,0.1); }
        .dump-item.meta { color: #888; border-top: 1px solid rgba(255,255,255,0.1); margin-top: 4px; }
        
        .dump-status {
            position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
            background: #1a1a1a; color: var(--dump-accent);
            border: 1px solid var(--dump-border); padding: 8px 16px;
            border-radius: 20px; font-size: 12px; font-family: monospace;
            pointer-events: none; opacity: 0; transition: 0.3s; z-index: 10001;
        }
        .dump-status.show { opacity: 1; top: 30px; }
        
        @keyframes dumpFadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
    `);

  // --- Core Logic: State & Processing ---
  const State = {
    isProcessing: false,
    collected: new Map(),
    menuRef: null,
  };

  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  function showStatus(msg) {
    let el = document.querySelector(".dump-status");
    if (!el) {
      el = document.createElement("div");
      el.className = "dump-status";
      document.body.appendChild(el);
    }
    el.textContent = msg;
    el.classList.add("show");
    clearTimeout(el.timer);
    el.timer = setTimeout(() => el.classList.remove("show"), 3000);
  }

  function cleanTextForTxt(text) {
    if (!text) return "";
    return text.trim().replace(/\n/g, META.newlineToken);
  }

  function parseVisibleNodes() {
    const containers = document.querySelectorAll(".conversation-container");
    containers.forEach((container, idx) => {
      const userNode = container.querySelector(SELECTORS.userQuery);
      if (userNode) {
        const text = userNode.textContent;
        const key = `user|${text.substring(0, 32)}|${text.length}`;
        if (!State.collected.has(key)) {
          State.collected.set(key, {
            role: "User",
            content: text,
            order: idx * 2,
          });
        }
      }

      const modelNode = container.querySelector(SELECTORS.modelResponse);
      if (modelNode) {
        const parts = modelNode.querySelectorAll(SELECTORS.responseParts);
        let fullText = Array.from(parts)
          .map((p) => p.textContent)
          .join("\n");
        if (fullText.trim()) {
          const key = `gemini|${fullText.substring(0, 32)}|${fullText.length}`;
          if (!State.collected.has(key)) {
            State.collected.set(key, {
              role: "Gemini",
              content: fullText.trim(),
              order: idx * 2 + 1,
            });
          }
        }
      }
    });
  }

  async function executeDump(format) {
    if (State.isProcessing) return;
    State.isProcessing = true;
    State.collected.clear();
    showStatus("Scrolling & Capturing...");

    window.scrollTo(0, 0);
    await sleep(600);

    let lastHeight = 0,
      noChange = 0;

    while (true) {
      parseVisibleNodes();
      window.scrollBy(0, window.innerHeight * 0.85);
      await sleep(600);

      let h = document.body.scrollHeight;
      if (h === lastHeight) {
        if (++noChange >= 3) break;
      } else {
        noChange = 0;
        lastHeight = h;
      }
    }

    parseVisibleNodes();

    const rawData = Array.from(State.collected.values());

    const titleEl = document.querySelector(".conversation-title, h1");
    const title = (titleEl ? titleEl.textContent : "Gemini_Dump")
      .trim()
      .replace(/[\\/:*?"<>|]/g, "_")
      .substring(0, 50);
    const date = new Date().toISOString().slice(0, 10);
    const filename = `${title}_${date}`;

    if (format === "json") {
      const jsonStr = JSON.stringify(
        rawData.map((d) => ({
          role: d.role,
          content: d.content,
        })),
        null,
        2
      );
      download(jsonStr, "application/json", `${filename}.json`);
    } else {
      const txtStr = rawData
        .map((d) => `[${d.role}] ${cleanTextForTxt(d.content)}`)
        .join("\n");
      download(txtStr, "text/plain", `${filename}.txt`);
    }

    showStatus(`Done. Captured ${rawData.length} messages.`);
    State.isProcessing = false;
  }

  function download(content, mime, name) {
    const blob = new Blob([content], { type: mime });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = name;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  // --- UI Construction ---
  function createMenu() {
    const menu = document.createElement("div");
    menu.className = "dump-menu";

    const addOption = (text, action, isMeta = false) => {
      const item = document.createElement("div");
      item.className = "dump-item" + (isMeta ? " meta" : "");
      item.textContent = text;
      item.onclick = (e) => {
        e.stopPropagation();
        menu.classList.remove("active");
        action();
      };
      menu.appendChild(item);
    };

    addOption("Export as TXT", () => executeDump("txt"));
    addOption("Export as JSON", () => executeDump("json"));
    addOption("[LICENSE]", () => window.open(META.licenseUrl, "_blank"), true);
    addOption("[Github]", () => window.open(META.repo, "_blank"), true);

    document.body.appendChild(menu);
    return menu;
  }

  // --- Injection ---
  function inject() {
    const disclaimer = document.querySelector(SELECTORS.disclaimer);
    if (!disclaimer || disclaimer.querySelector("#y-dump-link")) return;

    if (!State.menuRef) State.menuRef = createMenu();

    const link = document.createElement("a");
    link.id = "y-dump-link";
    link.className = "dump-link";
    link.textContent = "DumpChat";

    link.onclick = (e) => {
      e.preventDefault();
      e.stopPropagation();

      const rect = link.getBoundingClientRect();
      const menu = State.menuRef;

      if (menu.classList.contains("active")) {
        menu.classList.remove("active");
      } else {
        menu.style.bottom = window.innerHeight - rect.top + 10 + "px";
        menu.style.left = rect.left - 20 + "px";
        menu.classList.add("active");
      }
    };

    disclaimer.appendChild(link);
  }

  document.addEventListener("click", (e) => {
    if (
      State.menuRef &&
      State.menuRef.classList.contains("active") &&
      !e.target.closest(".dump-menu")
    ) {
      State.menuRef.classList.remove("active");
    }
  });

  const observer = new MutationObserver((mutations) => {
    if (!document.getElementById("y-dump-link")) inject();
  });

  observer.observe(document.body, { childList: true, subtree: true });

  inject();
})();