AtCoder UI Cleaner

Hide certain UI elements from contest page for the reduced amount of noise.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         AtCoder UI Cleaner
// @namespace    https://atcoder.jp/
// @version      1.0.3
// @description  Hide certain UI elements from contest page for the reduced amount of noise. 
// @author       (https://x.com/deep_nap_engine)
// @match        https://atcoder.jp/contests/*
// @grant        none
// @license      MIT
// @homepage     https://github.com/TrueRyoB/atcoder-ui-cleaner/
// @support      https://github.com/TrueRyoB/atcoder-ui-cleaner/issues/

// ==/UserScript==

(function () {
  'use strict';

  // ─────────────────────────────────────────
  // constants
  // ─────────────────────────────────────────

  const STORAGE_KEYS = {
    hide: {
      score: 'atcoder_ui_cleaner:hide_score:enabled',
      runtime_limit: 'atcoder_ui_cleaner:hide_runtime_limit:enabled',
      memory_limit: 'atcoder_ui_cleaner:hide_memory_limit:enabled',
      submission_detail: 'atcoder_ui_cleaner:hide_submission_detail:enabled',
      problem_label: 'atcoder_ui_cleaner:hide_problem_label:enabled',
    }
  };

  const PROCESSED_ATTR = 'data-atcoder-ui-cleaner-hidden';
  const EXCEPTION_CLASS = "ui-atcoder-ui-cleaner-critical";

  // ─────────────────────────────────────────
  // page categorization
  // ─────────────────────────────────────────

  /**
   * @returns {'contest_top' | 'task_page' | 'submission' | 'task_list' | null}
   */
  function detectPageType() {
    const path = location.pathname;

    // submission detail: /contests/******/submissions/xxxxxxxxxx/
    if (/^\/contests\/[^/]+\/submissions\/\d+$/.test(path)) {
      return 'submission';
    }

    // task detail: /contests/******/tasks/******_*/
    if (/^\/contests\/[^/]+\/tasks\/[^/]+$/.test(path)) {
      return 'task_page';
    }

    // problem list: /contests/******/
    if (/^\/contests\/[^/]+\/?$/.test(path)) {
      return 'contest_top';
    }

    // task list: /contests/*******/tasks
    if (/^\/contests\/[^/]+\/tasks\/?$/.test(path)) {
      return 'task_list';
    }

    return null;
  }

  // ─────────────────────────────────────────
  // localStorage helper
  // ─────────────────────────────────────────

  // is set enabled by defualt
  function isEnabled(key) {
    const val = localStorage.getItem(key);
    return val === null ? true : val === 'true';
  }

  function setEnabled(sectionType, value) {
    localStorage.setItem(sectionType, value ? 'true' : 'false');
  }

  // ─────────────────────────────────────────
  // helper for applying a hidden tag
  // ─────────────────────────────────────────

  function hideElement(el) {
    if (!el || el.hasAttribute(PROCESSED_ATTR) || el.classList.contains(EXCEPTION_CLASS)) return;
    el.style.display = 'none';
    el.setAttribute(PROCESSED_ATTR, '1');
  }

  // ─────────────────────────────────────────
  // helper for a text search
  // ─────────────────────────────────────────

  /**
   * retrives a set of nodes with the certain keyword as its immediate child
   * ...from the entire document
   */
  function findElementsByOwnText(keyword) {
    const walker = document.createTreeWalker(
      document.body,
      NodeFilter.SHOW_TEXT,
      null
    );
    const results = [];
    let node;
    while ((node = walker.nextNode())) {
      if (node.nodeValue && node.nodeValue.includes(keyword)) {
        const parent = node.parentElement;
        if (parent && !results.includes(parent)) {
          results.push(parent);
        }
      }
    }
    return results;
  }

  // ─────────────────────────────────────────
  // 5.1.1 contest top
  // ─────────────────────────────────────────

  function hideContestTopElements() {
    // tageting every <section> tag which is a parent of nodes
    // ...holdinge one of the keywords as a substring
    if(!isEnabled(STORAGE_KEYS.hide.score)) return;

    const keywords = ['点数'];
    keywords.forEach((kw) => {
      const matched = findElementsByOwnText(kw);
      matched.forEach((el) => {
        let cursor = el;
        let minSection = null;
        while (cursor && cursor !== document.body) {
          if (cursor.tagName === 'SECTION') {
            minSection = cursor;
            break;
          }
          cursor = cursor.parentElement;
        }
        if (minSection) {
          hideElement(minSection);
        }
      });
    });

    // and its associated h3 tag (outside of the section)
    const h3tag = findElementsByOwnText('配点');
    h3tag.forEach((el)=>hideElement(el));
  }

  // ─────────────────────────────────────────
  // 5.1.2 task detail
  // ─────────────────────────────────────────

  // hide minimum wrappers with certain information
  function hideTaskPageElements() {
    // problem label
    if(isEnabled(STORAGE_KEYS.hide.problem_label)) {
      const label = document.title?.trim();
      if(!label) return;

      const m = label.match(/^[A-Z]\s*-\s*/);
      if(!m) return;
      const prefix=m[0];
      const altered=label.slice(prefix.length) + " ";
      document.title=altered;

      const matched=findElementsByOwnText(label);
      matched.forEach((el) => {
        if(el.hasAttribute(PROCESSED_ATTR)) return;

        for(const node of el.childNodes) if(node.nodeType==Node.TEXT_NODE) {
          const text=node.nodeValue;
          if(!text || !text.trimStart().startsWith(prefix)) continue;

          node.nodeValue=altered;
          el.setAttribute(PROCESSED_ATTR, "1");
        }
      })
    }

    // score
    if(isEnabled(STORAGE_KEYS.hide.score)) {
      const matched = findElementsByOwnText("配点");
      matched.forEach((el) => {
        const wrapper = findMinimalWrapper(el);
        hideElement(wrapper);
      })
    }

    //memory, runtime (so-sorry for the hardcoding ;-;)
    let hideML=isEnabled(STORAGE_KEYS.hide.memory_limit);
    let hideRL=isEnabled(STORAGE_KEYS.hide.runtime_limit);
    if(!hideML && !hideRL) return;

    const matched = findElementsByOwnText("実行時間制限");
    matched.forEach((el) => {
      const p = findMinimalWrapper(el);
      if(p.classList.contains(EXCEPTION_CLASS) || p.hasAttribute(PROCESSED_ATTR)) return;
      if(hideML && hideRL) {
        hideElement(p);
        return;
      }
      p.setAttribute(PROCESSED_ATTR, '1');
      if (hideML) p.innerHTML = p.innerHTML.replace(/\/\s*メモリ制限\s*:[^<]+/, '');
      if (hideRL) p.innerHTML = p.innerHTML.replace(/実行時間制限\s*:[^/]+\/?/, '');
    })
  }

  /**
   * retrieves the minimum semantic UI element containing an input element;
   * prioritizes li / p / div / tr / td for a cleaner DOM if they are close enough
   */
  function findMinimalWrapper(el) {
    const blockTags = new Set(['LI', 'P', 'DIV', 'TR', 'TD', 'DT', 'DD', 'SPAN']);
    let cursor = el;
    // explore up to three levels
    for (let i = 0; i < 3; i++) {
      if (!cursor.parentElement || cursor.parentElement === document.body) break;
      const parent = cursor.parentElement;
      if (blockTags.has(parent.tagName)) {
        // take the parent node if it seems not containing 
        // ....other significant information
        const textLength = (parent.textContent || '').trim().length;
        if (textLength < 100) {
          cursor = parent;
        } else {
          break;
        }
      } else {
        break;
      }
    }
    return cursor;
  }

  // ─────────────────────────────────────────
  // 5.1.3 submission detail
  // ─────────────────────────────────────────
  function hideSubmissionElements() {
    // hide every subsequent UI element in the same scope
    // ...from and after the label specified
    if(!isEnabled(STORAGE_KEYS.hide.submission_detail)) return;
    const h4List = document.querySelectorAll('h4');
    const label = 'ジャッジ結果';
    h4List.forEach((h4) => {
      if (!h4.textContent.includes(label) ||
          h4.hasAttribute(PROCESSED_ATTR) ||
          !h4.parentElement) return;

      let u = h4;
      while(u) {
        hideElement(u);
        u = u.nextElementSibling;
      }
    });
  }

  // ─────────────────────────────────────────
  // 5.1.4 task list
  // ─────────────────────────────────────────
  
  function hideTaskListElements() {
    // hide columns indicating time or memory limit
    const targets = [
      { key: "実行時間制限", val: isEnabled(STORAGE_KEYS.hide.runtime_limit) },
      { key: "メモリ制限", val: isEnabled(STORAGE_KEYS.hide.memory_limit) }
    ];

    const headerRow = document.querySelector('tr');
    if(!headerRow) return;

    const headers = [...headerRow.querySelectorAll('th')];
    
    const hide = [...headerRow.querySelectorAll('th')].map(cell =>
      targets.some(t => t.val && cell.textContent === t.key)
    );

    const rows = document.querySelectorAll('tr');

    rows.forEach(row => {
      row.querySelectorAll('th, td').forEach((cell, i) => {
        if(hide[i]) hideElement(cell);
      })
    })
  }


  // ─────────────────────────────────────────
  // core function called for every redrawal
  // ─────────────────────────────────────────

  function run(pageType) {
    switch (pageType) {
      case 'contest_top':
        hideContestTopElements();
        break;
      case 'task_page':
        hideTaskPageElements();
        break;
      case 'submission':
        hideSubmissionElements();
        break;
      case 'task_list':
        hideTaskListElements();
        break;
    }
  }

  // ─────────────────────────────────────────
  // ui insertion (dropdown and modal)
  // ─────────────────────────────────────────

  function mountUI({
    modalHTML,
    dropdownHTML,
    legacyDropdownHTML,
    modalSelector = "#my-settings-modal",
    rowSelector = ".settings-row",
  }) {
    document.body.insertAdjacentHTML("afterbegin", modalHTML);

    document
      .querySelector(".header-mypage_list li:nth-last-child(1)")
      ?.insertAdjacentHTML("beforebegin", dropdownHTML);

    document
      .querySelector(".navbar-right .dropdown-menu .divider:nth-last-child(2)")
      ?.insertAdjacentHTML("beforebegin", legacyDropdownHTML);

    const modal = document.querySelector(modalSelector);
    if (!modal) throw new Error("modal not found");

    const row = modal.querySelector(rowSelector);
    if (!row) throw new Error("settings row not found");

    return { modal, row };
  }

  function addCheckbox(row, label, checked, description, onChange) {
    const div = document.createElement("div");
    div.className = "checkbox";
    div.classList.add(EXCEPTION_CLASS);

    const labelElem = document.createElement("label");
    const input = document.createElement("input");
    input.type = "checkbox";
    input.checked = checked;

    labelElem.append(input, label);
    labelElem.classList.add(EXCEPTION_CLASS);

    if (description) {
      const small = document.createElement("div");
      small.className = "small gray";
      small.textContent = description;
      labelElem.append(small);
    }

    div.append(labelElem);
    row.append(div);

    input.addEventListener("change", () => onChange(input.checked));
  }

  const modalTitle="ui-cleaner 非表示設定";
  const dropdownLabel="ui-cleaner 設定";

  const { modal, row } = mountUI({
    modalHTML: `
      <div id="my-settings-modal" class="modal fade" tabindex="-1" role="dialog">
        <div class="modal-dialog" role="document">
          <div class="modal-content">
            <div class="modal-header">
              <button type="button" class="close" data-dismiss="modal">x</button>
              <h4 class="modal-title">${modalTitle}</h4>
            </div>
            <div class="modal-body">
              <div class="container-fluid ${EXCEPTION_CLASS}">
                <div class="settings-row"></div>
              </div>
            </div>
          </div>
        </div>
      </div>
    `,
    dropdownHTML: `<li><a data-toggle="modal" data-target="#my-settings-modal" style="cursor:pointer;"><i class=\"a-icon a-icon-setting\"></i> ${dropdownLabel}</a></li>`,
    legacyDropdownHTML: `<li><a data-toggle="modal" data-target="#my-settings-modal" style="cursor:pointer;"><span class=\"glyphicon glyphicon-eye-close\" aria-hidden=\"true\"></span> ${dropdownLabel}</a></li>`,
  });

  addCheckbox(row, "配点", isEnabled(STORAGE_KEYS.hide.score), "前から順番に解くことを推奨しています", (v) => setEnabled(STORAGE_KEYS.hide.score, v));
  addCheckbox(row, "実行時間制限", isEnabled(STORAGE_KEYS.hide.runtime_limit), "2000msを切ることはないでしょう", (v) => setEnabled(STORAGE_KEYS.hide.runtime_limit, v));
  addCheckbox(row, "メモリ制限", isEnabled(STORAGE_KEYS.hide.memory_limit), "普段通りの実装を心がけましょう", (v) => setEnabled(STORAGE_KEYS.hide.memory_limit, v));
  addCheckbox(row, "提出結果詳細", isEnabled(STORAGE_KEYS.hide.submission_detail), "下手な憶測は却って逆効果です", (v) => setEnabled(STORAGE_KEYS.hide.submission_detail, v));
  addCheckbox(row, "問題ラベル", isEnabled(STORAGE_KEYS.hide.problem_label), "見て呉れに惑わされません", (v) => setEnabled(STORAGE_KEYS.hide.problem_label, v));

  // ─────────────────────────────────────────
  // support for dynamic redrawal, using MutationObserver
  // ─────────────────────────────────────────

  function setupObserver(pageType) {
    let timer = null;
    const observer = new MutationObserver(() => {
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => {
        run(pageType);
      }, 100);
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  }

  // ─────────────────────────────────────────
  // entry
  // ─────────────────────────────────────────

  function main() {
    const pageType = detectPageType();
    if (!pageType) return;

    run(pageType);
    setupObserver(pageType);
  }

  // calls main() on DOM load completion
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', main);
  } else {
    main();
  }

})();