AtCoder题目下载器(Markdown格式)

将AtCoder题目页面下载为Markdown文件,支持LaTeX数学公式和图片。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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();
})();