Perplexity Code Blocks Archiver

Download code as zip

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Perplexity Code Blocks Archiver
// @namespace    http://tampermonkey.net/
// @version      1.4.88
// @description  Download code as zip
// @author       Karasiq
// @match        https://www.perplexity.ai/*
// @grant        none
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// ==/UserScript==

(function() {
    'use strict';

    // Selectors for current markup
    const RESPONSE_SELECTOR = 'div[class*="pb-md"][class*="mx-auto"][class*="pt-5"][class*="border-b"]';
    const CONTENT_SELECTOR = 'div[id^="markdown-content-"]';
    const CODE_BLOCK_SELECTOR = 'pre.not-prose code';
    const LANGUAGE_INDICATOR_SELECTOR = '[data-testid="code-language-indicator"]';

    // Collapse settings
    const COLLAPSE_THRESHOLD = 10;

    // Maximum compression settings for JSZip
    const MAX_COMPRESSION_OPTIONS = {
        type: 'blob',
        compression: 'DEFLATE',
        compressionOptions: {
            level: 9,
            chunkSize: 1024,
            windowBits: 15,
            memLevel: 9,
            strategy: 0
        },
        streamFiles: false,
        platform: 'UNIX'
    };

    // Extended language mapping to extensions
    const langExtensions = {
        python: 'py', javascript: 'js', typescript: 'ts', java: 'java',
        scala: 'scala', kotlin: 'kt', cpp: 'cpp', c: 'c', 'c++': 'cpp',
        csharp: 'cs', 'c#': 'cs', go: 'go', rust: 'rs', php: 'php',
        ruby: 'rb', swift: 'swift', bash: 'sh', shell: 'sh', zsh: 'sh',
        fish: 'fish', powershell: 'ps1', pwsh: 'ps1', 'power-shell': 'ps1',
        batch: 'bat', cmd: 'bat', html: 'html', css: 'css', scss: 'scss',
        sass: 'sass', less: 'less', json: 'json', yaml: 'yml', yml: 'yml',
        toml: 'toml', ini: 'ini', xml: 'xml', sql: 'sql', mysql: 'sql',
        postgresql: 'sql', sqlite: 'sql', markdown: 'md', tex: 'tex',
        latex: 'tex', haskell: 'hs', erlang: 'erl', elixir: 'ex',
        clojure: 'clj', lisp: 'lisp', scheme: 'scm', dockerfile: 'dockerfile',
        makefile: 'mk', cmake: 'cmake', text: 'txt', plain: 'txt', txt: 'txt'
    };

    // Add CSS styles
    function addStyles() {
        const style = document.createElement('style');
        style.textContent = `
            .code-collapse-wrapper {
                position: relative;
            }
            
            .code-collapse-btn {
                position: absolute;
                top: 8px;
                right: 8px;
                background: rgba(0, 0, 0, 0.7);
                color: white;
                border: none;
                border-radius: 4px;
                padding: 4px 8px;
                font-size: 12px;
                cursor: pointer;
                z-index: 100;
                transition: all 0.2s ease;
                font-family: monospace;
            }
            
            .code-collapse-btn:hover {
                background: rgba(0, 0, 0, 0.9);
            }
            
            .code-collapsed {
                max-height: 200px;
                overflow: hidden;
                position: relative;
            }
            
            .code-collapsed::after {
                content: '';
                position: absolute;
                bottom: 0;
                left: 0;
                right: 0;
                height: 50px;
                background: linear-gradient(transparent, rgba(255, 255, 255, 0.9));
                pointer-events: none;
            }
            
            .code-collapsed.dark::after {
                background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
            }
            
            .compression-info {
                position: fixed;
                top: 10px;
                left: 50%;
                transform: translateX(-50%);
                background: rgba(0, 0, 0, 0.8);
                color: white;
                padding: 8px 16px;
                border-radius: 6px;
                font-size: 12px;
                z-index: 10000;
                transition: opacity 0.3s ease;
                font-family: monospace;
            }
        `;
        document.head.appendChild(style);
    }

    // Show compression ratio indicator
    function showCompressionInfo(originalSize, compressedSize) {
        const ratio = ((1 - compressedSize / originalSize) * 100).toFixed(1);
        const info = document.createElement('div');
        info.className = 'compression-info';
        info.textContent = `Compression: ${formatSize(originalSize)} → ${formatSize(compressedSize)} (${ratio}%)`;
        
        document.body.appendChild(info);
        
        setTimeout(() => {
            info.style.opacity = '0';
            setTimeout(() => info.remove(), 300);
        }, 3000);
    }

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

    // Sanitize path/filename
    function sanitizePath(path) {
        return path
            .replace(/[<>:"|?*]/g, '_')
            .replace(/\\/g, '/')
            .replace(/\/+/g, '/')
            .replace(/^\/+|\/+$/g, '');
    }

    // Convert HTML to Markdown
    function htmlToMarkdown(element) {
        let markdown = '';
        
        for (const node of element.childNodes) {
            if (node.nodeType === Node.TEXT_NODE) {
                markdown += node.textContent;
            } else if (node.nodeType === Node.ELEMENT_NODE) {
                const tag = node.tagName.toLowerCase();
                const text = node.textContent.trim();
                
                switch (tag) {
                    case 'h1':
                        markdown += `# ${text}\n\n`;
                        break;
                    case 'h2':
                        markdown += `## ${text}\n\n`;
                        break;
                    case 'h3':
                        markdown += `### ${text}\n\n`;
                        break;
                    case 'h4':
                        markdown += `#### ${text}\n\n`;
                        break;
                    case 'h5':
                        markdown += `##### ${text}\n\n`;
                        break;
                    case 'h6':
                        markdown += `###### ${text}\n\n`;
                        break;
                    case 'p':
                        markdown += `${htmlToMarkdown(node)}\n\n`;
                        break;
                    case 'strong':
                    case 'b':
                        markdown += `**${text}**`;
                        break;
                    case 'em':
                    case 'i':
                        markdown += `*${text}*`;
                        break;
                    case 'code':
                        if (node.closest('pre')) {
                            const language = getCodeLanguage(node) || '';
                            markdown += `\`\`\`${language}\n${text}\n\`\`\`\n\n`;
                        } else {
                            markdown += `\`${text}\``;
                        }
                        break;
                    case 'pre':
                        break;
                    case 'ul':
                        for (const li of node.querySelectorAll('li')) {
                            markdown += `- ${htmlToMarkdown(li)}\n`;
                        }
                        markdown += '\n';
                        break;
                    case 'ol':
                        const items = node.querySelectorAll('li');
                        items.forEach((li, index) => {
                            markdown += `${index + 1}. ${htmlToMarkdown(li)}\n`;
                        });
                        markdown += '\n';
                        break;
                    case 'li':
                        markdown += htmlToMarkdown(node);
                        break;
                    case 'blockquote':
                        const lines = htmlToMarkdown(node).split('\n');
                        markdown += lines.map(line => line.trim() ? `> ${line}` : '>').join('\n') + '\n\n';
                        break;
                    case 'hr':
                        markdown += '---\n\n';
                        break;
                    case 'a':
                        const href = node.href;
                        if (href && href !== text) {
                            markdown += `[${text}](${href})`;
                        } else {
                            markdown += text;
                        }
                        break;
                    case 'br':
                        markdown += '\n';
                        break;
                    default:
                        markdown += htmlToMarkdown(node);
                        break;
                }
            }
        }
        
        return markdown;
    }

    // Extract full response in markdown
    function extractFullResponse(responseElement) {
        const contentArea = responseElement.querySelector(CONTENT_SELECTOR);
        if (!contentArea) return '';

        const clone = contentArea.cloneNode(true);
        clone.querySelectorAll('.archive-code-btn, .code-collapse-btn').forEach(btn => btn.remove());
        
        return htmlToMarkdown(clone).trim();
    }

    // Format date for filename
    function formatDateForFilename(date) {
        const year = date.getFullYear();
        const month = String(date.getMonth() + 1).padStart(2, '0');
        const day = String(date.getDate()).padStart(2, '0');
        const hours = String(date.getHours()).padStart(2, '0');
        const minutes = String(date.getMinutes()).padStart(2, '0');
        
        return `${year}-${month}-${day}_${hours}-${minutes}`;
    }

    // Normalize language name
    function normalizeLanguage(lang) {
        if (!lang) return 'txt';
        
        const normalized = lang.toLowerCase()
            .replace(/[-_\s]/g, '')
            .replace(/script$/, '');
            
        const aliases = {
            'py': 'python', 'js': 'javascript', 'ts': 'typescript',
            'ps1': 'powershell', 'powershell': 'powershell', 'pwsh': 'powershell',
            'cs': 'csharp', 'rb': 'ruby', 'kt': 'kotlin',
            'sh': 'bash', 'zsh': 'bash', 'fish': 'fish'
        };
        
        return aliases[normalized] || normalized;
    }

    // Get language for code block
    function getCodeLanguage(codeElement) {
        const codeWrapper = codeElement.closest('.codeWrapper') || codeElement.closest('pre');
        if (codeWrapper) {
            const langIndicator = codeWrapper.querySelector(LANGUAGE_INDICATOR_SELECTOR);
            if (langIndicator) {
                return normalizeLanguage(langIndicator.textContent.trim());
            }
        }

        if (codeElement.dataset.language) {
            return normalizeLanguage(codeElement.dataset.language);
        }

        const classMatch = codeElement.className.match(/(?:language-|lang-)([^\s]+)/);
        if (classMatch) {
            return normalizeLanguage(classMatch[1]);
        }

        const content = codeElement.innerText.trim();
        if (content.includes('#!/bin/bash') || content.includes('#!/bin/sh')) return 'bash';
        if (content.includes('#!') && content.includes('python')) return 'python';
        if (content.includes('<?php')) return 'php';

        return 'txt';
    }

    // Extract file path from comments
    function extractFilePathFromCode(content) {
        const lines = content.split('\n');
        
        for (let i = 0; i < Math.min(3, lines.length); i++) {
            const line = lines[i].trim();
            
            const pathPatterns = [
                /^#\s*([a-zA-Z0-9_.\/-]+\/[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)/,
                /^\/\/\s*([a-zA-Z0-9_.\/-]+\/[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)/,
                /^\/\*\s*([a-zA-Z0-9_.\/-]+\/[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)/,
                /^--\s*([a-zA-Z0-9_.\/-]+\/[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)/,
                /^<!\-\-\s*([a-zA-Z0-9_.\/-]+\/[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)/,
                /^(?:файл|file):\s*([a-zA-Z0-9_.\/-]+\/[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)/i,
                /^([a-zA-Z0-9_.\/-]+\/[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)\s*$/
            ];
            
            for (const pattern of pathPatterns) {
                const match = line.match(pattern);
                if (match && match[1]) {
                    const path = match[1];
                    if (path.includes('/') && path.includes('.')) {
                        return sanitizePath(path);
                    }
                }
            }
        }
        
        return null;
    }

    // Generate file path considering folder structure
    function generateCodeFilepath(codeElement, index, language) {
        if (codeElement.dataset.filename) {
            return sanitizePath(codeElement.dataset.filename);
        }

        const content = codeElement.innerText.trim();
        
        const extractedPath = extractFilePathFromCode(content);
        if (extractedPath) {
            return extractedPath;
        }

        if (language.includes('/')) {
            return sanitizePath(language);
        }

        const firstLine = content.split('\n')[0].trim();
        const simpleFilenamePatterns = [
            /^#\s*([a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)$/,
            /^\/\/\s*([a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)$/,
            /^\/\*\s*([a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)/,
            /^--\s*([a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)$/,
            /^<!\-\-\s*([a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)/
        ];
        
        for (const pattern of simpleFilenamePatterns) {
            const match = firstLine.match(pattern);
            if (match && match[1] && match[1].includes('.')) {
                return sanitizePath(match[1]);
            }
        }

        const extension = langExtensions[language] || 'txt';
        
        if (language === 'dockerfile' || content.toLowerCase().includes('from ')) {
            return 'Dockerfile';
        }
        if (language === 'makefile' || content.includes('all:') || content.includes('.PHONY:')) {
            return 'Makefile';
        }

        return `code_${index + 1}.${extension}`;
    }

    // Collapse long code blocks
    function addCollapseToCodeBlocks() {
        const codeBlocks = document.querySelectorAll(CODE_BLOCK_SELECTOR);
        
        codeBlocks.forEach(codeElement => {
            if (codeElement.querySelector('.code-collapse-btn')) return;
            
            const lines = codeElement.innerText.trim().split('\n');
            if (lines.length <= COLLAPSE_THRESHOLD) return;

            const preElement = codeElement.closest('pre');
            if (!preElement) return;

            if (!preElement.classList.contains('code-collapse-wrapper')) {
                preElement.classList.add('code-collapse-wrapper');
                preElement.style.position = 'relative';
            }

            const collapseBtn = document.createElement('button');
            collapseBtn.className = 'code-collapse-btn';
            collapseBtn.textContent = 'Collapse';
            
            let isCollapsed = lines.length > 20;
            
            if (isCollapsed) {
                preElement.classList.add('code-collapsed');
                collapseBtn.textContent = `Expand (${lines.length} lines)`;
            }

            collapseBtn.addEventListener('click', (e) => {
                e.preventDefault();
                e.stopPropagation();
                
                isCollapsed = !isCollapsed;
                
                if (isCollapsed) {
                    preElement.classList.add('code-collapsed');
                    collapseBtn.textContent = `Expand (${lines.length} lines)`;
                } else {
                    preElement.classList.remove('code-collapsed');
                    collapseBtn.textContent = 'Collapse';
                }
            });

            preElement.appendChild(collapseBtn);
        });
    }

    // Add archive buttons
    function addArchiveButtons() {
        const responses = document.querySelectorAll(RESPONSE_SELECTOR);
        
        responses.forEach((response, index) => {
            if (response.querySelector('.archive-code-btn')) return;

            const contentArea = response.querySelector(CONTENT_SELECTOR);
            if (!contentArea) return;

            const codeBlocks = contentArea.querySelectorAll(CODE_BLOCK_SELECTOR);
            if (codeBlocks.length === 0) return;

            const button = document.createElement('button');
            button.textContent = `📦 ${codeBlocks.length} block${codeBlocks.length > 1 ? 's' : ''}`;
            button.className = 'archive-code-btn';
            
            Object.assign(button.style, {
                position: 'absolute',
                top: '15px',
                right: '15px',
                zIndex: '1000',
                backgroundColor: '#2563eb',
                color: 'white',
                border: 'none',
                borderRadius: '8px',
                padding: '6px 12px',
                fontSize: '13px',
                fontWeight: '500',
                cursor: 'pointer',
                boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
                transition: 'all 0.2s ease',
                fontFamily: 'system-ui, -apple-system, sans-serif'
            });

            button.addEventListener('mouseenter', () => {
                button.style.backgroundColor = '#1d4ed8';
                button.style.transform = 'translateY(-1px)';
            });
            
            button.addEventListener('mouseleave', () => {
                button.style.backgroundColor = '#2563eb';
                button.style.transform = 'translateY(0)';
            });

            button.addEventListener('click', () => archiveCodeBlocks(response, index + 1, button));

            const relativeContainer = response.querySelector('.relative') || response;
            if (getComputedStyle(relativeContainer).position === 'static') {
                relativeContainer.style.position = 'relative';
            }
            relativeContainer.appendChild(button);
        });
    }

    // Archive code blocks with maximum compression
    async function archiveCodeBlocks(responseElement, responseNumber, button) {
        const originalText = button.textContent;
        button.disabled = true;
        button.style.opacity = '0.7';
        button.textContent = '⏳ Processing...';

        try {
            const zip = new JSZip();
            const contentArea = responseElement.querySelector(CONTENT_SELECTOR);
            const codeBlocks = contentArea.querySelectorAll(CODE_BLOCK_SELECTOR);

            let processedCount = 0;
            let totalOriginalSize = 0;
            const usedFilepaths = new Set();

            // Process each code block
            codeBlocks.forEach((codeElement, index) => {
                const codeText = codeElement.innerText.trim();
                
                if (codeText.length < 10) return;

                const language = getCodeLanguage(codeElement);
                let filepath = generateCodeFilepath(codeElement, index, language);

                // Check filepath uniqueness
                let counter = 1;
                let originalFilepath = filepath;
                while (usedFilepaths.has(filepath)) {
                    const pathParts = originalFilepath.split('/');
                    const filename = pathParts.pop();
                    const dir = pathParts.join('/');
                    
                    const parts = filename.split('.');
                    if (parts.length > 1) {
                        const ext = parts.pop();
                        const newFilename = `${parts.join('.')}_${counter}.${ext}`;
                        filepath = dir ? `${dir}/${newFilename}` : newFilename;
                    } else {
                        const newFilename = `${filename}_${counter}`;
                        filepath = dir ? `${dir}/${newFilename}` : newFilename;
                    }
                    counter++;
                }
                usedFilepaths.add(filepath);

                zip.file(filepath, codeText, {
                    compression: 'DEFLATE',
                    compressionOptions: { level: 9 }
                });
                
                totalOriginalSize += new Blob([codeText]).size;
                processedCount++;

                button.textContent = `⏳ ${index + 1}/${codeBlocks.length}`;
            });

            if (processedCount === 0) {
                button.textContent = '❌ No code';
                setTimeout(() => {
                    button.textContent = originalText;
                    button.disabled = false;
                    button.style.opacity = '1';
                }, 2000);
                return;
            }

            // Create .ai_history folder and save full response
            const now = new Date();
            const dateStr = formatDateForFilename(now);
            const fullResponse = extractFullResponse(responseElement);
            
            // Minimal README content - only URL
            const aiHistoryContent = `${window.location.href}

---

${fullResponse}`;

            totalOriginalSize += new Blob([aiHistoryContent]).size;
            
            zip.file(`.ai_history/${dateStr}_response_${responseNumber}.md`, aiHistoryContent, {
                compression: 'DEFLATE',
                compressionOptions: { level: 9 }
            });

            button.textContent = '⏳ Compressing...';
            
            const zipBlob = await zip.generateAsync(MAX_COMPRESSION_OPTIONS);

            showCompressionInfo(totalOriginalSize, zipBlob.size);

            const downloadLink = document.createElement('a');
            downloadLink.href = URL.createObjectURL(zipBlob);
            downloadLink.download = `perplexity_code_${responseNumber}_${dateStr}_max.zip`;
            document.body.appendChild(downloadLink);
            downloadLink.click();
            document.body.removeChild(downloadLink);
            URL.revokeObjectURL(downloadLink.href);

            button.textContent = '✅ Done';
            setTimeout(() => {
                button.textContent = originalText;
                button.disabled = false;
                button.style.opacity = '1';
            }, 3000);

        } catch (error) {
            console.error('Archiving error:', error);
            button.textContent = '❌ Error';
            setTimeout(() => {
                button.textContent = originalText;
                button.disabled = false;
                button.style.opacity = '1';
            }, 3000);
        }
    }

    // Initialize
    function init() {
        addStyles();
        addArchiveButtons();
        addCollapseToCodeBlocks();

        const observer = new MutationObserver((mutations) => {
            let needsUpdate = false;
            
            mutations.forEach(mutation => {
                if (mutation.type === 'childList') {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === 1) {
                            if (node.matches && (
                                node.matches(RESPONSE_SELECTOR) || 
                                node.matches(CODE_BLOCK_SELECTOR) ||
                                node.querySelector && (
                                    node.querySelector(RESPONSE_SELECTOR) || 
                                    node.querySelector(CODE_BLOCK_SELECTOR)
                                )
                            )) {
                                needsUpdate = true;
                            }
                        }
                    });
                }
            });

            if (needsUpdate) {
                setTimeout(() => {
                    addArchiveButtons();
                    addCollapseToCodeBlocks();
                }, 500);
            }
        });

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

        setInterval(() => {
            addArchiveButtons();
            addCollapseToCodeBlocks();
        }, 5000);
    }

    // Start
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        setTimeout(init, 1000);
    }
})();