Download AtCoder problem statements as Markdown files. Supports both Japanese and English, with proper LaTeX math conversion and image handling.
// ==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 ``;
}
// ── 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();
})();