Perplexity Code Blocks Archiver

Download code as zip

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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