AtCoder Problem Downloader (Markdown)

Download AtCoder problem statements as Markdown files. Supports both Japanese and English, with proper LaTeX math conversion and image handling.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         AtCoder Problem Downloader (Markdown)
// @name:en      AtCoder Problem Downloader (Markdown)
// @name:zh-CN   AtCoder题目下载器(Markdown格式)
// @namespace    https://atcoder.jp/
// @version      1.0.0
// @description  Download AtCoder problem statements as Markdown files. Supports both Japanese and English, with proper LaTeX math conversion and image handling.
// @description:en Download AtCoder problem statements as Markdown files with LaTeX math support.
// @description:zh-CN 将AtCoder题目页面下载为Markdown文件,支持LaTeX数学公式和图片。
// @author       aspen138
// @license      MIT
// @match        https://atcoder.jp/contests/*/tasks/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {
    'use strict';

    // ─── Configuration ───────────────────────────────────────────────────
    const STYLE = {
        btnBg: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
        btnBgHover: 'linear-gradient(135deg, #764ba2 0%, #667eea 100%)',
        btnText: '#ffffff',
        btnShadow: '0 2px 8px rgba(102,126,234,0.35)',
        btnRadius: '6px',
        successBg: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
        successText: '#1a1a2e',
        groupGap: '6px',
    };

    // ─── Entry Point ─────────────────────────────────────────────────────
    function main() {
        injectStyles();
        placeDownloadButtons();
        watchLanguageSwitch();
    }

    // ─── CSS Injection ───────────────────────────────────────────────────
    function injectStyles() {
        if (document.querySelector('#apc-dl-styles')) return;
        const style = document.createElement('style');
        style.id = 'apc-dl-styles';
        style.textContent = `
            .apc-dl-btn {
                display: inline-flex;
                align-items: center;
                gap: 4px;
                padding: 3px 10px;
                font-size: 12px;
                font-weight: 600;
                color: ${STYLE.btnText};
                background: ${STYLE.btnBg};
                border: none;
                border-radius: ${STYLE.btnRadius};
                box-shadow: ${STYLE.btnShadow};
                cursor: pointer;
                transition: all 0.25s ease;
                text-decoration: none;
                line-height: 1.6;
                vertical-align: middle;
            }
            .apc-dl-btn:hover {
                background: ${STYLE.btnBgHover};
                transform: translateY(-1px);
                box-shadow: 0 4px 12px rgba(102,126,234,0.5);
            }
            .apc-dl-btn:active {
                transform: translateY(0);
                box-shadow: 0 1px 4px rgba(102,126,234,0.3);
            }
            .apc-dl-btn.success {
                background: ${STYLE.successBg};
                color: ${STYLE.successText};
            }
            .apc-dl-btn:disabled {
                opacity: 0.4;
                cursor: not-allowed;
                transform: none !important;
                box-shadow: none !important;
            }
            .apc-dl-group {
                display: inline-flex;
                gap: ${STYLE.groupGap};
                margin-left: 10px;
                vertical-align: middle;
            }
            .apc-dl-btn svg {
                width: 14px;
                height: 14px;
                fill: currentColor;
                flex-shrink: 0;
            }
        `;
        document.head.appendChild(style);
    }

    // ─── SVG Icons ───────────────────────────────────────────────────────
    const DOWNLOAD_ICON = `<svg viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>`;
    const CHECK_ICON = `<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`;

    // ─── Button Placement ────────────────────────────────────────────────
    function placeDownloadButtons() {
        // Remove existing buttons to prevent duplicates
        document.querySelectorAll('.apc-dl-group').forEach(el => el.remove());

        const taskData = extractAllContent();

        const createGroup = () => {
            const group = document.createElement('span');
            group.className = 'apc-dl-group';

            if (taskData.ja.length > 0) {
                group.appendChild(makeDownloadButton('📥 JP .md', 'ja', taskData));
            }
            if (taskData.en.length > 0) {
                group.appendChild(makeDownloadButton('📥 EN .md', 'en', taskData));
            }
            // If neither language tag exists, treat as single-language
            if (taskData.ja.length === 0 && taskData.en.length === 0 && taskData.all.length > 0) {
                group.appendChild(makeDownloadButton('📥 Download .md', 'all', taskData));
            }

            return group;
        };

        const findHeader = (root, candidates) => {
            if (!root) return null;
            const headers = Array.from(root.querySelectorAll('h3'));
            for (const text of candidates) {
                const found = headers.find(h => h.textContent.includes(text));
                if (found) return found;
            }
            return null;
        };

        const jaNode = document.querySelector('.lang-ja');
        const enNode = document.querySelector('.lang-en');
        const taskStatement = document.querySelector('#task-statement');

        if (jaNode || enNode) {
            if (jaNode) {
                const target = findHeader(jaNode, ['問題文']) || findHeader(jaNode, ['ストーリー']) || jaNode.querySelector('h3');
                if (target) target.appendChild(createGroup());
            }
            if (enNode) {
                const target = findHeader(enNode, ['Problem Statement']) || findHeader(enNode, ['Story']) || enNode.querySelector('h3');
                if (target) target.appendChild(createGroup());
            }
        } else if (taskStatement) {
            let target = findHeader(taskStatement, ['問題文', 'Problem Statement', 'ストーリー', 'Story']);
            if (!target) target = taskStatement.querySelector('h3');
            if (target) target.appendChild(createGroup());
        }
    }

    // ─── Create Download Button ──────────────────────────────────────────
    function makeDownloadButton(label, lang, data) {
        const btn = document.createElement('button');
        btn.className = 'apc-dl-btn';
        btn.innerHTML = `${DOWNLOAD_ICON} ${label}`;

        btn.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();

            const markdown = buildMarkdown(lang, data);
            const filename = generateFilename(lang);

            downloadAsFile(filename, markdown);

            // Visual feedback
            btn.classList.add('success');
            btn.innerHTML = `${CHECK_ICON} Downloaded!`;
            setTimeout(() => {
                btn.classList.remove('success');
                btn.innerHTML = `${DOWNLOAD_ICON} ${label}`;
            }, 2000);
        });

        return btn;
    }

    // ─── Build Markdown Content ──────────────────────────────────────────
    function buildMarkdown(lang, data) {
        const parts = [];

        // Header
        parts.push(`# ${data.title}`);
        parts.push('');
        parts.push(`Source: ${window.location.href}`);
        parts.push('');

        // Time/Memory limit
        if (data.limit) {
            parts.push(data.limit);
            parts.push('');
        }

        parts.push('---');
        parts.push('');

        // Main content
        let contentParts;
        if (lang === 'ja') {
            contentParts = data.ja;
        } else if (lang === 'en') {
            contentParts = data.en;
        } else {
            contentParts = data.all;
        }

        parts.push(contentParts.join('\n\n'));

        return parts.join('\n');
    }

    // ─── Generate Filename ───────────────────────────────────────────────
    function generateFilename(lang) {
        // Extract contest ID and task ID from URL
        const urlMatch = window.location.pathname.match(/\/contests\/([^/]+)\/tasks\/([^/?]+)/);
        let name = 'atcoder_problem';
        if (urlMatch) {
            const contestId = urlMatch[1];   // e.g., "ahc063"
            const taskId = urlMatch[2];      // e.g., "ahc063_a"
            name = `${taskId}`;
        }

        const langSuffix = (lang === 'all') ? '' : `_${lang}`;
        return `${name}${langSuffix}.md`;
    }

    // ─── Download File ───────────────────────────────────────────────────
    function downloadAsFile(filename, content) {
        const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
        const url = URL.createObjectURL(blob);

        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        a.style.display = 'none';
        document.body.appendChild(a);
        a.click();

        // Cleanup
        setTimeout(() => {
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        }, 100);
    }

    // ─── Language Switch Watcher ─────────────────────────────────────────
    function watchLanguageSwitch() {
        const langBtn = document.querySelector('#task-lang-btn');
        if (!langBtn) return;

        langBtn.addEventListener('click', () => {
            setTimeout(() => placeDownloadButtons(), 50);
        });
    }

    // ─── Extract Title ───────────────────────────────────────────────────
    function extractTitle() {
        const h2 = document.querySelector('.h2, h2');
        if (h2) {
            // Get text content but exclude the editorial link button text
            const clone = h2.cloneNode(true);
            clone.querySelectorAll('a.btn, .apc-dl-group').forEach(el => el.remove());
            return clone.textContent.trim();
        }
        return document.title || 'AtCoder Problem';
    }

    // ─── Extract Limit Info ──────────────────────────────────────────────
    function extractLimit() {
        const container = document.querySelector('#main-container');
        if (!container) return '';
        const lines = container.innerText.split('\n');
        for (const line of lines) {
            const trimmed = line.trim();
            if (trimmed.startsWith('Time Limit') || trimmed.startsWith('実行時間制限')) {
                return trimmed;
            }
        }
        return '';
    }

    // ─── Extract All Content ─────────────────────────────────────────────
    function extractAllContent() {
        const title = extractTitle();
        const limit = extractLimit();
        const container = document.querySelector('#task-statement');

        if (!container) return { title, limit, ja: [], en: [], all: [] };

        const langJaNode = container.querySelector('span.lang-ja');
        const langEnNode = container.querySelector('span.lang-en');

        if (langJaNode || langEnNode) {
            return {
                title,
                limit,
                ja: langJaNode ? extractPartsFromNode(langJaNode) : [],
                en: langEnNode ? extractPartsFromNode(langEnNode) : [],
                all: []
            };
        }

        // No language tags — treat everything as a single document
        return {
            title,
            limit,
            ja: [],
            en: [],
            all: extractPartsFromNode(container)
        };
    }

    // ─── Extract ".part" Sections ────────────────────────────────────────
    function extractPartsFromNode(rootNode) {
        const elements = rootNode.querySelectorAll('.part');
        const parts = [];
        elements.forEach(element => {
            let markdown = convertToMarkdown(element).trim();
            if (markdown) parts.push(markdown);
        });
        return parts;
    }

    // ─── HTML → Markdown Converter ───────────────────────────────────────
    function convertToMarkdown(element) {

        function walk(node) {
            if (node.nodeType === Node.TEXT_NODE) {
                return node.textContent.replace(/\s+/g, ' ');
            }

            if (node.nodeType !== Node.ELEMENT_NODE) return '';

            // Skip UI elements and rendered KaTeX HTML
            if (
                node.classList.contains('apc-dl-group') ||
                node.classList.contains('apc-dl-btn') ||
                node.classList.contains('ext-copy-group') ||
                node.classList.contains('div-btn-copy') ||
                node.classList.contains('btn-copy') ||
                node.classList.contains('btn') ||
                node.classList.contains('katex-html') ||
                node.classList.contains('prettyprint')
            ) {
                return '';
            }

            // ── KaTeX MathML: extract raw LaTeX ──
            if (node.classList.contains('katex-mathml')) {
                const anno = node.querySelector('annotation[encoding="application/x-tex"]');
                if (!anno) return '';
                const latex = anno.textContent.trim();
                const isDisplay = node.closest('.katex-display') || node.querySelector('math[display="block"]');
                return isDisplay ? `\n$$\n${latex}\n$$\n` : `$${latex}$`;
            }

            const tag = node.tagName;

            // ── Images ──
            if (tag === 'IMG') {
                const src = node.getAttribute('src') || '';
                const alt = node.getAttribute('alt') || '';
                // Make relative URLs absolute
                const fullSrc = src.startsWith('http') ? src : `https://atcoder.jp${src.startsWith('/') ? '' : '/'}${src}`;
                return `![${alt}](${fullSrc})`;
            }

            // ── Links ──
            if (tag === 'A') {
                const href = node.getAttribute('href') || '';
                const children = walkChildren(node);
                const text = children.trim();
                if (!text) return '';
                const fullHref = href.startsWith('http') ? href : `https://atcoder.jp${href.startsWith('/') ? '' : '/'}${href}`;
                return `[${text}](${fullHref})`;
            }

            // ── Preformatted / code blocks ──
            if (tag === 'PRE') {
                // Check if it contains KaTeX formulas
                const formulas = node.querySelectorAll('.katex-mathml');
                if (formulas.length > 0) {
                    const lines = [];
                    formulas.forEach(katex => {
                        const anno = katex.querySelector('annotation[encoding="application/x-tex"]');
                        if (anno) lines.push(`$$${anno.textContent.trim()}$$`);
                    });
                    return `\n\`\`\`\n${lines.join('\n')}\n\`\`\`\n\n`;
                }
                return `\n\`\`\`\n${node.textContent.trim()}\n\`\`\`\n\n`;
            }

            // ── Block container elements ──
            const BLOCK_CONTAINERS = ['BODY', 'SECTION', 'DIV', 'ARTICLE', 'MAIN',
                'ASIDE', 'HEADER', 'FOOTER', 'UL', 'OL', 'DL', 'BLOCKQUOTE', 'DETAILS'];
            const isBlockContainer = BLOCK_CONTAINERS.includes(tag);

            let children = '';
            node.childNodes.forEach(child => {
                if (isBlockContainer && child.nodeType === Node.TEXT_NODE && child.textContent.trim() === '') {
                    return; // skip layout whitespace
                }
                children += walk(child);
            });

            switch (tag) {
                case 'H1': return `# ${children.trim()}\n\n`;
                case 'H2': return `## ${children.trim()}\n\n`;
                case 'H3': return `### ${children.trim()}\n\n`;
                case 'H4': return `#### ${children.trim()}\n\n`;
                case 'H5': return `##### ${children.trim()}\n\n`;
                case 'H6': return `###### ${children.trim()}\n\n`;
                case 'P': return `${children.trim()}\n\n`;
                case 'LI': return `- ${children.trim()}\n`;
                case 'UL':
                case 'OL': return `${children}\n`;
                case 'BR': return '\n';
                case 'HR': return '\n---\n\n';
                case 'STRONG':
                case 'B': return `**${children.trim()}**`;
                case 'EM':
                case 'I': return `*${children.trim()}*`;
                case 'CODE': return `\`${children.trim()}\``;
                case 'TABLE': return convertTable(node);
                case 'VAR': {
                    const t = children.trim();
                    if (!t) return '';
                    return t.startsWith('$') ? t : `$${t}$`;
                }
                default: return children;
            }
        }

        function walkChildren(node) {
            let result = '';
            node.childNodes.forEach(child => result += walk(child));
            return result;
        }

        // ── Table Conversion ──
        function convertTable(tableNode) {
            const rows = [];
            tableNode.querySelectorAll('tr').forEach(tr => {
                const cells = [];
                tr.querySelectorAll('td, th').forEach(cell => {
                    cells.push(walk(cell).trim().replace(/\|/g, '\\|').replace(/\n/g, ' '));
                });
                rows.push(cells);
            });

            if (rows.length === 0) return '';

            const colCount = Math.max(...rows.map(r => r.length));
            const lines = [];

            // Header row
            lines.push('| ' + rows[0].map(c => c || '').concat(Array(Math.max(0, colCount - rows[0].length)).fill('')).join(' | ') + ' |');
            // Separator
            lines.push('| ' + Array(colCount).fill('---').join(' | ') + ' |');
            // Data rows
            for (let i = 1; i < rows.length; i++) {
                lines.push('| ' + rows[i].map(c => c || '').concat(Array(Math.max(0, colCount - rows[i].length)).fill('')).join(' | ') + ' |');
            }

            return '\n' + lines.join('\n') + '\n\n';
        }

        return walk(element).trim();
    }

    // ─── Initialize ──────────────────────────────────────────────────────
    main();
})();