Skip to content

Instantly share code, notes, and snippets.

@7nik
Last active February 25, 2024 05:32
Show Gist options
  • Save 7nik/11176311a1a59de8ae3f36f33f21d9fd to your computer and use it in GitHub Desktop.
Save 7nik/11176311a1a59de8ae3f36f33f21d9fd to your computer and use it in GitHub Desktop.
A NeonMob series downloader

This usescript downloads the originals of all the series' cards, the cover, and the general metadata (names, description, creator).

Installation

  1. To use usercripts, you need an appropriate extension. I recommend Tampermonkey (Chrome Webstore, Firefox Add-ons).
  2. Then press the Raw button against the userscript and the extension will automatically offer you to install it.

Shared downloads

In this Google Sheet you can find some downloaded and shared series or add there your series.

// ==UserScript==
// @name NM Series downloader
// @namespace http://tampermonkey.net/
// @version 1.10.1
// @description try to take over the world!
// @author 7nik
// @match https://www.neonmob.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=neonmob.com
// @grant GM.download
// @grant GM.addStyle
// ==/UserScript==
"use strict";
GM.addStyle(`
div.sett-card--collect-it, span.card--actions {
display: flex;
}
div.sett-card--collect-it > div:first-child {
flex-grow: 1;
}
body > .btn-download,
h1.set-header--title > .btn-download,
.card--actions > .btn-download,
div.sett-card--collect-it > .btn-download {
width: min-content;
min-width: 35px;
min-height: 34px;
margin-left: 1ch;
}
body > .btn-download {
position: fixed;
left: 0;
top: 60px;
}
.btn-download:empty {
background-image: url('data:image/svg+xml,%3csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 29 31"%3e%3cpath xmlns="http://www.w3.org/2000/svg" fill="white" d="M25 19.1V26H4V19H0V28c0 1 .9 2 2 2h25a2 2 0 0 0 2-2V19h-4zM14.1 18.4l-5.7-7s-1-.8 0-.8h3.3V.5s-.1-.5.6-.5h4.6c.5 0 .5.4.5.4v10h3c1.2 0 .3.9.3.9s-5 6.5-5.6 7.2c-.5.5-1 0-1 0z"/%3e%3c/svg%3e');
background-repeat: no-repeat;
background-size: 25px;
background-position: center;
}
`);
/* eslint-disable no-await-in-loop */
const json = async (url) => {
for (let i = 0; i < 3; i++) {
const r = await fetch(url);
if (r.ok) return r.json();
}
throw new Error("max attempts reached", url);
};
function download (url, name) {
return new Promise((res, rej) => GM.download({
url, name, onload: res, onerror: rej, ontimeout: rej,
}));
}
async function downloadCard (assets, name) {
const sizes = ["original", "xlarge", "large", "medium", "small"];
let size;
let url;
do {
if (!url) {
// eslint-disable-next-line no-restricted-syntax
for (const s of sizes) {
if (assets[s] && (assets[s].url || assets[s]?.sources?.[0]?.url)) {
size = s;
url = assets[s].url ?? assets[s].sources[0].url;
break;
}
}
if (!url) {
console.error("No url to download", assets);
throw new Error("No url to download");
}
if (url.startsWith("//")) url = `https:${url}`;
}
const s = size === "original" ? "" : ` ${size}`;
let parts = url.split("/").at(-1).split(".");
const ext = parts.length > 1 ? parts.at(-1) : "";
parts = name.split("/");
const fullName = ext
? `${name}${s}.${ext}`
: [...parts.slice(0, -1), parts.at(-1).replaceAll(".", "_").concat(s)].join("/");
try {
await download(url, fullName);
return fullName.split("/").at(-1);
} catch (ex) {
console.error(assets, name, ex);
const t = Date.now();
if (ex?.error === "not_whitelisted") {
alert(`Whitelist the file extension:
open Tampermokey's Settings,
set Config mode to Begginer,
go to the Downloads BETA section,
add ".${ext}" to the field and press Save below the field.
Then press OK here.`);
} else if (ex?.error === "not_succeeded") {
parts = url.split("/");
let newName = parts.pop();
newName = prompt(`Couldn't download the file ${fullName} from ${url}
Try add, change, or remove the file extension.
Press Cancel to download the xlarge thumbnail.`, newName);
if (newName) {
parts.push(newName);
url = parts.join("/");
} else {
sizes.shift();
url = "";
}
} else if (!("error" in ex)) {
parts = name.split("/");
let newName = parts.at(-1);
newName = prompt(`Couldn't save the file ${fullName} from ${url}
Seems the name contains forbiden characters. Try to remove them.
Press Cancel to try auto-renaming.`, newName);
if (newName) {
parts.splice(-1, 1, newName);
} else {
newName = parts.pop().replace(/[^\u0020-\u007F]/g, "_");
parts.push(newName);
}
name = parts.join("/");
} else {
throw ex;
}
// if the dialog was auto-closed
if (Date.now() - t < 100) {
await new Promise((res) => setTimeout(res, 3000));
}
}
} while (true);
}
async function getCardInfo (cardId) {
try {
const { payload, refs } = await json(`https://www.neonmob.com/api/users/${NM.you.attributes.id}/piece/${cardId}/detail/`);
const card = refs[payload[1]];
card.rarity = refs[card.rarity[1]];
return card;
} catch {
const { payload, refs } = await json(`https://www.neonmob.com/api/pieces/${cardId}/`);
payload.rarity = refs[payload.rarity[1]];
return payload;
}
}
// setts where the fast endpoint returns wrong data
// eslint-disable-next-line comma-spacing, max-len
const exceptIDs = [70,159,181,188,197,198,214,295,302,315,316,329,356,357,382,450,471,476,580,606,757,834,835,870,902,919,961,1097,1105,1175,1189,1334,1336,1347,1370,1387,1418,1446,1475,1538,1541,1803,1954,2353,2707,2971,3045,3263,3308,3310,3329,3357,3932,3966,4034,4113,4121,4254,4435,4436,4922,5192,5502,5592,5615,5688,5699,5939,5960,6094,6327,6328,6385,6531,7048,7327,7359,7434,7665,7919,7947,8017,8019,8020,8108,8127,8165,8168,8254,8299,8521,8694,8696,8834,8915,8921,8928,9017,9414,9497,9608,9640,9856,9937,9953,10083,10085,10195,10420,10616,10627,10717,10770,10884,10934,10959,10978,11225,11250,11339,11461,11464,11714,11896,11963,11994,12132,12148,12597,12603,12646,12816,12879,12920,12925,13405,13461,13614,13709,14023,14231,14272,14408,14414,14488,14648,14741,14882,14982,15120,15156,15567,15571,16100,16145,16348,16436,16676,16693,16819,16833,17344,17381,17596,17630,17671,17945,18085,18115,18495,18629,18693,18721,18964,20611,20964,21056,21166,21178,21239,21249,21441,22219,22988,24427,24440,24763,24778,24949,25278,25601,25654];
// eslint-disable-next-line no-control-regex
const badChars = /[\u0000-\u001F"*/:<>?\\|\u007F\u200B]|[\u200D\uD800-\uDB7F\uDC00-\uDFFF]+|\.+\s*$/g;
async function downloadSeries (settId, elem, rootDir) {
elem.textContent = "0/???";
const sett = await json(`https://www.neonmob.com/api/setts/${+settId}/`);
let dir = sett.name.replace(badChars, "_").trim();
if (rootDir) dir = `${rootDir}/${dir}`;
await downloadCard(sett.sett_assets, `${dir}/cover`);
const cardsInfo = [];
let cards = [];
try {
if (exceptIDs.includes(settId)) throw new Error("Don't use fast endpoint");
let url = `/api/sets/${settId}/pieces/`;
do {
const data = await json(url);
cards = cards.concat(data.payload.results.map((card) => {
card.rarity = data.refs[card.rarity[1]];
return card;
}));
url = data.payload.metadata.resultset.link.next;
} while (url);
} catch {
cards = await json(`https://napi.neonmob.com/user/${NM.you.attributes.id}/sett/${settId}`);
}
for (let i = 0; i < cards.length; i++) {
elem.textContent = `${i}/${cards.length}`;
const card = "description" in cards[i] ? cards[i] : await getCardInfo(cards[i].id);
let filename = `${i + 1} ${card.name.replace(badChars, "_")} ${card.id}`.trim();
filename = await downloadCard(card.piece_assets[card.asset_type], `${dir}/${filename}`);
cardsInfo.push(`Name: ${card.name}
Filename: ${filename}
Rarity: ${card.rarity.name}
Description: ${card.description}
`);
}
elem.textContent = `${cards.length}/${cards.length}`;
const settInfo = `Name: ${sett.name}
Creator: ${sett.creator.name}
Difficulty: ${sett.difficulty.name}
Cards: ${cards.length}
Released: ${new Date(sett.released).toGMTString()}
Discontinued: ${new Date(sett.discontinued ?? sett.discontinue_date).toGMTString()}
Description: ${sett.description}`;
await download(`data:text/plain,${encodeURIComponent(settInfo)}`, `${dir}/series info.txt`);
await download(`data:text/plain,${encodeURIComponent(cardsInfo.join("\n"))}`, `${dir}/cards info.txt`);
elem.textContent = ``;
}
function addButton (target) {
if (!target) return false;
const button = document.createElement("span");
button.className = "btn reward btn-download";
button.style.verticalAlign = "middle";
button.addEventListener("click", () => {
if (GM.info.downloadMode === "native") {
alert(`Please, switch download mode to the Brower API in the TM's settings!
open Tampermokey's Settings,
set Config mode to Begginer,
go to the Downloads BETA section,
set Download Mode to Browser API and press Save below the field.
Then restart this page and start downloading again.`);
return;
}
let container = target.closest("[art-sett-card],[art-sett-card-full]");
if (!container) container = document.querySelector(".set-detail-page--container");
const settId = angular.element(container).scope().sett.id;
button.classList.remove("reward");
button.classList.add("danger");
downloadSeries(settId, button).finally(() => button.classList.remove("danger"));
});
target.after(button);
return true;
}
setInterval(() => {
document.querySelectorAll(".sett-card--collect-it > div:only-child, .card--actions > [nm-collect-it-button]:only-child, .set-header--title a:only-child")
.forEach((el) => addButton(el));
}, 300);
unsafeWindow.downloadSeries = async (ids, rootDir) => {
if (typeof ids === "number") ids = [ids];
if (typeof ids === "string") ids = ids.match(/\d+/g);
const button = document.createElement("span");
button.className = "btn danger btn-download";
button.innerHTML = "<span></span>, <span></span>, <span>0</span> errors";
document.body.append(button);
let err = 0;
for (let i = 0; i < ids.length; i++) {
button.firstChild.textContent = `${i}/${ids.length}`;
try {
await downloadSeries(ids[i], button.children[1], rootDir);
} catch (ex) {
console.error("Error during downloading", ids[i], ex);
err += 1;
button.lastElementChild.textContent = err;
}
}
button.firstChild.textContent = `${ids.length}/${ids.length}`;
button.classList.remove("danger");
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment