AtCoder Task Navigator

AtCoderのコンテストで、問題を簡単に変更することができます。

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
})();