Sleazy Fork is available in English.
Fetches every collection (optionally for the current game) and lists them sorted by mods, downloads, date added and more.
// ==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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
})();