ChatGPT Export Markdown

Экспорт чатов ChatGPT в Markdown (UTF-8). Надежно извлекает все ссылки, включая скрытые в групповых цитатах (+N). Использование: 1. Выберите сообщения чекбоксами или кнопкой "Select all" (повторное нажатие снимает выделение). 2. Нажмите "Export MD". Скрипт автоматически прокликивает цитаты, собирает ссылки (с очисткой от refs) и форматирует результат в порядке отображения на странице.

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         ChatGPT Export Markdown
// @namespace    hmmml.chatgpt.export.md
// @version      4.9.0
// @description  Экспорт чатов ChatGPT в Markdown (UTF-8). Надежно извлекает все ссылки, включая скрытые в групповых цитатах (+N). Использование: 1. Выберите сообщения чекбоксами или кнопкой "Select all" (повторное нажатие снимает выделение). 2. Нажмите "Export MD". Скрипт автоматически прокликивает цитаты, собирает ссылки (с очисткой от refs) и форматирует результат в порядке отображения на странице.
// @license      MIT
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @run-at       document-idle
// @noframes
// @grant        GM_addStyle
// ==/UserScript==
(function() {
    'use strict';

    /* ========= CONFIGURATION ========= */
    const CONFIG = {
        VERSION: '4.9.0',
        COLD_START_PILLS: 3,
        COLD_START_MULTIPLIER: 1.4,
    };
    /* ========= END CONFIGURATION ========= */

    /* ========= UTILS ========= */
    const $ = (s, r = document) => r.querySelector(s);
    const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
    const visible = el => el instanceof Element && (() => {
        const r = el.getBoundingClientRect();
        const cs = getComputedStyle(el);
        return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none';
    })();
    const pad = n => String(n).padStart(2, '0');
    const ts = () => {
        const d = new Date();
        return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
    };
    const title = () => (document.querySelector('[data-testid="conversation-title"]')?.textContent || document.title || 'chat').replace(/\s+-\s*ChatGPT\s*$/i, '').trim() || 'chat';
    const sanitizeName = n => (n || 'export').replace(/[\/\\?%*:|"<>.]/g, '_').replace(/\s+/g, ' ').trim() || 'export';
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    const REAL_WIN = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || (document.defaultView || window);

    // Strict role detection based on attributes
    function getMessageRole(node) {
        // Check the node itself first
        const roleAttr = node.getAttribute('data-message-author-role');

        if (roleAttr === 'user') return 'user';
        if (roleAttr === 'assistant' || roleAttr === 'system') return 'assistant';

        // Fallback check
        const childRoleEl = node.querySelector('[data-message-author-role]');
        if (childRoleEl) {
                const childRoleAttr = childRoleEl.getAttribute('data-message-author-role');
                if (childRoleAttr === 'user') return 'user';
                if (childRoleAttr === 'assistant' || childRoleAttr === 'system') return 'assistant';
        }
        return 'unknown';
    }


    /* ========= UI (Minimized) ========= */
    const CSS = `
  .mdx-bar{position:fixed;right:16px;bottom:16px;z-index:2147483646;display:flex;gap:8px;align-items:center;flex-wrap:wrap;background:rgba(33,33,33,.92);color:#fff;border:1px solid rgba(127,127,127,.35);padding:10px 12px;border-radius:12px;font:13px system-ui,-apple-system,Segoe UI,Roboto,Ubuntu}
  .mdx-bar[data-disabled="true"] { opacity: 0.6; pointer-events: none; }
  .mdx-bar button{cursor:pointer;border:1px solid rgba(127,127,127,.4);background:rgba(255,255,255,.08);color:#fff;padding:8px 10px;border-radius:10px;font:inherit}
  .mdx-bar button:disabled { cursor: default; opacity: 0.6; }
  .mdx-version{font-size:11px; opacity: 0.7;}
  .mdx-note{position:fixed;right:16px;bottom:64px;z-index:2147483646;background:rgba(0,0,0,.78);color:#fff;padding:6px 8px;border-radius:8px;font:12px system-ui;display:none}
  .mdx-selected{outline:2px solid rgba(0,200,255,.9);outline-offset:2px;border-radius:10px}
  .mdx-checkwrap{position:absolute; right:-36px; top:8px; z-index:2147483645; pointer-events:auto}
  .mdx-checkbox{appearance:auto;width:18px;height:18px;cursor:pointer;border:1px solid #aaa;background:#fff;border-radius:4px;box-shadow:0 0 0 2px rgba(0,0,0,.05)}
  `;
    try {
        if (typeof GM_addStyle === 'function') GM_addStyle(CSS);
    } catch {}
    if (!document.querySelector('style[data-mdx-style]')) {
        const st = document.createElement('style');
        st.setAttribute('data-mdx-style', '1');
        st.textContent = CSS;
        document.head.appendChild(st);
    }

    let bar, bSelectAll, bExport, note;
    let selectAllState = false,
        CANCEL = false;

    // Function to get all message nodes in DOM order
    function messageNodes() {
        return $$('div[data-message-id]');
    }

    // Simplified UI construction
    function ensureUI() {
        if (bar && bar.isConnected) return;
        bar = document.createElement('div');
        bar.className = 'mdx-bar';
        bar.setAttribute('data-mdx', 'ui');

        // Minimized Layout: (vX.X.X) | Select all | Export MD
        bar.innerHTML = `
      <span class="mdx-version">(v${CONFIG.VERSION})</span>
      <button id="mdx-selectall">Select all</button>
      <button id="mdx-export" disabled>Export MD</button>
    `;
        document.body.appendChild(bar);

        bSelectAll = $('#mdx-selectall', bar);
        bExport = $('#mdx-export', bar);

        bSelectAll.addEventListener('click', onSelectAllToggle);
        bExport.addEventListener('click', exportSelected);

        // Note used for displaying progress/status during export
        note=document.createElement('div'); note.className='mdx-note'; note.setAttribute('data-mdx','ui'); document.body.appendChild(note);
    }

    // UI state management (Blocking)
    function setUIState(isEnabled) {
        if (!bar) return;
        bar.setAttribute('data-disabled', !isEnabled);
    }

    function toast(t, duration=2500){ if(!note) return; note.textContent=t; note.style.display='block'; clearTimeout(toast._t); if (duration) toast._t=setTimeout(()=>note.style.display='none',duration); }


    const selected = new Set();


    function ensureCheckbox(host) {
        if (host.querySelector(':scope > .mdx-checkwrap')) return;
        const wrap = document.createElement('div');
        wrap.className = 'mdx-checkwrap';
        wrap.setAttribute('data-mdx', 'ui');
        const cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.className = 'mdx-checkbox';
        cb.addEventListener('click', e => {
            e.stopPropagation();
            toggleMsg(host, cb.checked);
        });
        if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
        wrap.appendChild(cb);
        host.prepend(wrap);
    }

    function toggleMsg(node, on) {
        if (on === undefined) on = !selected.has(node);
        const cb = node.querySelector(':scope > .mdx-checkwrap > .mdx-checkbox');
        if (on) {
            selected.add(node);
            if (cb) cb.checked = true;
        } else {
            selected.delete(node);
            if (cb) cb.checked = false;
        }
        node.classList.toggle('mdx-selected', on);
        // Update button states
        const empty = selected.size === 0;
        if (bExport) bExport.disabled = empty;
    }

    // Strict toggle behavior (All/None)
    function onSelectAllToggle() {
        const nodes = messageNodes();
        if (!nodes.length) return;
        selectAllState = !selectAllState;
        nodes.forEach(n => {
            ensureCheckbox(n);
            toggleMsg(n, selectAllState);
        });
        bSelectAll.textContent = selectAllState ? 'Select all (off)' : 'Select all';
    }

    new MutationObserver(() => messageNodes().forEach(ensureCheckbox)).observe(document.body, {
        childList: true,
        subtree: true
    });

    /* ========= Low-level Events ========= */

    function fireMouseLike(el, type, x, y) {
        const opts = {
            bubbles: true,
            cancelable: true,
            view: REAL_WIN,
            clientX: x,
            clientY: y,
            screenX: x,
            screenY: y,
            button: 0,
            buttons: 0,
            pointerId: 1,
            pointerType: 'mouse',
            isPrimary: true
        };
        try {
            const E = REAL_WIN.PointerEvent || window.PointerEvent;
            if (E) el.dispatchEvent(new E(type.replace('mouse', 'pointer'), opts));
        } catch {}
        try {
            const E = REAL_WIN.MouseEvent || window.MouseEvent;
            if (E) el.dispatchEvent(new E(type.replace('pointer', 'mouse'), opts));
        } catch {}
    }

    /* ========= URL Normalization ========= */

    function normalizeUrl(href) {
        try {
            const u = new URL(href, location.href);
            const trackingParams = [
                'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
                'gclid', 'fbclid', 'msclkid', 'mc_eid', 'srsltid',
                'ref', 'ref_src', '_hsenc', '_hsmi', 'yclid', 'ysclid'
            ];
            for (const k of [...u.searchParams.keys()]) {
                if (trackingParams.includes(k.toLowerCase())) {
                    u.searchParams.delete(k);
                }
            }
            u.hash = '';
            if (u.protocol === 'http:') u.protocol = 'https:';
            return u.origin + u.pathname + (u.search || '');
        } catch {
            return href;
        }
    }

    function uniqueByBase(arr) {
        const seen = new Set(),
            out = [];
        for (const x of arr) {
            const k = normalizeUrl(x);
            if (!seen.has(k)) {
                seen.add(k);
                out.push(k);
            }
        }
        return out;
    }

    /* ========= Markdown Conversion ========= */
    function __mdx_stripPlusLabel(label) {
        const t = (label || '').trim();
        const m = t.match(/^(.*?)(?:\s*\+\d+|\+\d+)?\s*$/);
        return (m && m[1]) ? m[1].trim() : t;
    }

    // Helper function to ensure text is wrapped in brackets
    function ensureBrackets(text) {
        if (!text) return '';
        text = text.trim();
        if (text.startsWith('[') && text.endsWith(']')) {
            return text;
        }
        return `[${text}]`;
    }

    // Modified htmlToMarkdown for formatted inline labels
    function htmlToMarkdown(root) {
        const doc = document.implementation.createHTMLDocument('');
        doc.body.innerHTML = root.innerHTML;

        // Inline-вставка [Label] (url1) (url2)
        doc.querySelectorAll('[data-mdx-inline-links]').forEach(el => {
            let labelRaw = (el.textContent || '').trim();
            let label = __mdx_stripPlusLabel(labelRaw);

            // Apply bracket formatting
            let formattedLabel = ensureBrackets(label);

            let arr = [];
            try {
                arr = JSON.parse(el.getAttribute('data-mdx-inline-links') || '[]');
            } catch {
                arr = [];
            }
            if (Array.isArray(arr) && arr.length >= 1) {
                const urls = uniqueByBase(arr).map(normalizeUrl);
                const span = doc.createElement('span');

                // Construct the replacement text: [Label] (url1) (url2)
                span.textContent = (formattedLabel ? (formattedLabel + ' ') : '') + urls.map(u => `(${u})`).join(' ');
                el.replaceWith(span);
            }
        });

        // Standard conversions
        doc.querySelectorAll('a[href]').forEach(a => a.setAttribute('href', normalizeUrl(a.getAttribute('href'))));
        doc.querySelectorAll('span.katex-html, mrow').forEach(e => e.remove());
        doc.querySelectorAll('annotation[encoding="application/x-tex"]').forEach(el => {
            const latex = el.textContent.trim();
            el.replaceWith(el.closest('.katex-display') ? `\n$$\n${latex}\n$$\n` : `$${latex}$`);
        });
        doc.querySelectorAll('pre').forEach(pre => {
            const codeType = pre.querySelector('div > div:first-child')?.textContent || '';
            const code = pre.querySelector('div > div:nth-child(3) > code, code')?.textContent || pre.textContent;
            pre.innerHTML = `\n\`\`\`${(codeType||'').trim()}\n${code}\n\`\`\`\n`;
        });
        doc.querySelectorAll('strong,b').forEach(n => n.replaceWith(`**${n.textContent}**`));
        doc.querySelectorAll('em,i').forEach(n => n.replaceWith(`*${n.textContent}*`));
        doc.querySelectorAll('p code').forEach(n => n.replaceWith(`\`${n.textContent}\``));
        // Handle standard links - only if they weren't already processed by inline expansion
         doc.querySelectorAll('a').forEach(a => {
             // Check if element is still connected to the DOM (might have been replaced by inline expansion)
             if (a.isConnected && a.textContent && a.getAttribute('href')) {
                 a.replaceWith(`[${a.textContent.trim()}](${a.getAttribute('href')})`);
             }
         });
        doc.querySelectorAll('img').forEach(img => img.replaceWith(`![${img.alt||''}](${img.src})`));
        doc.querySelectorAll('ul').forEach(ul => {
            let md = '';
            ul.querySelectorAll(':scope>li').forEach(li => md += `- ${li.textContent.trim()}\n`);
            ul.replaceWith('\n' + md.trim() + '\n');
        });
        doc.querySelectorAll('ol').forEach(ol => {
            let md = '';
            ol.querySelectorAll(':scope>li').forEach((li, i) => md += `${i+1}. ${li.textContent.trim()}\n`);
            ol.replaceWith('\n' + md.trim() + '\n');
        });
        for (let i = 1; i <= 6; i++) doc.querySelectorAll(`h${i}`).forEach(h => h.replaceWith(`\n${'#'.repeat(i)} ${h.textContent}\n`));
        doc.querySelectorAll('p').forEach(p => p.replaceWith('\n' + p.textContent + '\n'));

        // Final cleanup
        let text = doc.body.innerHTML.replace(/<[^>]*>/g, '');
        text = text.replaceAll(/&amp;/g, '&').replaceAll(/&lt;/g, '<').replaceAll(/&gt;/g, '>');
        // Final normalization pass on generated Markdown links
        text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, l, u) => `[${l}](${normalizeUrl(u)})`);
        return text.trim();
    }

    /* ========= Profiles (Max Speed) ========= */
    // Fixed profile set to Ultra for maximum speed.
    const PROFILE_ULTRA = {
        NAME: 'Ultra (Default)',
        DWELL_MS: 160,
        OPEN_DELAY_MS: 140,
        OVERLAY_TIMEOUT: 2500,
        STEP_MS: 140,
        PAG_TRIES: 8,
        HOVER_JITTER_STEPS: 2
    };
    const getProfile = () => PROFILE_ULTRA;

    /* ========= Overlay detection ========= */
    const isOurUi = el => !!(el && el.closest && el.closest('[data-mdx]'));

    // Detect likely overlay elements, excluding hidden accessibility tooltips
    function overlayLikely(el) {
        if (!el || !el.getBoundingClientRect) return false;
        if (isOurUi(el)) return false;
        // Явные признаки порталов
        if (el.closest('[data-radix-popper-content-wrapper]')) return true;

        // Проверка внутри портала, исключая скрытые тултипы
        if (el.closest('[data-radix-portal]')) {
            if (el.matches && el.matches('[role="tooltip"][id^="radix-"]')) return false;
            return true;
        }

        const role = el.getAttribute('role') || '';
        if (role && /dialog|listbox|menu/i.test(role)) return true;

        // Fallback
        const cs = getComputedStyle(el);
        const highZ = parseInt(cs.zIndex || '0', 10) >= 100;
        if ((cs.position === 'fixed' || cs.position === 'absolute') && highZ) {
            const hasLinks = el.querySelector('a[href], [role="link"]');
            if (hasLinks) return true;
        }
        return false;
    }

    function findOverlayCandidates() {
        const list = [];
        const push = el => {
            if (el && visible(el) && !isOurUi(el) && !list.includes(el)) list.push(el);
        };
        $$('[data-radix-popper-content-wrapper]').forEach(push);
        $$('[data-radix-portal] > *').forEach(push);
        $$('div[role="dialog"]').forEach(push);

        return list.filter(overlayLikely);
    }

    function nearestTo(el, candidates) {
        if (!candidates.length) return null;
        const r = el.getBoundingClientRect();
        const cx = r.left + r.width / 2,
            cy = r.top + r.height / 2;
        let best = null,
            bestd = 1e9;
        for (const c of candidates) {
            const cr = c.getBoundingClientRect();
            const d = Math.hypot((cr.left + cr.width / 2) - cx, (cr.top + cr.height / 2) - cy);
            if (d < bestd) {
                best = c;
                bestd = d;
            }
        }
        return best;
    }

    function waitOverlayAppearNear(target, timeout) {
        return new Promise(resolve => {
            let winner = null,
                done = false;
            const t0 = performance.now();
            const mo = new MutationObserver(() => {
                const cands = findOverlayCandidates();
                const near = nearestTo(target, cands);
                if (near) {
                    winner = near;
                    finish();
                }
            });

            function finish() {
                if (done) return;
                done = true;
                mo.disconnect();
                resolve(winner || null);
            }
            mo.observe(document.body, {
                childList: true,
                subtree: true
            });
            (function loop() {
                if (done) return;
                const cands = findOverlayCandidates();
                const near = nearestTo(target, cands);
                if (near) {
                    winner = near;
                    return finish();
                }
                if (performance.now() - t0 >= timeout) return finish();
                setTimeout(loop, 80);
            })();
        });
    }

    function closeAllOverlays() {
        try {
            document.dispatchEvent(new KeyboardEvent('keydown', {
                key: 'Escape',
                bubbles: true
            }));
        } catch {}
        // "Клик мимо" для сброса hover-состояния
        const host = document.body;
        const r = host.getBoundingClientRect();
        fireMouseLike(host, 'mousemove', r.left + 6, r.top + 6);
    }

    /* ========= Pills & clickables ========= */
    function pillRoot(el) {
        return el.closest('[data-testid="webpage-citation-pill"]') || el;
    }

    function getPlusBadgeNode(pill) {
        const nodes = pill.querySelectorAll('span,div,button');
        for (const n of nodes) {
            const txt = (n.textContent || '').trim();
            if (/^\+\d+$/.test(txt)) return n;
        }
        const whole = (pill.textContent || '').trim();
        if (/\+\d+\s*$/.test(whole)) return pill;
        return null;
    }

    function expectedCountFromPill(pill) {
        const badge = getPlusBadgeNode(pill);
        if (!badge) return 1;
        const m = (badge.textContent || '').match(/\+(\d+)/);
        return m ? 1 + parseInt(m[1], 10) : 1;
    }

    function clickableOf(pill) {
        return pill.querySelector('a[href],button,[role="button"]') || pill;
    }

    // Поиск только групповых плашек (+N).
    function findPills(scope) {
        const set = new Set();

        scope.querySelectorAll('[data-testid="webpage-citation-pill"], [data-testid*="citation"]').forEach(el => {
            const root = pillRoot(el);
            if (visible(root)) set.add(root);
        });

        scope.querySelectorAll('button,[role="button"],a[href]').forEach(el => {
            if (!visible(el)) return;
            const root = pillRoot(el);
            if (visible(root) && getPlusBadgeNode(root)) set.add(root);
        });

        const grouped = Array.from(set).filter(el => expectedCountFromPill(el) > 1);
        grouped.sort((a, b) => expectedCountFromPill(b) - expectedCountFromPill(a));
        return grouped;
    }

    /* ========= Open overlay (Robust Open) ========= */

    // Realistic hover with Dwell Time and Jitter
    async function hoverWithDwell(el, dwellMs, jitterSteps) {
        try {
            el.scrollIntoView({
                block: 'center',
                inline: 'center',
                behavior: 'instant'
            });
        } catch {}
        // Short pause after scroll stabilization
        await sleep(50);

        const r = el.getBoundingClientRect();
        const cx = r.left + r.width * 0.5;
        const cy = r.top + r.height * 0.5;

        // 1. Entry sequence
        fireMouseLike(el, 'pointerover', cx, cy);
        fireMouseLike(el, 'mouseover', cx, cy);
        fireMouseLike(el, 'pointerenter', cx, cy);
        fireMouseLike(el, 'mouseenter', cx, cy);

        // 2. Jitter
        const stepMs = Math.max(50, dwellMs / Math.max(1, jitterSteps));
        for (let i = 0; i < jitterSteps; i++) {
            const dx = (Math.random() - 0.5) * r.width * 0.3;
            const dy = (Math.random() - 0.5) * r.height * 0.3;
            fireMouseLike(el, 'mousemove', cx + dx, cy + dy);
            await sleep(stepMs);
        }

        // 3. Final pause
        const remainingDwell = dwellMs - (jitterSteps * stepMs);
        if (remainingDwell > 0) {
            await sleep(remainingDwell);
        }
    }

    // Enhanced function with Retry Logic (3 strategies)
    async function openOverlayMulti(pill, prof, isCold) {
        closeAllOverlays();
        const clickable = clickableOf(pill);

        let dwellMs = prof.DWELL_MS || 160; // Default to Ultra speed
        if (isCold) {
            dwellMs *= CONFIG.COLD_START_MULTIPLIER;
        }
        const jitterSteps = prof.HOVER_JITTER_STEPS || 2;

        // --- ATTEMPT 1: Hover + Dwell ---
        await hoverWithDwell(clickable, dwellMs, jitterSteps);

        // Check and short wait
        let overlay = await waitOverlayAppearNear(clickable, 500);

        if (overlay) {
            return overlay;
        }

        // --- ATTEMPT 2: Guarded Click ---
        const guard = e => {
            const a = e.target?.closest?.('a[href]');
            if (!a) return;
            e.preventDefault();
            e.stopImmediatePropagation();
        };
        document.addEventListener('click', guard, true);
        try {
            clickable.dispatchEvent(new MouseEvent('click', {
                bubbles: true,
                cancelable: true,
                view: REAL_WIN
            }));
        } catch {}

        // Wait after click
        overlay = await waitOverlayAppearNear(clickable, (prof.OPEN_DELAY_MS || 140) * 2);
        document.removeEventListener('click', guard, true);

        if (overlay) {
            return overlay;
        }

        // --- ATTEMPT 3: Re-Hover (Increased time) ---
        const retryDwellMs = dwellMs * 1.5; // Increased time for retry
        await hoverWithDwell(clickable, retryDwellMs, jitterSteps + 1);

        // Final wait
        overlay = await waitOverlayAppearNear(clickable, (prof.OVERLAY_TIMEOUT || 2500) / 2);

        if (overlay) {
            return overlay;
        }

        return null;
    }

    /* ========= Link collection & pagination ========= */

    // Continuous Keep-Alive
    function keepAliveOverlay(overlay) {
        if (!overlay || !visible(overlay)) return () => {};

        const r = overlay.getBoundingClientRect();
        const cx = r.left + r.width * 0.5;
        const cy = r.top + r.height * 0.5;

        // Initial stabilization
        fireMouseLike(overlay, 'pointerenter', cx, cy);
        fireMouseLike(overlay, 'mousemove', cx, cy);

        const ev = () => {
            if (!overlay.isConnected || !visible(overlay)) {
                stop();
                return;
            }
            const r_live = overlay.getBoundingClientRect();
            const x_live = r_live.left + r_live.width * (0.4 + Math.random() * 0.2);
            const y_live = r_live.top + r_live.height * (0.4 + Math.random() * 0.2);
            fireMouseLike(overlay, 'mousemove', x_live, y_live);
        };

        const intervalId = setInterval(ev, 140);
        const stop = () => clearInterval(intervalId);
        return stop;
    }

    function getPagerInfo(overlay) {
        const label = Array.from(overlay.querySelectorAll('span,div')).find(s => /^\s*\d+\s*\/\s*\d+\s*$/.test((s.textContent || '')));
        let cur = 1,
            total = 1;
        if (label) {
            const m = (label.textContent || '').match(/(\d+)\s*\/\s*(\d+)/);
            if (m) {
                cur = +m[1];
                total = +m[2];
            }
        }
        return {
            cur,
            total,
            label
        };
    }

    // Get navigation buttons, excluding hidden accessibility elements
    function getPrevNextButtons(overlay) {
        const hiddenAccessibilitySelector = '[role="tooltip"][id^="radix-"], [aria-hidden="true"]';
        const allBtns = Array.from(overlay.querySelectorAll('button,[role="button"]'));

        const interactiveBtns = allBtns.filter(btn => {
            if (btn.closest(hiddenAccessibilitySelector)) {
                return false;
            }
            return visible(btn);
        });

        const btns = interactiveBtns;
        const txt = b => (b.getAttribute('aria-label') || b.textContent || '').toLowerCase();

        let prev = btns.find(b => /(prev|previous|назад|<|<)/i.test(txt(b)));
        let next = btns.find(b => /(next|следующ|>|>)/i.test(txt(b)));

        const svgButtons = btns.filter(b => b.querySelector('svg') && (b.textContent || '').trim() === '');
        if (svgButtons.length > 0) {
            if (!prev) prev = svgButtons[0];
            if (!next) next = svgButtons[svgButtons.length - 1];
        }

        return {
            prev: prev || null,
            next: next || null
        };
    }

    function focusAny(overlay) {
        const cand = overlay.querySelector('button,[role="button"],a[href],[tabindex]');
        if (cand && cand.focus) try {
            cand.focus();
        } catch {}
        else try {
            overlay.focus();
        } catch {}
    }

    // Reliable Navigation: Button Click OR Keyboard
    function clickNav(overlay, direction) {
        const {
            prev,
            next
        } = getPrevNextButtons(overlay);
        const btn = direction === 'prev' ? prev : next;
        const keyName = direction === 'prev' ? 'ArrowLeft' : 'ArrowRight';

        // Attempt 1: Button Click
        if (btn) {
            try {
                btn.dispatchEvent(new MouseEvent('click', {
                    bubbles: true,
                    cancelable: true,
                    view: REAL_WIN
                }));
                return true;
            } catch (e) {}
        }

        // Attempt 2: Keyboard
        try {
            focusAny(overlay);
            overlay.dispatchEvent(new KeyboardEvent('keydown', {
                key: keyName,
                code: keyName,
                bubbles: true
            }));
            return true;
        } catch (e) {}

        return false;
    }

    function collectOverlayLinksSimple(overlay) {
        const out = new Set();
        overlay.querySelectorAll('a[href]').forEach(a => out.add(a.getAttribute('href')));
        overlay.querySelectorAll('[data-url],[data-href]').forEach(el => {
            const v = el.getAttribute('data-url') || el.getAttribute('data-href');
            if (v) out.add(v);
        });
        return uniqueByBase([...out]).map(normalizeUrl);
    }

    async function gatherLinksFromCurrentSlide(overlay) {
        let urls = collectOverlayLinksSimple(overlay);
        return urls;
    }

    // Strategy "Full Sweep"
    async function paginateOverlayAll(overlay, prof, targetCount) {
        const all = new Set();
        let {
            cur,
            total
        } = getPagerInfo(overlay);

        const stopKeepAlive = keepAliveOverlay(overlay);

        try {
            // 1. Rewind to start (1/N)
            let guard = 0;
            while (total > 1 && cur > 1 && guard++ < Math.max(10, total + 2) && !CANCEL) {
                if (!clickNav(overlay, 'prev')) break;
                await sleep((prof.STEP_MS) || 140);
                const p = getPagerInfo(overlay);
                if (p.cur === cur) {
                    break;
                }
                cur = p.cur;
                total = p.total;
            }

            // 2. Sweep forward (1/N -> N/N)
            guard = 0;
            const maxTries = Math.max(prof.PAG_TRIES || 8, total || 1);
            while (guard++ < maxTries && !CANCEL) {
                // Collect links from current slide
                const urls = await gatherLinksFromCurrentSlide(overlay);
                urls.forEach(u => all.add(u));

                if (all.size >= (targetCount || 0)) break;

                const p = getPagerInfo(overlay);
                cur = p.cur;
                total = p.total;
                if (total <= 1 || cur >= total) break;

                // Navigate forward
                if (!clickNav(overlay, 'next')) break;
                await sleep((prof.STEP_MS) || 140);

                const p2 = getPagerInfo(overlay);
                if (p2.cur === cur) {
                    break;
                }
                cur = p2.cur;
                total = p2.total;
            }
        } catch (e) {
            console.error("Pagination Error:", e);
        }
        finally {
            stopKeepAlive();
        }

        return uniqueByBase([...all]);
    }

    /* ========= Per message ========= */
    function collectDomLinks(node) {
        const set = new Set();
        node.querySelectorAll('a[href]').forEach(a => set.add(a.getAttribute('href')));
        return uniqueByBase([...set]).map(normalizeUrl);
    }

    // This function focuses on identifying the content node.
    function gatherMessageContent(node) {
        // Prioritize .markdown, then .whitespace-pre-wrap (typical for user prompts), fallback to the node itself.
        const content = node.querySelector(':scope .markdown, :scope .whitespace-pre-wrap') || node;
        return content;
    }

    const processedPills = new WeakSet();

    async function harvestAllPillsInMessage(node, prof, context) {
        const pills = findPills(node);
        const links = new Set();

        for (const pill of pills) {
            if (CANCEL) break;

            if (processedPills.has(pill)) continue;
            processedPills.add(pill);

            const need = expectedCountFromPill(pill);

            // Cold Start Management
            context.pillsProcessedCount++;
            const isColdStart = context.pillsProcessedCount <= CONFIG.COLD_START_PILLS;

            let linksFromThisPill = [];
            try {
                // Open overlay (robust version with retries)
                const overlay = await openOverlayMulti(pill, prof, isColdStart);
                if (!overlay) {
                    continue;
                }

                // Paginate and collect (Full Sweep)
                linksFromThisPill = await paginateOverlayAll(overlay, prof, need);
                linksFromThisPill.forEach(h => links.add(h));

                // Prepare for inline expansion
                try {
                    const both = uniqueByBase(linksFromThisPill || []).map(normalizeUrl);
                    if (both.length >= 1) {
                        (clickableOf(pill)).setAttribute('data-mdx-inline-links', JSON.stringify(both));
                    }
                } catch (e) {
                     console.error("Inline Prep Error:", e);
                }

                // Close overlay
                closeAllOverlays();
                await sleep(80);
            } catch (e) {
                 console.error("Pill Processing Error:", e);
            }
            await sleep(70);
        }
        return uniqueByBase([...links]);
    }

    /* ========= Export ========= */
    function download(text, filename, type = "text/markdown;charset=utf-8") {
        const blob = new Blob([text], {
            type
        });
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        setTimeout(() => {
            URL.revokeObjectURL(a.href);
            a.remove();
        }, 0);
    }

    // Modified exportSelected to respect DOM order
    async function exportSelected() {
        if (selected.size === 0) return;

        // Determine the export order based on DOM structure, not selection order
        const allNodesInOrder = messageNodes();
        const nodesToExport = allNodesInOrder.filter(node => selected.has(node));
        const total = nodesToExport.length;

        // Initialize export state and block UI
        CANCEL = false;
        setUIState(false);
        toast(`Exporting ${total} messages...`, null); // Show persistent toast

        const prof = getProfile(); // Gets 'ultra' profile
        const name = sanitizeName(title()),
            stamp = ts();

        const blocks = [];
        let totalLinks = 0;

        // Context for cold start and numbering
        const context = {
            pillsProcessedCount: 0,
            userMsgCount: 0,
            assistantMsgCount: 0,
            unknownMsgCount: 0
        };

        try {
            // Iterate over the DOM-ordered list
            for (let i = 0; i < total; i++) {
                if (CANCEL) break;
                const node = nodesToExport[i];
                const role = getMessageRole(node);

                const contentNode = gatherMessageContent(node);

                toast(`Processing ${i+1}/${total} (Links: ${totalLinks})`, null);

                // 1. Collect DOM links
                const domLinks = collectDomLinks(contentNode);

                // 2. Collect links from grouped pills (+N).
                const overlayLinks = await harvestAllPillsInMessage(node, prof, context);

                // 3. Combine links
                const hrefs = uniqueByBase([...domLinks, ...overlayLinks]).map(normalizeUrl);
                totalLinks += hrefs.length;

                // 4. Convert to Markdown
                const md = htmlToMarkdown(contentNode.cloneNode(true));

                // Construct numbered Links section
                const refs = hrefs.length ? '\n**Links:**\n' + hrefs.map((u, index) => `${index + 1}. ${u}`).join('\n') + '\n' : '';

                // Generate Header with Numbering
                let headerLabel;
                if (role === 'user') {
                    context.userMsgCount++;
                    headerLabel = `#User_question (${context.userMsgCount})`;
                } else if (role === 'assistant') {
                    context.assistantMsgCount++;
                    headerLabel = `#GPT_answer (${context.assistantMsgCount})`;
                } else {
                    context.unknownMsgCount++;
                    headerLabel = `#Unknown (${context.unknownMsgCount})`;
                }

                blocks.push(`${headerLabel}:\n${md}\n${refs}`);
            }

            const mdFilename = `${name}_selected_${stamp}.md`;
            if (!CANCEL && blocks.length > 0) {
                download(blocks.join('\n\n'), mdFilename, 'text/markdown;charset=utf-8');
            }

            toast(CANCEL ? 'Export Cancelled' : 'Export Complete');

        } catch (e) {
            console.error("Export Error:", e);
            toast('Error during export');
        } finally {
            // Reset UI state
            setUIState(true);
        }
    }

    /* ========= Boot ========= */
    function boot() {
        ensureUI();
        messageNodes().forEach(ensureCheckbox);
    }
    if (document.readyState === 'complete' || document.readyState === 'interactive') boot();
    else document.addEventListener('DOMContentLoaded', boot, {
        once: true
    });
    setInterval(() => ensureUI(), 2000);
})();