AtCoder UI Cleaner

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
  }

})();