AtCoder Problems の Problem List の現在のソート列に ▲ または ▼ を表示します。
// ==UserScript==
// @name AtCoder Problems Problem List Sort Marker
// @namespace http://tampermonkey.net/
// @version 2026.07.07
// @description AtCoder Problems の Problem List の現在のソート列に ▲ または ▼ を表示します。
// @author Not_Leonian
// @match https://kenkoooo.com/atcoder*
// @icon https://www.google.com/s2/favicons?domain=https://kenkoooo.com/atcoder
// @run-at document-idle
// @license MIT
// ==/UserScript==
(() => {
"use strict";
const ASC_MARK = "▲";
const DESC_MARK = "▼";
const MARKER_CLASS = "acp-sort-marker";
const ACTIVE_TH_CLASS = "acp-sort-active";
const STYLE_ID = "acp-sort-marker-style";
const FIELD_TO_HEADER = new Map([
["contestDate", "Date"],
["title", "Problem"],
["id", "Problem"],
["mergedProblem", "Problem"],
["contest", "Contest"],
["contestTitle", "Contest"],
["status", "Result"],
["lastAcceptedDate", "Last AC Date"],
["solverCount", "Solvers"],
["point", "Point"],
["problemModel", "Difficulty"],
["solveProbability", "Solve Prob"],
["timeEstimation", "Time"],
["executionTime", "Fastest"],
["fastestUserId", "Fastest"],
["codeLength", "Shortest"],
["shortestUserId", "Shortest"],
["firstUserId", "First"],
]);
const VALID_SORT_FIELDS = new Set([
...FIELD_TO_HEADER.keys(),
"id",
"contestTitle",
"mergedProblem",
"shortestUserId",
"fastestUserId",
]);
const PROBLEM_LIST_SIGNATURE_FIELDS = [
"contestDate",
"title",
"contest",
"point",
"problemModel",
];
const PROBLEM_LIST_SIGNATURE_HEADERS = [
"Date",
"Problem",
"Contest",
"Point",
"Difficulty",
];
const MIN_SIGNATURE_MATCHES = 3;
function injectStyle() {
if (document.getElementById(STYLE_ID)) return;
const styleParent = document.head || document.documentElement;
if (!styleParent) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
th.${ACTIVE_TH_CLASS} {
position: relative !important;
padding-inline-end: 1.35em !important;
}
th.${ACTIVE_TH_CLASS} > .${MARKER_CLASS} {
position: absolute;
inset-inline-end: 0.35em;
top: 50%;
z-index: 1;
transform: translateY(-50%);
font-size: 0.85em;
font-weight: 700;
line-height: 1;
pointer-events: none;
user-select: none;
}
`;
styleParent.appendChild(style);
}
function normalizePath(path) {
const normalized = path.replace(/\/+$/g, "");
return normalized || "/";
}
function removeBasePath(pathname) {
if (pathname === "/atcoder") return "/";
if (pathname.startsWith("/atcoder/"))
return pathname.slice("/atcoder".length);
return pathname;
}
function getRouteUrl() {
const hash = location.hash.startsWith("#")
? location.hash.slice(1)
: location.hash;
if (!hash) return null;
try {
return new URL(hash, location.origin);
} catch {
return null;
}
}
function isListPath(path) {
const normalized = normalizePath(path);
return normalized === "/list" || normalized.startsWith("/list/");
}
function isProblemListPage() {
const routeUrl = getRouteUrl();
if (routeUrl) return isListPath(routeUrl.pathname);
const browserPath = removeBasePath(location.pathname || "/");
return isListPath(browserPath);
}
function getRouteSearchParams() {
const routeUrl = getRouteUrl();
if (routeUrl) return routeUrl.searchParams;
const questionIndex = location.hash.indexOf("?");
if (questionIndex >= 0) {
return new URLSearchParams(location.hash.slice(questionIndex + 1));
}
return new URLSearchParams(location.search);
}
function getSortState() {
const params = getRouteSearchParams();
const rawField = params.get("sortBy");
const field =
rawField && VALID_SORT_FIELDS.has(rawField) ? rawField : "contestDate";
const order = params.get("sortOrder") === "asc" ? "asc" : "desc";
return { field, order };
}
function escapeCssAttributeValue(value) {
if (window.CSS && typeof window.CSS.escape === "function") {
return window.CSS.escape(value);
}
return value.replace(/["\\]/g, "\\$&");
}
function normalizeHeaderText(text) {
return text
.replace(/[▲▼△▽▴▾↑↓]/g, "")
.replace(/\s+/g, " ")
.trim();
}
function isVisibleElement(element) {
const style = getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden";
}
function getHeaderScope(element) {
return (
element.closest(".react-bs-table-container") ||
element.closest(".react-bs-container-header") ||
element.closest("thead") ||
document
);
}
function getFirstHeaderCells(scope) {
const row = scope.querySelector("thead tr") || scope.querySelector("tr");
if (!row) return [];
return Array.from(row.querySelectorAll("th")).filter(isVisibleElement);
}
function countMatches(values, candidates) {
let count = 0;
for (const candidate of candidates) {
if (values.has(candidate)) count += 1;
}
return count;
}
function hasProblemListSignature(values, candidates, anchors) {
const matches = countMatches(values, candidates);
if (matches < MIN_SIGNATURE_MATCHES) return false;
for (const anchor of anchors) {
if (values.has(anchor)) return true;
}
return false;
}
function scopeLooksLikeProblemList(scope) {
const dataFields = new Set(
Array.from(scope.querySelectorAll("th[data-field]"))
.filter(isVisibleElement)
.map((th) => th.getAttribute("data-field") || ""),
);
if (
hasProblemListSignature(dataFields, PROBLEM_LIST_SIGNATURE_FIELDS, [
"contestDate",
"title",
])
) {
return true;
}
const headerTexts = new Set(
getFirstHeaderCells(scope).map((th) =>
normalizeHeaderText(th.textContent || ""),
),
);
return hasProblemListSignature(
headerTexts,
PROBLEM_LIST_SIGNATURE_HEADERS,
["Date", "Problem"],
);
}
function getProblemListHeaderScopes() {
const scopes = [];
const seen = new Set();
const headerCells = document.querySelectorAll(
".react-bs-container-header th, thead th",
);
for (const th of headerCells) {
const scope = getHeaderScope(th);
if (seen.has(scope)) continue;
seen.add(scope);
if (scopeLooksLikeProblemList(scope)) scopes.push(scope);
}
return scopes;
}
function findHeaderCellByDataField(field) {
const escapedField = escapeCssAttributeValue(field);
const selector = `.react-bs-container-header th[data-field="${escapedField}"], thead th[data-field="${escapedField}"]`;
const candidates = Array.from(document.querySelectorAll(selector)).filter(
isVisibleElement,
);
for (const th of candidates) {
if (scopeLooksLikeProblemList(getHeaderScope(th))) return th;
}
return candidates[0] || null;
}
function findHeaderCellByText(field) {
const expectedHeader = FIELD_TO_HEADER.get(field);
if (!expectedHeader) return null;
for (const scope of getProblemListHeaderScopes()) {
for (const th of getFirstHeaderCells(scope)) {
if (normalizeHeaderText(th.textContent || "") === expectedHeader)
return th;
}
}
return null;
}
function findTargetHeaderCell(field) {
return findHeaderCellByDataField(field) || findHeaderCellByText(field);
}
function clearMarkers() {
for (const marker of document.querySelectorAll(`.${MARKER_CLASS}`)) {
marker.remove();
}
for (const th of document.querySelectorAll(`th.${ACTIVE_TH_CLASS}`)) {
th.classList.remove(ACTIVE_TH_CLASS);
}
}
function isMarkerAlreadyCorrect(targetTh, mark) {
const markers = Array.from(document.querySelectorAll(`.${MARKER_CLASS}`));
const activeHeaders = Array.from(
document.querySelectorAll(`th.${ACTIVE_TH_CLASS}`),
);
return (
markers.length === 1 &&
activeHeaders.length === 1 &&
markers[0].parentElement === targetTh &&
markers[0].textContent === mark &&
activeHeaders[0] === targetTh
);
}
let isApplying = false;
function applyMarker() {
if (isApplying) return;
isApplying = true;
try {
injectStyle();
if (!isProblemListPage()) {
clearMarkers();
return;
}
const { field, order } = getSortState();
const targetTh = findTargetHeaderCell(field);
if (!targetTh) {
clearMarkers();
return;
}
const mark = order === "asc" ? ASC_MARK : DESC_MARK;
if (isMarkerAlreadyCorrect(targetTh, mark)) return;
clearMarkers();
const marker = document.createElement("span");
marker.className = MARKER_CLASS;
marker.textContent = mark;
marker.setAttribute("aria-hidden", "true");
marker.setAttribute("data-atcoder-problems-sort-marker", "true");
targetTh.classList.add(ACTIVE_TH_CLASS);
targetTh.appendChild(marker);
} finally {
setTimeout(() => {
isApplying = false;
}, 0);
}
}
let scheduled = 0;
function scheduleApply() {
if (scheduled) cancelAnimationFrame(scheduled);
scheduled = requestAnimationFrame(() => {
scheduled = 0;
applyMarker();
});
}
function hookHistoryMethod(name) {
const original = history[name];
if (typeof original !== "function") return;
try {
history[name] = function (...args) {
const result = original.apply(this, args);
scheduleApply();
return result;
};
} catch {}
}
function installStartupRetries() {
let count = 0;
const timerId = setInterval(() => {
count += 1;
scheduleApply();
if (count >= 20) clearInterval(timerId);
}, 500);
}
function start() {
injectStyle();
hookHistoryMethod("pushState");
hookHistoryMethod("replaceState");
window.addEventListener("hashchange", scheduleApply);
window.addEventListener("popstate", scheduleApply);
window.addEventListener("load", scheduleApply);
const observer = new MutationObserver(() => {
if (!isApplying) scheduleApply();
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ["class", "data-field", "style"],
childList: true,
subtree: true,
});
installStartupRetries();
scheduleApply();
}
if (document.body) {
start();
} else {
window.addEventListener("DOMContentLoaded", start, { once: true });
}
})();