☰

Nexus Mods - Collection Sorter

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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