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