Spaces:
Running
Running
| // ============================================================================= | |
| // Chat Bird's-Eye — Universal Chat Extractor (bookmarklet source) | |
| // | |
| // Click this bookmarklet on any AI chat page. It detects the site, scrapes the | |
| // currently-visible conversation(s), accumulates them in localStorage, and | |
| // offers a one-click export to birdseye_chats.json — ready to load into the | |
| // Chat Bird's-Eye workspace. | |
| // | |
| // Supported sites (auto-detected by hostname): | |
| // chatgpt.com / chat.openai.com → ChatGPT | |
| // claude.ai → Claude | |
| // gemini.google.com → Gemini | |
| // perplexity.ai → Perplexity | |
| // x.com (grok) → Grok | |
| // copilot.microsoft.com → Copilot | |
| // * → Generic fallback (best-effort DOM scrape) | |
| // | |
| // This file is the readable source. The actual bookmarklet is the minified | |
| // single-line version produced by build-bookmarklet.js and embedded in | |
| // index.html's "Get real chats" modal. | |
| // ============================================================================= | |
| (function () { | |
| "use strict"; | |
| if (window.__BIRDS_EYE_OPEN) { return; } // prevent double-injection | |
| window.__BIRDS_EYE_OPEN = true; | |
| var STORE_KEY = "birdseye_chats_v1"; | |
| var host = location.hostname.replace(/^www\./, ""); | |
| var site = | |
| /chatgpt\.com|chat\.openai\.com/.test(host) ? "ChatGPT" : | |
| /claude\.ai/.test(host) ? "Claude" : | |
| /gemini\.google\.com/.test(host) ? "Gemini" : | |
| /perplexity\.ai/.test(host) ? "Perplexity" : | |
| /x\.com/.test(host) && /grok/i.test(location.pathname) ? "Grok" : | |
| /copilot\.microsoft\.com/.test(host) ? "Copilot" : | |
| "Unknown"; | |
| // ---- helpers ---- | |
| function txt(el) { return (el.innerText || el.textContent || "").trim(); } | |
| function nowISO() { return new Date().toISOString(); } | |
| function uid() { return "ext_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } | |
| function loadStore() { | |
| try { return JSON.parse(localStorage.getItem(STORE_KEY) || "[]"); } | |
| catch (e) { return []; } | |
| } | |
| function saveStore(arr) { | |
| try { localStorage.setItem(STORE_KEY, JSON.stringify(arr)); } catch (e) {} | |
| } | |
| // ---- per-site scrapers. Each returns { title, messages: [{role, content, ts}] } or null ---- | |
| function scrapeChatGPT() { | |
| // visible conversation turns | |
| var turns = document.querySelectorAll('[data-testid^="conversation-turn-"]'); | |
| if (!turns.length) { | |
| // fallback: older DOM | |
| turns = document.querySelectorAll('article[data-testid]'); | |
| } | |
| var msgs = []; | |
| for (var i = 0; i < turns.length; i++) { | |
| var t = turns[i]; | |
| var role = /-2\b|-4\b|user/i.test(t.getAttribute("data-testid") || "") ? "user" : "assistant"; | |
| // try to find the actual text container | |
| var content = txt(t); | |
| if (content) msgs.push({ role: role, content: content, ts: nowISO() }); | |
| } | |
| if (!msgs.length) return null; | |
| var title = document.title.replace(/^ChatGPT\s*[:\-]?\s*/i, "").trim() || "Untitled ChatGPT"; | |
| return { title: title, messages: msgs }; | |
| } | |
| function scrapeClaude() { | |
| var userMsgs = document.querySelectorAll('[data-testid="user-message"]'); | |
| var allMsgs = []; | |
| // Claude interleaves user + assistant in the main thread | |
| var thread = document.querySelector('[class*="conversation"]') || document.querySelector("main"); | |
| if (!thread) return null; | |
| var blocks = thread.querySelectorAll('[data-testid="user-message"], [class*="assistant"]'); | |
| if (blocks.length) { | |
| for (var i = 0; i < blocks.length; i++) { | |
| var b = blocks[i]; | |
| var role = b.getAttribute("data-testid") === "user-message" ? "user" : "assistant"; | |
| var content = txt(b); | |
| if (content) allMsgs.push({ role: role, content: content, ts: nowISO() }); | |
| } | |
| } | |
| // fallback: just user messages | |
| if (!allMsgs.length && userMsgs.length) { | |
| for (var j = 0; j < userMsgs.length; j++) { | |
| var c = txt(userMsgs[j]); | |
| if (c) allMsgs.push({ role: "user", content: c, ts: nowISO() }); | |
| } | |
| } | |
| if (!allMsgs.length) return null; | |
| var title = document.title.replace(/^Claude\s*[:\-]?\s*/i, "").trim() || "Untitled Claude"; | |
| return { title: title, messages: allMsgs }; | |
| } | |
| function scrapeGemini() { | |
| // Gemini renders turns in a scrollable conversation; try broad selectors | |
| var turns = document.querySelectorAll("message-content, .response-container, .model-response-text, .user-query, .query-text"); | |
| var msgs = []; | |
| for (var i = 0; i < turns.length; i++) { | |
| var el = turns[i]; | |
| var cls = (el.className || "") + " " + (el.tagName || ""); | |
| var role = /user|query/i.test(cls) ? "user" : "assistant"; | |
| var content = txt(el); | |
| if (content) msgs.push({ role: role, content: content, ts: nowISO() }); | |
| } | |
| if (!msgs.length) return null; | |
| var title = document.title.replace(/^Gemini\s*[:\-]?\s*/i, "").trim() || "Untitled Gemini"; | |
| return { title: title, messages: msgs }; | |
| } | |
| function scrapePerplexity() { | |
| var queries = document.querySelectorAll('[class*="query"], textarea'); | |
| var responses = document.querySelectorAll('[class*="prose"], [class*="answer"]'); | |
| var msgs = []; | |
| // approximate: interleave by DOM order | |
| var all = document.querySelectorAll('[class*="query"], [class*="prose"]'); | |
| for (var i = 0; i < all.length; i++) { | |
| var el = all[i]; | |
| var role = /query/i.test(el.className || "") ? "user" : "assistant"; | |
| var content = txt(el); | |
| if (content) msgs.push({ role: role, content: content, ts: nowISO() }); | |
| } | |
| if (!msgs.length) return null; | |
| var title = document.title.replace(/^Perplexity\s*[:\-]?\s*/i, "").trim() || "Untitled Perplexity"; | |
| return { title: title, messages: msgs }; | |
| } | |
| function scrapeGeneric() { | |
| // best-effort: find the largest text container and split by headings/paragraphs | |
| var main = document.querySelector("main") || document.body; | |
| var paras = main.querySelectorAll("p, pre, h1, h2, h3"); | |
| var msgs = []; | |
| var buf = ""; | |
| for (var i = 0; i < paras.length; i++) { | |
| var t = txt(paras[i]); | |
| if (t.length > 10) buf += t + "\n\n"; | |
| } | |
| if (buf.length > 50) msgs.push({ role: "user", content: buf.trim(), ts: nowISO() }); | |
| if (!msgs.length) return null; | |
| return { title: document.title.trim() || "Untitled", messages: msgs }; | |
| } | |
| var scrapers = { | |
| "ChatGPT": scrapeChatGPT, | |
| "Claude": scrapeClaude, | |
| "Gemini": scrapeGemini, | |
| "Perplexity": scrapePerplexity, | |
| "Grok": scrapeGeneric, | |
| "Copilot": scrapeGeneric, | |
| "Unknown": scrapeGeneric | |
| }; | |
| var result = (scrapers[site] || scrapeGeneric)(); | |
| if (!result) { | |
| toast("Couldn't find a chat on this page (" + site + "). Open a conversation and try again.", "#f87171"); | |
| window.__BIRDS_EYE_OPEN = false; | |
| return; | |
| } | |
| // dedupe by title+first-message hash | |
| var store = loadStore(); | |
| var dedupeKey = result.title + "|" + (result.messages[0] || {}).content.slice(0, 80); | |
| var exists = store.some(function (c) { return c._dedupe === dedupeKey; }); | |
| if (!exists) { | |
| store.push({ | |
| id: uid(), | |
| source: site, | |
| title: result.title, | |
| createdAt: nowISO(), | |
| updatedAt: nowISO(), | |
| messages: result.messages, | |
| _dedupe: dedupeKey | |
| }); | |
| saveStore(store); | |
| } | |
| var count = store.length; | |
| toast( | |
| (exists ? "Already had that one. " : "Captured from " + site + ". ") + | |
| "Total: " + count + " chat" + (count === 1 ? "" : "s") + " stored. " + | |
| "Click here to export →", | |
| exists ? "#fbbf24" : "#4ade80", | |
| function () { exportJSON(store); } | |
| ); | |
| // ---- toast UI ---- | |
| function toast(msg, color, onClick) { | |
| var old = document.getElementById("__birdseye_toast"); | |
| if (old) old.remove(); | |
| var t = document.createElement("div"); | |
| t.id = "__birdseye_toast"; | |
| t.style.cssText = [ | |
| "position:fixed", "bottom:20px", "right:20px", "z-index:2147483647", | |
| "background:#1a1a24", "color:#e8e8f0", "padding:14px 18px", | |
| "border-radius:12px", "font:14px/1.5 -apple-system,sans-serif", | |
| "max-width:380px", "box-shadow:0 8px 30px rgba(0,0,0,.5)", | |
| "border:1px solid " + (color || "#7c83ff"), "cursor:pointer", | |
| "transition:opacity .3s" | |
| ].join(";") + ";"; | |
| t.textContent = msg; | |
| t.onclick = function () { if (onClick) onClick(); }; | |
| document.body.appendChild(t); | |
| setTimeout(function () { t.style.opacity = "0"; setTimeout(function () { t.remove(); }, 400); }, 8000); | |
| } | |
| function exportJSON(store) { | |
| // strip internal _dedupe keys | |
| var clean = store.map(function (c) { | |
| return { id: c.id, source: c.source, title: c.title, createdAt: c.createdAt, updatedAt: c.updatedAt, messages: c.messages }; | |
| }); | |
| var blob = new Blob([JSON.stringify(clean, null, 2)], { type: "application/json" }); | |
| var url = URL.createObjectURL(blob); | |
| var a = document.createElement("a"); | |
| a.href = url; a.download = "birdseye_chats.json"; | |
| document.body.appendChild(a); a.click(); a.remove(); | |
| URL.revokeObjectURL(url); | |
| // clear store after export so user can start fresh | |
| if (confirm("Exported " + clean.length + " chats. Clear the bookmarklet's stored chats?")) { | |
| localStorage.removeItem(STORE_KEY); | |
| } | |
| } | |
| window.__BIRDS_EYE_OPEN = false; | |
| })(); | |