☰

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