☰

Nexus Mods - Collection Sorter

Fetches every collection (optionally for the current game) and lists them sorted by mods, downloads, date added and more.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Nexus Mods - Collection Sorter
// @version      1.3.0
// @author       DeffONotKevO
// @description  Fetches every collection (optionally for the current game) and lists them sorted by mods, downloads, date added and more.
// @match        https://www.nexusmods.com/collections*
// @match        https://www.nexusmods.com/games/*/collections*
// @match        https://www.nexusmods.com/*/collections*
// @grant        none
// @run-at       document-idle
// @namespace https://greasyfork.org/users/1648151
// ==/UserScript==

/*
 * Nexus Mods - Sort Collections by Mod Count
 * Created by DeffONotKevO
 */

(function () {
  'use strict';

  const API = 'https://api-router.nexusmods.com/graphql';
  const PAGE_SIZE = 80; // API caps each request at 80 results
  const CONCURRENCY = 6;
  const ROWS_PER_PAGE = 100;
  const CACHE_TTL_MS = 30 * 60 * 1000;
  const CACHE_VERSION = 2;

  const QUERY = `query CollectionsByModCount($offset: Int, $count: Int, $filter: CollectionsSearchFilter) {
    collectionsV2(offset: $offset, count: $count, filter: $filter) {
      totalCount
      nodes {
        name slug endorsements totalDownloads createdAt lastPublishedAt updatedAt
        user { name }
        game { name domainName }
        tileImage { thumbnailUrl(size: small) }
        latestPublishedRevision { modCount adultContent }
      }
    }
  }`;

  const state = { all: [], filtered: [], desc: true, sortKey: 'mods', page: 0, loadedFor: null };

  function currentGameDomain() {
    const m = location.pathname.match(/^\/(?:games\/)?([^/]+)\/collections/);
    return m ? m[1] : '';
  }

  async function gql(variables) {
    const res = await fetch(API, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({ query: QUERY, variables }),
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const json = await res.json();
    if (json.errors) throw new Error(json.errors.map((e) => e.message).join('; '));
    return json.data.collectionsV2;
  }

  async function fetchAll(gameDomain, onProgress) {
    const cacheKey = `nx-modcount-sort:v${CACHE_VERSION}:${gameDomain || 'all'}`;
    try {
      const cached = JSON.parse(sessionStorage.getItem(cacheKey) || 'null');
      if (cached && Date.now() - cached.t < CACHE_TTL_MS) return cached.items;
    } catch (_) { /* ignore bad cache */ }

    const filter = gameDomain ? { gameDomain: [{ value: gameDomain, op: 'EQUALS' }] } : undefined;
    const first = await gql({ offset: 0, count: PAGE_SIZE, filter });
    const total = first.totalCount;
    const items = [...first.nodes];
    onProgress(items.length, total);

    const offsets = [];
    for (let o = PAGE_SIZE; o < total; o += PAGE_SIZE) offsets.push(o);

    let idx = 0;
    const worker = async () => {
      while (idx < offsets.length) {
        const offset = offsets[idx++];
        let tries = 0;
        for (;;) {
          try {
            const page = await gql({ offset, count: PAGE_SIZE, filter });
            items.push(...page.nodes);
            break;
          } catch (err) {
            if (++tries >= 3) throw err;
            await new Promise((r) => setTimeout(r, 1000 * tries));
          }
        }
        onProgress(items.length, total);
      }
    };
    await Promise.all(Array.from({ length: CONCURRENCY }, worker));

    const seen = new Set();
    const unique = items.filter((c) => {
      const key = `${c.game?.domainName}/${c.slug}`;
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    });

    try {
      sessionStorage.setItem(cacheKey, JSON.stringify({ t: Date.now(), items: unique }));
    } catch (_) { /* quota exceeded - skip caching */ }
    return unique;
  }

  const modCount = (c) => c.latestPublishedRevision?.modCount ?? 0;
  const lastUpdated = (c) => c.lastPublishedAt || c.updatedAt;
  const toTime = (d) => (d ? Date.parse(d) || 0 : 0);
  const fmtDate = (d) => (d ? new Date(d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '-');
  const SORT_VALUES = {
    mods: modCount,
    created: (c) => toTime(c.createdAt),
    updated: (c) => toTime(lastUpdated(c)),
    downloads: (c) => c.totalDownloads ?? 0,
    endorsements: (c) => c.endorsements ?? 0,
  };
  const SORT_LABELS = {
    mods: ['Most mods first', 'Fewest mods first'],
    created: ['Newest first', 'Oldest first'],
    updated: ['Recently updated first', 'Least recently updated first'],
    downloads: ['Most downloads first', 'Fewest downloads first'],
    endorsements: ['Most endorsements first', 'Fewest endorsements first'],
  };
  const fmt = (n) => (n ?? 0).toLocaleString();
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));

  function injectStyles() {
    const css = `
      #nxmc-btn{position:fixed;right:20px;bottom:20px;z-index:99998;background:#d98f40;color:#fff;border:0;border-radius:6px;padding:10px 16px;font:600 14px sans-serif;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.4)}
      #nxmc-btn:hover{background:#e8a055}
      #nxmc-overlay{position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.75);display:none;align-items:center;justify-content:center}
      #nxmc-panel{background:#1b1c1f;color:#e6e6e6;width:min(1100px,95vw);height:90vh;border-radius:8px;display:flex;flex-direction:column;font:14px sans-serif;box-shadow:0 4px 30px rgba(0,0,0,.6)}
      #nxmc-head{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:12px 16px;border-bottom:1px solid #333}
      #nxmc-head h2{margin:0 12px 0 0;font-size:18px;color:#fff}
      #nxmc-head input,#nxmc-head button,#nxmc-head select{background:#2a2c31;color:#e6e6e6;border:1px solid #444;border-radius:4px;padding:6px 8px;font-size:13px}
      #nxmc-head button{cursor:pointer}
      #nxmc-head button:hover{background:#3a3d44}
      #nxmc-head .grow{flex:1}
      #nxmc-status{padding:8px 16px;color:#aaa;font-size:13px}
      #nxmc-list{flex:1;overflow:auto;padding:0 16px}
      #nxmc-list table{width:100%;border-collapse:collapse}
      #nxmc-list th{position:sticky;top:0;background:#1b1c1f;text-align:left;padding:8px 6px;border-bottom:1px solid #444;color:#bbb;font-weight:600}
      #nxmc-list td{padding:6px;border-bottom:1px solid #2b2b2b;vertical-align:middle}
      #nxmc-list tr:hover td{background:#25272b}
      #nxmc-list img{width:72px;height:45px;object-fit:cover;border-radius:3px;display:block;background:#333}
      #nxmc-list a{color:#e8a055;text-decoration:none;font-weight:600}
      #nxmc-list a:hover{text-decoration:underline}
      #nxmc-list .num{text-align:right;white-space:nowrap}
      #nxmc-list .mods{font-weight:700;color:#fff;font-size:15px}
      #nxmc-list .sub{color:#999;font-size:12px}
      #nxmc-foot{display:flex;gap:8px;align-items:center;justify-content:center;padding:10px;border-top:1px solid #333}
      #nxmc-foot button{background:#2a2c31;color:#e6e6e6;border:1px solid #444;border-radius:4px;padding:6px 12px;cursor:pointer}
      #nxmc-foot button:disabled{opacity:.4;cursor:default}
      #nxmc-credit{color:#777;font-size:12px;margin-left:auto}
      #nxmc-credit b{color:#d98f40}
      #nxmc-foot .spacer{margin-right:auto;visibility:hidden}
    `;
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
  }

  function buildUI() {
    injectStyles();

    const btn = document.createElement('button');
    btn.id = 'nxmc-btn';
    btn.textContent = 'Sort by mod count';
    btn.title = 'Nexus Mods Collection Sorter - created by DeffONotKevO';
    document.body.appendChild(btn);

    const overlay = document.createElement('div');
    overlay.id = 'nxmc-overlay';
    overlay.innerHTML = `
      <div id="nxmc-panel">
        <div id="nxmc-head">
          <h2>Collections</h2>
          <input id="nxmc-game" placeholder="game domain (blank = all games)" size="28" />
          <button id="nxmc-load">Load</button>
          <input id="nxmc-search" class="grow" placeholder="Filter by name / author / game..." />
          <input id="nxmc-min" type="number" min="0" placeholder="min mods" style="width:90px" />
          <input id="nxmc-max" type="number" min="0" placeholder="max mods" style="width:90px" />
          <label style="display:flex;align-items:center;gap:4px;font-size:13px"><input id="nxmc-adult" type="checkbox" /> hide adult</label>
          <select id="nxmc-sort" title="Sort by">
            <option value="mods">Sort: mod count</option>
            <option value="created">Sort: date created</option>
            <option value="updated">Sort: last updated</option>
            <option value="downloads">Sort: downloads</option>
            <option value="endorsements">Sort: endorsements</option>
          </select>
          <button id="nxmc-dir">Most mods first</button>
          <button id="nxmc-close" title="Close">X</button>
        </div>
        <div id="nxmc-status"></div>
        <div id="nxmc-list"></div>
        <div id="nxmc-foot">
          <span class="spacer">Created by DeffONotKevO</span>
          <button id="nxmc-prev">Prev</button>
          <span id="nxmc-page"></span>
          <button id="nxmc-next">Next</button>
          <span id="nxmc-credit">Created by <b>DeffONotKevO</b></span>
        </div>
      </div>`;
    document.body.appendChild(overlay);

    const $ = (id) => overlay.querySelector('#' + id);
    const gameInput = $('nxmc-game');
    gameInput.value = currentGameDomain();

    const close = () => { overlay.style.display = 'none'; };
    $('nxmc-close').onclick = close;
    overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
    document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });

    const load = async () => {
      const game = gameInput.value.trim().toLowerCase();
      if (state.loadedFor === game && state.all.length) { applyFilters(); return; }
      $('nxmc-load').disabled = true;
      $('nxmc-list').innerHTML = '';
      $('nxmc-status').textContent = 'Loading collections...';
      try {
        state.all = await fetchAll(game, (n, total) => {
          $('nxmc-status').textContent = `Loading collections... ${fmt(n)} / ${fmt(total)}`;
        });
        state.loadedFor = game;
        applyFilters();
      } catch (err) {
        $('nxmc-status').textContent = 'Failed to load collections: ' + err.message;
      } finally {
        $('nxmc-load').disabled = false;
      }
    };

    const applyFilters = () => {
      const q = $('nxmc-search').value.trim().toLowerCase();
      const min = parseInt($('nxmc-min').value, 10);
      const max = parseInt($('nxmc-max').value, 10);
      const hideAdult = $('nxmc-adult').checked;
      state.filtered = state.all.filter((c) => {
        const m = modCount(c);
        if (!Number.isNaN(min) && m < min) return false;
        if (!Number.isNaN(max) && m > max) return false;
        if (hideAdult && c.latestPublishedRevision?.adultContent) return false;
        if (q) {
          const hay = `${c.name} ${c.user?.name} ${c.game?.name}`.toLowerCase();
          if (!hay.includes(q)) return false;
        }
        return true;
      });
      const val = SORT_VALUES[state.sortKey];
      state.filtered.sort((a, b) => (state.desc ? val(b) - val(a) : val(a) - val(b))
        || modCount(b) - modCount(a)
        || (b.totalDownloads ?? 0) - (a.totalDownloads ?? 0));
      state.page = 0;
      render();
    };

    const render = () => {
      const total = state.filtered.length;
      const pages = Math.max(1, Math.ceil(total / ROWS_PER_PAGE));
      state.page = Math.min(state.page, pages - 1);
      const start = state.page * ROWS_PER_PAGE;
      const rows = state.filtered.slice(start, start + ROWS_PER_PAGE);
      const scope = state.loadedFor ? `for "${state.loadedFor}"` : 'across all games';
      $('nxmc-status').textContent = `${fmt(total)} of ${fmt(state.all.length)} collections ${scope}`;
      $('nxmc-page').textContent = `Page ${state.page + 1} / ${pages}`;
      $('nxmc-prev').disabled = state.page === 0;
      $('nxmc-next').disabled = state.page >= pages - 1;

      $('nxmc-list').innerHTML = `
        <table>
          <thead><tr><th>#</th><th></th><th>Collection</th><th>Game</th><th class="num">Mods</th><th class="num">Created</th><th class="num">Last updated</th><th class="num">Downloads</th><th class="num">Endorsements</th></tr></thead>
          <tbody>${rows.map((c, i) => {
            const url = `https://www.nexusmods.com/games/${esc(c.game?.domainName)}/collections/${esc(c.slug)}`;
            const img = c.tileImage?.thumbnailUrl ? `<img loading="lazy" src="${esc(c.tileImage.thumbnailUrl)}" alt="">` : '<img alt="">';
            return `<tr>
              <td class="sub">${start + i + 1}</td>
              <td><a href="${url}">${img}</a></td>
              <td><a href="${url}">${esc(c.name)}</a><div class="sub">by ${esc(c.user?.name)}</div></td>
              <td class="sub">${esc(c.game?.name)}</td>
              <td class="num mods">${fmt(modCount(c))}</td>
              <td class="num sub" title="${esc(c.createdAt)}">${fmtDate(c.createdAt)}</td>
              <td class="num sub" title="${esc(lastUpdated(c))}">${fmtDate(lastUpdated(c))}</td>
              <td class="num">${fmt(c.totalDownloads)}</td>
              <td class="num">${fmt(c.endorsements)}</td>
            </tr>`;
          }).join('')}</tbody>
        </table>`;
      $('nxmc-list').scrollTop = 0;
    };

    let debounce;
    const onFilterInput = () => { clearTimeout(debounce); debounce = setTimeout(applyFilters, 200); };
    $('nxmc-search').addEventListener('input', onFilterInput);
    $('nxmc-min').addEventListener('input', onFilterInput);
    $('nxmc-max').addEventListener('input', onFilterInput);
    $('nxmc-adult').addEventListener('change', applyFilters);
    $('nxmc-load').onclick = load;
    gameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') load(); });
    const updateDirLabel = () => {
      $('nxmc-dir').textContent = SORT_LABELS[state.sortKey][state.desc ? 0 : 1];
    };
    $('nxmc-dir').onclick = () => {
      state.desc = !state.desc;
      updateDirLabel();
      applyFilters();
    };
    $('nxmc-sort').addEventListener('change', (e) => {
      state.sortKey = e.target.value;
      state.desc = true;
      updateDirLabel();
      applyFilters();
    });
    $('nxmc-prev').onclick = () => { state.page--; render(); };
    $('nxmc-next').onclick = () => { state.page++; render(); };

    btn.onclick = () => {
      overlay.style.display = 'flex';
      load();
    };
  }

  buildUI();
})();