AtCoder Highlighter

AtCoder の問題文中の数字と変数、実行時間/メモリ制限を自動で強調表示させます

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

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

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

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

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

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name            AtCoder Highlighter
// @name:en         AtCoder Highlighter
// @namespace       https://github.com/nsubaru11/AtCoder/AtCoder_Scripts
// @version         1.3.2
// @description     AtCoder の問題文中の数字と変数、実行時間/メモリ制限を自動で強調表示させます
// @description:en  Automatically highlights numbers, variables, and time/memory limits in AtCoder task statements
// @author          nsubaru11
// @license         MIT
// @match           https://atcoder.jp/contests/*/tasks/*
// @grant           GM_getValue
// @grant           GM_setValue
// @grant           GM_registerMenuCommand
// @run-at          document-idle
// @homepageURL     https://github.com/nsubaru11/AtCoder/tree/main/AtCoder_Scripts/AtCoderHighlighter
// @supportURL      https://github.com/nsubaru11/AtCoder/issues
// ==/UserScript==

(function () {
	'use strict';

	const TARGET_KEYWORDS = ['問題文', 'Problem Statement', '制約', 'Constraints'];
	const TIME_LIMIT_KEYWORDS = ['Time Limit', '実行時間制限'];
	const MEMORY_LIMIT_KEYWORDS = ['Memory Limit', 'メモリ制限'];
	const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'CODE', 'PRE', 'VAR', 'KBD', 'SAMP']);

	const NUM_PATTERN = /(^|\W)([+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:e[+-]?\d+)?)/gi;
	const NUM_PURE = /^[+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:e[+-]?\d+)?$/i;

	const DEFAULT_COLORS = {
		num: '#0033B3',
		var: '#9E2927',
		time: '#b3542a',
		memory: '#1d643b',
	};

	const IS_JP = navigator.language.startsWith('ja');
	const MSG = {
		prompt: IS_JP ? 'の色 (例: #0033B3 / #03b / rgb(0,51,179))' : ' Color (e.g. #0033B3 / #03b / rgb(0,51,179))',
		error: IS_JP ? '色の形式が正しくありません。' : 'Invalid color format.',
		labels: {
			num: IS_JP ? '数字の色' : 'Numbers Color',
			var: IS_JP ? '変数の色' : 'Variables Color',
			time: IS_JP ? '実行時間制限の色' : 'Time Limit Color',
			memory: IS_JP ? 'メモリ制限の色' : 'Memory Limit Color'
		}
	};

	function normalizeHexColor(input) {
		if (typeof input !== 'string') return null;
		const value = input.trim();
		if (/^#[0-9a-fA-F]{3}$/.test(value)) return `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`;
		if (/^#[0-9a-fA-F]{4}$/.test(value)) return `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}${value[4]}${value[4]}`;
		if (/^#[0-9a-fA-F]{6}$/.test(value) || /^#[0-9a-fA-F]{8}$/.test(value)) return value;
		return null;
	}

	function normalizeColor(input) {
		if (typeof input !== 'string') return null;
		const trimmed = input.trim();
		const normalizedHex = normalizeHexColor(trimmed);
		if (normalizedHex) return normalizedHex;
		if (/^(rgb|rgba|hsl|hsla)\([^)]*\)$/.test(trimmed)) return trimmed;
		return null;
	}

	function readColors() {
		if (typeof GM_getValue !== 'function') return Object.assign({}, DEFAULT_COLORS);
		return {
			num: GM_getValue('numColor', DEFAULT_COLORS.num),
			var: GM_getValue('varColor', DEFAULT_COLORS.var),
			time: GM_getValue('timeLimitColor', DEFAULT_COLORS.time),
			memory: GM_getValue('memoryLimitColor', DEFAULT_COLORS.memory),
		};
	}

	function writeColor(key, value) {
		if (typeof GM_setValue !== 'function') return;
		GM_setValue(key, value);
	}

	function injectStyles() {
		const existingStyle = document.getElementById('atcoder-highlighter-style');
		if (existingStyle) existingStyle.remove();

		const colors = readColors();
		const style = document.createElement('style');
		style.id = 'atcoder-highlighter-style';
		style.textContent = /* language=css */ `
			/* 強調表示の共通設定 */
			.target-scope .katex .mathnormal,
			.target-scope .number,
			.time-limit-value,
			.memory-limit-value {
				font-weight: 800 !important;
			}

			.target-scope .katex .mathnormal {
				color: ${colors.var} !important;
			}

			.target-scope .number {
				color: ${colors.num} !important;
			}

			.time-limit-value, .time-limit-value-number {
				color: ${colors.time};
			}

			.memory-limit-value, .memory-limit-value-number {
				color: ${colors.memory};
			}

			.time-limit-value-number, .memory-limit-value-number {
				font-size: 2em;
			}
		`;
		(document.head || document.documentElement).appendChild(style);
	}

	function markTargetSections(root) {
		const sections = (root || document).querySelectorAll('#task-statement section');

		sections.forEach(sec => {
			const h3 = sec.querySelector('h3');
			if (!h3) return;

			const title = h3.textContent.trim();
			if (TARGET_KEYWORDS.some(kw => title.includes(kw))) {
				sec.classList.add('target-scope');
			}
		});
	}

	function isPureNumber(text) {
		return NUM_PURE.test(text.trim());
	}

	function highlightKaTeXNumbers(scope) {
		const elements = scope.querySelectorAll('.katex .mord, .katex .text, .katex .mord.text');
		elements.forEach(el => {
			if (el.classList.contains('number')) return;
			if (el.classList.contains('mathnormal')) return;
			if (isPureNumber(el.textContent)) {
				el.classList.add('number');
			}
		});
	}

	function highlightTextNumbers(scope) {
		const walker = document.createTreeWalker(
			scope,
			NodeFilter.SHOW_TEXT,
			{
				acceptNode: function (node) {
					const parent = node.parentNode;
					if (!parent || !parent.tagName) return NodeFilter.FILTER_REJECT;

					const tagName = parent.tagName.toUpperCase();
					if (SKIP_TAGS.has(tagName)) return NodeFilter.FILTER_REJECT;

					if (typeof parent.closest === 'function') {
						if (parent.closest('.katex, var, .number')) {
							return NodeFilter.FILTER_REJECT;
						}
					}

					return NodeFilter.FILTER_ACCEPT;
				}
			}
		);

		const nodesToProcess = [];
		let currentNode;
		while ((currentNode = walker.nextNode())) {
			if (/\d/.test(currentNode.nodeValue)) {
				nodesToProcess.push(currentNode);
			}
		}

		nodesToProcess.forEach(node => {
			const text = node.nodeValue;
			if (!NUM_PATTERN.test(text)) return;

			const fragment = document.createDocumentFragment();
			let lastIndex = 0;
			let match;

			NUM_PATTERN.lastIndex = 0;
			while ((match = NUM_PATTERN.exec(text)) !== null) {
				const fullStart = match.index;
				const prefix = match[1] || '';
				const numberText = match[2];
				const numberStart = fullStart + prefix.length;
				const numberEnd = numberStart + numberText.length;

				if (fullStart > lastIndex) {
					fragment.appendChild(document.createTextNode(text.slice(lastIndex, fullStart)));
				}
				if (prefix) {
					fragment.appendChild(document.createTextNode(prefix));
				}
				const span = document.createElement('span');
				span.className = 'number';
				span.textContent = numberText;
				fragment.appendChild(span);

				lastIndex = numberEnd;
			}
			if (lastIndex < text.length) {
				fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
			}
			node.parentNode.replaceChild(fragment, node);
		});
	}

	function highlightNumbers() {
		const scopes = document.querySelectorAll('.target-scope');
		scopes.forEach(scope => {
			highlightKaTeXNumbers(scope);
			highlightTextNumbers(scope);
		});
	}

	function wrapLimitValue(element, keyword, className, options = {}) {
		const walker = document.createTreeWalker(
			element,
			NodeFilter.SHOW_TEXT,
			{
				acceptNode: function (node) {
					const parent = node.parentNode;
					if (!parent || !parent.tagName) return NodeFilter.FILTER_REJECT;

					const tagName = parent.tagName.toUpperCase();
					if (SKIP_TAGS.has(tagName)) return NodeFilter.FILTER_REJECT;
					if (typeof parent.closest === 'function') {
						if (parent.closest('.katex, var, .number, .time-limit-value, .time-limit-value-number, .memory-limit-value')) {
							return NodeFilter.FILTER_REJECT;
						}
					}
					return NodeFilter.FILTER_ACCEPT;
				}
			}
		);

		const nodes = [];
		let currentNode;
		while ((currentNode = walker.nextNode())) {
			if (currentNode.nodeValue && currentNode.nodeValue.includes(keyword)) {
				nodes.push(currentNode);
			}
		}

		const valuePattern = new RegExp(`${keyword}\\s*[::]\\s*([0-9][0-9,]*(?:\\.[0-9]+)?)(\\s*[a-zA-Z]+)?`, 'g');

		nodes.forEach(node => {
			const text = node.nodeValue;
			if (!text || !text.includes(keyword)) return;
			const fragment = document.createDocumentFragment();
			let lastIndex = 0;
			let match;
			while ((match = valuePattern.exec(text)) !== null) {
				const fullStart = match.index;
				const valueNumber = match[1] || '';
				const valueUnit = match[2] || '';
				const valueStart = fullStart + match[0].lastIndexOf(valueNumber);
				const matchEnd = fullStart + match[0].length;
				if (fullStart > lastIndex) {
					fragment.appendChild(document.createTextNode(text.slice(lastIndex, fullStart)));
				}
				fragment.appendChild(document.createTextNode(text.slice(fullStart, valueStart)));
				if (options.numberOnly) {
					const span = document.createElement('span');
					span.className = options.numberClass || className;
					span.textContent = valueNumber;
					fragment.appendChild(span);
					if (valueUnit) fragment.appendChild(document.createTextNode(valueUnit));
				} else {
					const span = document.createElement('span');
					span.className = className;
					span.textContent = valueNumber + valueUnit;
					fragment.appendChild(span);
				}
				lastIndex = matchEnd;
			}
			if (lastIndex < text.length) {
				fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
			}
			if (node.parentNode) node.parentNode.replaceChild(fragment, node);
		});
	}

	function emphasizeLimits() {
		const root = document.getElementById('main-container') || document.body;
		if (!root) return;

		const configs = [
			{keywords: TIME_LIMIT_KEYWORDS, cls: 'time-limit-value', numCls: 'time-limit-value-number'},
			{keywords: MEMORY_LIMIT_KEYWORDS, cls: 'memory-limit-value', numCls: 'memory-limit-value-number'}
		];

		const candidates = root.querySelectorAll('p, dt, dd, th, td, div, li');
		candidates.forEach(el => {
			const text = el.textContent || '';
			configs.forEach(({keywords, cls, numCls}) => {
				if (keywords.some(kw => text.includes(kw)) && !el.querySelector(`.${numCls}`)) {
					keywords.forEach(kw => wrapLimitValue(el, kw, cls, {
						numberOnly: true,
						numberClass: numCls,
					}));
				}
			});
		});
	}

	let scheduled = false;

	function scheduleHighlight() {
		if (scheduled) return;
		scheduled = true;
		setTimeout(() => {
			scheduled = false;
			injectStyles();
			markTargetSections();
			highlightNumbers();
			emphasizeLimits();
		}, 100);
	}

	function resetStyles() {
		const style = document.getElementById('atcoder-highlighter-style');
		if (style) style.remove();
		injectStyles();
		scheduleHighlight();
	}

	function registerMenu() {
		if (typeof GM_registerMenuCommand !== 'function') return;

		const menuItems = [
			{label: MSG.labels.num, key: 'numColor', prop: 'num'},
			{label: MSG.labels.var, key: 'varColor', prop: 'var'},
			{label: MSG.labels.time, key: 'timeLimitColor', prop: 'time'},
			{label: MSG.labels.memory, key: 'memoryLimitColor', prop: 'memory'}
		];

		menuItems.forEach(({label, key, prop}) => {
			GM_registerMenuCommand(`Highlighter: ${label}`, () => {
				const current = readColors();
				const next = prompt(`${label}${MSG.prompt}`, current[prop]);
				if (!next) return;
				const normalized = normalizeColor(next);
				if (!normalized) return alert(MSG.error);
				writeColor(key, normalized);
				resetStyles();
			});
		});
	}

	function observeTaskStatement() {
		const target = document.getElementById('task-statement') || document.body;
		if (!target) return;

		const observer = new MutationObserver(() => scheduleHighlight());
		observer.observe(target, {childList: true, subtree: true, characterData: true});
	}

	scheduleHighlight();
	observeTaskStatement();
	registerMenu();
})();