Perplexity Code Blocks Archiver

Download code as zip

Stan na 13-08-2025. Zobacz najnowsza wersja.

// ==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
// @license GPL
// ==/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);
    }
})();