Greasy Fork is available in English.

Power Tag

Adds a dedicated tag voting panel to https://namemc.com/privacy#vote

// ==UserScript==
// @name             Power Tag
// @namespace        https://bunnynabbit.com
// @version          1.6.1
// @description      Adds a dedicated tag voting panel to https://namemc.com/privacy#vote
// @author           BunnyNabbit
// @license          MIT
// @match            *.namemc.com/privacy
// @match            namemc.com/privacy
// @icon             https://bunnynabbit.com/powertag2.png
// @grant            GM.xmlHttpRequest
// @connect          s.namemc.com
// @contributionURL  https://ko-fi.com/bunnynabbit
// @supportURL       https://t.me/BunnyNabbit
// ==/UserScript==

(async function () {
	'use strict';
	const mainBody = document.querySelector("body > main")
	if (window.location.hash !== "#vote") {
		const button = createElement("button")
		button.innerText = "Looking for Power Tag? Click here."
		button.onclick = () => {
			window.location.hash = "#vote"
			window.location.reload()
		}
		mainBody.prepend(button)
		return
	}
	const pendingSkins = []
	document.querySelector("head > title").innerText = "Power Tag | NameMC"
	// Template HTML from NameMC
	let selectModeTemplate = '<div name="modes" class="card mb-3"><div class="card-header py-1"><strong uses="">Select mode</strong></div><div class="row no-gutters align-items-center border-top p-1 px-3"></div><button class="btn btn-secondary btn-sm" name="survey">Review pending tags</button><button class="btn btn-secondary btn-sm" name="search">Search and review approved tags</button><button class="btn btn-secondary btn-sm" name="trending">Review tags on trending skins</button><button class="btn btn-secondary btn-sm" name="random">Review random skins</button></div>'
	let cardMarkdownTemplate = '<div class="card-header py-1"><strong uses>1 uses</strong></div><div class="card-body checkered p-1 px-3"><a href="/skin/1cadce38d109c22f" target="_blank"><div class="card-body text-center p-1"><img class="drop-shadow auto-size" loading="lazy" width="320" height="160" src=""></div></a></div><div quik="votes"></div><div class="row no-gutters align-items-center border-top p-1 px-3"></div><button class="btn btn-secondary btn-sm" quik="skip">Skip</button>'
	let voteMarkdownTemplate = '<div class="row no-gutters align-items-center border-top p-1 px-3"><div class="col-auto m-1 text-nowrap"><span quik="status">Status:</span> Is <a style="border-radius: 1rem; max-width: 100%; font-weight: bold" class="text-nowrap text-ellipsis btn btn-sm btn-primary" quik="tag" href="" target="_blank">Blue Eyes</a> a good tag?<a quik="google" href="" target="_blank" rel="nofollow" title="Image Search (Google)"><img class="emoji" draggable="false" alt="🖼️" src="https://s.namemc.com/img/frame-with-picture.png"></a><a quik="bing" href="" target="_blank" rel="nofollow" title="Image Search (Bing)"><img style="filter: hue-rotate(150deg);" class="emoji" draggable="false" alt="🖼️" src="https://s.namemc.com/img/frame-with-picture.png"></a></div><div quik="voteButtons" class="col m-1 text-nowrap text-right"><button class="btn btn-outline-success btn-sm" type="submit" name="vote" value="1"><i class="fas fa-thumbs-up"></i></button><button class="btn btn-outline-danger btn-sm" type="submit" name="vote" value="-1"><i class="fas fa-thumbs-down"></i></button></div></div>'
	const references = [
		["ears mod", "Ears is a mod that adds ears, snouts, tails, horns, wings, and more to the player. These features are embedded in the skin file as unused space, you can 🔽 the skin from NameMC and use it with the Ears mod. NameMC does not render Ears data, so you must go to https://ears.unascribed.com/manipulator/ and check if this skin does contain valid Ears data."],
	]
	function getReference(tag, details) {
		console.log({tag})
		let reference = references.find(entry => tag.toLowerCase() == entry[0])
		if (reference) {
			reference = reference[1]
			reference = reference.replace("🔽", `<a target="_blank" href="https://s.namemc.com/i/${details.hash}.png">download</a>`)
			console.log(reference)
			return reference
		}
		return null
	}
	function clearPage() {
		mainBody.innerHTML = ""
	}
	function selectModePage() {
		mainBody.innerHTML = selectModeTemplate
		const selectMode = mainBody.children.modes
		selectMode.children.survey.onclick = () => {
			clearPage()
			modeSurvey()
		}
		selectMode.children.search.onclick = () => {
			const tag = prompt("Search tag")
			if (tag) {
				clearPage()
				modeSearch(`tag/${tag}`)
			}
		}
		selectMode.children.trending.onclick = () => {
			clearPage()
			modeSearch(`trending`)
		}
		selectMode.children.random.onclick = () => {
			clearPage()
			modeSearch(`random`)
		}
	}
	function createElement(tag, className = "") {
		const element = document.createElement(tag)
		element.className = className
		return element
	}
	function getFrontAndBackImage(skin, model) {
		return `https://s.namemc.com/3d/skin/body.png?id=${skin}&model=${model}&width=320&height=201&front_and_back=true`
	}
	function cleanSkinUrl(url) {
		return url.replace("https://namemc.com/skin/", "").replace(location.origin + /skin/, "")
	}
	function changeTag(el, newTagName, keepAttributes = true) { // https://gist.github.com/Daniel-Hug/d245b53b6195b9596c00659cb17cda6c
		var newEl = document.createElement(newTagName)

		// Copy the children
		while (el.firstChild) {
			newEl.appendChild(el.firstChild) // *Moves* the child
		}

		// Copy the attributes
		if (keepAttributes) {
			for (var i = el.attributes.length - 1; i >= 0; --i) {
				newEl.attributes.setNamedItem(el.attributes[i].cloneNode())
			}
		}

		// Replace it
		el.parentNode.replaceChild(newEl, el)
		return newEl
	}
	function getSkinDetails(skin = "31cfd17dee5fb3b0", datum) { // Collect some valid data of the skin
		function processData(data) {
			const voteElements = data.querySelectorAll("#tag-dialog > div > div > div.modal-body.border-bottom.p-0 > div > table > tbody > tr")
			const tags = []
			for (let index = 0; index < voteElements.length; index++) {
				const element = voteElements[index].children
				tags.push({
					status: element[0].innerText.trim(),
					name: element[1].innerText.trim(),
					disabled: element[1].children[0].disabled,
					votes: parseInt(element[2].innerText.trim().replace("−", "-")),
					upHilight: !element[3].children.vote.className.includes("outline"),
					downHilight: !element[4].children.vote.className.includes("outline")
				})
			}
			return {
				hash: skin,
				randomSkin: cleanSkinUrl(data.querySelector("a[href^=\"/skin/\"] div").parentElement.href),
				usersWearing: parseInt(data.querySelector("strong").innerText.match(/\d/g)?.join('') ?? 0, 10),
				model: data.querySelector("canvas").getAttribute("data-model"),
				hasEars: hasEarsData(skin), // promise
				tags: tags
			}
		}
		function fetchData(resolve) {
			fetch(`${location.origin}/skin/${skin}`)
				.then(response => response.text())
				.then(str => new window.DOMParser().parseFromString(str, "text/html"))
				.then(async data => {
					if (data.body.innerHTML.includes("Error: 429 (Too Many Requests)")) {
						console.log("429 detected, stalling")
						await sleep(2000)
						fetchData(resolve)
					}
					resolve(processData(data))
				})
		}
		if (datum) {
			return processData(datum)
		} else return new Promise(resolve => fetchData(resolve))
	}
	function hasEarsData(hash) {
		return new Promise(resolve => {
			GM.xmlHttpRequest({
				url: `https://s.namemc.com/i/${hash}.png`,
				responseType: "arraybuffer",
				onload: function (response) {
					const loadImage = new Image()
					loadImage.src = URL.createObjectURL(new Blob([this.response]))
					loadImage.onload = ev => {
						const canvas = document.createElement("canvas")
						canvas.width = loadImage.width
						canvas.height = loadImage.height
						const ctx = canvas.getContext("2d")
						ctx.drawImage(loadImage, 0, 0)
						// checks if the skin contains the "magic pixels"
						const pixel = ctx.getImageData(0, 32, 1, 1).data
						resolve(pixel[0] == 63 && pixel[1] == 35 && pixel[2] == 216 || pixel[0] == 234 && pixel[1] == 37 && pixel[2] == 1)
					}
				},
				onerror: function () { resolve(null) }
			})
		})
	}
	function createCard(details, replaceElement, next) {
		let rediv = createElement("div") // to refresh
		if (replaceElement) {
			replaceElement.innerText = ""
			rediv = replaceElement
		}
		const cardElement = createElement("div", "card mb-3")
		cardElement.innerHTML = cardMarkdownTemplate
		cardElement.querySelector("a > div > img").src = getFrontAndBackImage(details.hash, details.model)
		const cardHeader = cardElement.querySelector("strong[uses]")
		cardHeader.innerText = `${details.usersWearing} wearing | Model: ${details.model}`
		details.hasEars.then((itDoes) => {
			if (itDoes) cardHeader.innerHTML += ` | <span title="The skin likely has embedded data for the Ears mod.">Has Ears data</span>`
		})
		cardElement.querySelector("a").href = `${location.origin}/skin/${details.hash}`
		const tagContainer = cardElement.querySelector("div[quik=\"votes\"]")
		details.tags.forEach((tag) => {
			let tagElement = createElement("div")
			tagElement.innerHTML = voteMarkdownTemplate
			// maybe use "name" instead of "quik"? a bunch of the DOM API supports using that and is much cleaner.
			tagContainer.append(tagElement)
			if (tag.disabled) {
				changeTag(tagElement.querySelector("[quik=\"tag\"]"), "button")
				tagElement.querySelector("[quik=\"tag\"]").disabled = tag.disabled
			}
			tagElement.querySelector("[quik=\"status\"]").innerText = `${tag.status} (${tag.votes}) |`
			tagElement.querySelector("[quik=\"tag\"]").innerText = tag.name
			tagElement.querySelector("[quik=\"tag\"]").href = `/minecraft-skins/tag/${tag.name}`
			const voteButtons = tagElement.querySelector("[quik=\"voteButtons\"]").children
			const voteAmounts = [1, -1]
			if (tag.upHilight) {
				voteButtons[0].classList.remove("btn-outline-success")
				voteButtons[0].classList.add("btn-success")
				voteAmounts[0] = 0
			}
			if (tag.downHilight) {
				voteButtons[1].classList.remove("btn-outline-danger")
				voteButtons[1].classList.add("btn-danger")
				voteAmounts[1] = 0
			}
			async function postVote(amount) {
				fetch(`${location.origin}/skin/${details.hash}`, {
					method: 'POST',
					headers: {
						'Content-Type': 'application/x-www-form-urlencoded'
					},
					body: new URLSearchParams({
						"task": "vote-tag",
						"tag": tag.name,
						"vote": `${amount}`
					})
				})
					.then(response => response.text())
					.then(str => new window.DOMParser().parseFromString(str, "text/html"))
					.then(async (data) => {
						totalVotes++
						let newCardDetails = null
						let remove = true
						try {
							newCardDetails = getSkinDetails(details.hash, data || true)
							for (let index = 0; index < newCardDetails.tags.length; index++) {
								const other = newCardDetails.tags[index]
								if (!other.upHilight && !other.downHilight) remove = false
							}
						} catch (error) {
							alert("Error. Try again later?")
							console.log(error)
							return
							//rediv.remove()
						}
						if (remove) {
							rediv.remove()
							next(details, "allVoted")
						} else createCard(newCardDetails, rediv, next) // refresh card with new voting data
					})
			}
			voteButtons[0].onclick = () => { postVote(voteAmounts[0]) }
			voteButtons[1].onclick = () => { postVote(voteAmounts[1]) }
			tagElement.querySelector("[quik=\"google\"]").href = `https://www.google.com/search?q=${tag.name}&tbm=isch&amp;safe=active`
			tagElement.querySelector("[quik=\"bing\"]").href = `https://www.bing.com/images/search?q=${tag.name}`
			const reference = getReference(tag.name, details)
			console.log(reference)
			if (reference) {
				let referenceElement = createElement("p", "col-auto m-1 px-3")
				referenceElement.innerHTML = reference
				tagElement.append(referenceElement)
			}
		})
		cardElement.querySelector("[quik=\"skip\"]").onclick = async () => {
			rediv.remove()
			next(details, "skip")
		}
		rediv.append(cardElement)
		return rediv
	}
	selectModePage()
	let totalVotes = 0
	function breakHandler() {
		totalVotes = 0
		let breakTime = 0
		let breakTimeInterval = setInterval(() => {
			if (mainBody.innerText === "") {
				breakTime++
				if (breakTime > 3) {
					clearInterval(breakTimeInterval)
					selectModePage()
				}
			} else {
				breakTime = 0
			}
		}, 1000)
	}
	async function modeSurvey() {
		async function next(details, action) {
			let randomSkin = await getSkinDetails(details.randomSkin)
			mainBody.append(createCard(randomSkin, null, next))
		}
		let result = await getSkinDetails()
		for (let i = 0; i < 2; i++) {
			result = await getSkinDetails(result.randomSkin)
			mainBody.append(createCard(result, null, next))
		}
		breakHandler()
	}
	function sleep(ms) {
		return new Promise((resolve) => {
			setTimeout(resolve, ms)
		})
	}
	async function modeSearch(feature) {
		const cache = []
		const scrapeTime = 8000
		const maxVisible = 2
		async function next(details, action) {
			let cachedSkin = cache[0]
			cache.shift()
			if (!cachedSkin) return
			cachedSkin = await getSkinDetails(cachedSkin)
			mainBody.append(createCard(cachedSkin, null, next))
		}
		async function getPage(page) {
			fetch(`https://namemc.com/minecraft-skins/${feature}?page=${page}`)
				.then(response => response.text())
				.then(str => new window.DOMParser().parseFromString(str, "text/html"))
				.then(async (data) => {
					const skinElements = data.querySelectorAll('a[href*="/skin/"]')
					for (let i = 0; i < skinElements.length; i++) {
						const skinElement = skinElements[i]
						cache.push(cleanSkinUrl(skinElement.href))
					}

					if (skinElements.length < 30) return
					await sleep(scrapeTime)
					getPage(page + 1)
				})
		}
		let populateTime = 0
		setInterval(() => {
			const cardCount = mainBody.querySelectorAll("strong[uses]").length
			if (cardCount < maxVisible) {
				populateTime++
			} else populateTime = 0
			if (populateTime > 5) {
				populateTime = 0
				next(null, "populate")
			}
		}, 400)
		getPage(1)
		breakHandler()
	}
})()