AtCoderのコンテストで、問題を簡単に変更することができます。
// ==UserScript==
// @name AtCoder Task Navigator
// @name:en AtCoder Task Navigator
// @license MIT
// @namespace https://example.com/
// @version 1.0.0
// @description:en Navigate AtCoder contest tasks with ArrowLeft/ArrowRight and edge buttons.
// @description AtCoderのコンテストで、問題を簡単に変更することができます。
// @match https://atcoder.jp/contests/*
// @grant none
// @run-at document-end
// ==/UserScript==
(() => {
'use strict';
const TASK_LIST_CACHE = new Map(); // contestBase -> Promise<string[]>
const getContestBase = () => {
const m = location.pathname.match(/^\/contests\/([^/]+)/);
return m ? `/contests/${m[1]}` : null;
};
const isTaskPage = () => /^\/contests\/[^/]+\/tasks\/[^/]+$/.test(location.pathname);
const normalizePath = (p) => p.replace(/\/$/, '');
async function getTaskUrls() {
const base = getContestBase();
if (!base) return [];
if (TASK_LIST_CACHE.has(base)) {
return TASK_LIST_CACHE.get(base);
}
const promise = (async () => {
try {
const res = await fetch(`${base}/tasks`, { credentials: 'same-origin' });
if (!res.ok) return [];
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const urls = [];
for (const a of doc.querySelectorAll('a[href]')) {
const href = a.getAttribute('href');
if (!href) continue;
const pathname = new URL(href, location.origin).pathname;
// Task pages are under /contests/<contest>/tasks/<task_id>
// Exclude the tasks index itself and the print page.
if (pathname === `${base}/tasks`) continue;
if (!pathname.startsWith(`${base}/tasks/`)) continue;
if (pathname.endsWith('/tasks/print')) continue;
urls.push(pathname);
}
return [...new Set(urls)];
} catch {
return [];
}
})();
TASK_LIST_CACHE.set(base, promise);
return promise;
}
async function go(delta) {
const urls = await getTaskUrls();
if (!urls.length) return;
const current = normalizePath(location.pathname);
const idx = urls.findIndex((u) => normalizePath(u) === current);
if (idx < 0) return;
const next = urls[idx + delta];
if (next) location.href = next;
}
function createEdgeButton(side, symbol, title, onClick) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = symbol;
btn.title = title;
btn.setAttribute('aria-label', title);
Object.assign(btn.style, {
position: 'fixed',
top: '50%',
[side]: '0',
transform: 'translateY(-50%)',
zIndex: '2147483647',
width: '42px',
height: '96px',
border: 'none',
padding: '0',
background: 'rgba(0,0,0,0.18)',
color: '#fff',
fontSize: '38px',
lineHeight: '96px',
cursor: 'pointer',
opacity: '0.75',
borderRadius: side === 'left' ? '0 12px 12px 0' : '12px 0 0 12px',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
userSelect: 'none',
});
btn.addEventListener('mouseenter', () => {
btn.style.opacity = '1';
});
btn.addEventListener('mouseleave', () => {
btn.style.opacity = '0.75';
});
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
onClick();
});
document.body.appendChild(btn);
return btn;
}
function shouldIgnoreKeyEvent(e) {
if (e.altKey || e.ctrlKey || e.metaKey) return true;
const el = e.target;
if (!el) return false;
if (el.isContentEditable) return true;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON';
}
async function init() {
if (!isTaskPage()) return;
const urls = await getTaskUrls();
if (urls.length < 2) return;
const current = normalizePath(location.pathname);
const idx = urls.findIndex((u) => normalizePath(u) === current);
if (idx < 0) return;
const leftBtn = createEdgeButton('left', '‹', '前の問題 (ArrowLeft)', () => go(-1));
const rightBtn = createEdgeButton('right', '›', '次の問題 (ArrowRight)', () => go(1));
const updateDisabledState = () => {
const now = normalizePath(location.pathname);
const i = urls.findIndex((u) => normalizePath(u) === now);
const leftDisabled = i <= 0;
const rightDisabled = i >= urls.length - 1;
leftBtn.style.pointerEvents = leftDisabled ? 'none' : 'auto';
rightBtn.style.pointerEvents = rightDisabled ? 'none' : 'auto';
leftBtn.style.opacity = leftDisabled ? '0.2' : '0.75';
rightBtn.style.opacity = rightDisabled ? '0.2' : '0.75';
};
updateDisabledState();
window.addEventListener(
'keydown',
(e) => {
if (shouldIgnoreKeyEvent(e)) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
go(-1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
go(1);
}
},
true
);
// AtCoder normally does full page navigation, but this keeps state correct
// if the page changes without a reload.
const observer = new MutationObserver(() => updateDisabledState());
observer.observe(document.documentElement, { childList: true, subtree: true });
}
init();
})();