Skip to content

Instantly share code, notes, and snippets.

@cjmaxik
Last active April 23, 2025 16:47
Show Gist options
  • Save cjmaxik/630b1e0d2c0fb6ca1b3ed6034446e605 to your computer and use it in GitHub Desktop.
Save cjmaxik/630b1e0d2c0fb6ca1b3ed6034446e605 to your computer and use it in GitHub Desktop.
Skeb Helper

Skeb Helper

https://cjmaxik.com/skeb-helper

This userscript provides QoL features for Skeb:

  1. Currency conversion
  2. Price alerts (visible self-imposed limits)
  3. Timestamps are based on the locale
  4. Artist's stats on the works pages (prices, response and complete average, complete rate)
  5. NSFW works are blurred out (if NSFW is disabled in Settings, you won't even know the artist does NSFW, this is a good fix)

If you need help, feel free to @ me on Twitter or Discord - CJMAXiK.

Installation

  1. Install Tampermonkey - https://www.tampermonkey.net
  2. Click on this link to install the script - https://gist.github.com/cjmaxik/630b1e0d2c0fb6ca1b3ed6034446e605/raw/script.user.js

License

(c) 2024-2025 CJMAXiK All Rights Reserved

// ==UserScript==
// @name Skeb Helper
// @namespace https://skeb.jp/
// @version 2025-04-23
// @description Helpful tools for Skeb
// @author CJMAXiK
// @license All Rights Reserved
// @homepage https://cjmaxik.com/skeb-helper
// @match https://skeb.jp/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=skeb.jp
// @downloadURL https://gist.github.com/cjmaxik/630b1e0d2c0fb6ca1b3ed6034446e605/raw/script.user.js
// @updateURL https://gist.github.com/cjmaxik/630b1e0d2c0fb6ca1b3ed6034446e605/raw/script.user.js
// @require https://cdn.jsdelivr.net/gh/sizzlemctwizzle/GM_config/gm_config.min.js
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @connect cdn.jsdelivr.net
// ==/UserScript==
"use strict";
let currency;
let rates = {};
let currentPageUrl;
let creatorCache = new Map();
let worksCache = new Set();
let currentAccount;
const apiEndpoint = "https://skeb.jp/api";
const convertValutesSettings = (data) => {
const valutesLines = data.split("\n");
let valutes = {};
try {
valutes = valutesLines.map((line) => {
const values = line.split(",");
if ((values.length !== 1 && values.length !== 2) || !values[1]) {
throw new Error(
`The valute line should have 1 or 2 values split by commas; supplied ${values.length} instead.`
);
}
return {
valute: values[0].trim(),
correction: Number(values[1].trim() ?? 1.0),
};
});
} catch (e) {
console.error(e);
}
return valutes;
};
const convertPriceAlerts = (data) => {
const alertLine = data.trim();
let alerts = {};
try {
const alertsArray = alertLine.split(",");
if (alertsArray.length !== 3) {
throw new Error(
`The alert line should have 3 values split by commas; supplied ${alertsArray.length} instead.`
);
}
alerts = {
valute: alertsArray[0].trim(),
warning: Number(alertsArray[1].trim()),
danger: Number(alertsArray[2].trim()),
};
} catch (e) {
console.error(e);
}
return alerts;
};
// Default settings
const defaultValutes = "rub, 1.05\ntry, 1";
const defaultAlerts = "rub, 2500, 5000";
let settings = {
blurNsfw: false,
conversionEnabled: false,
conversionValutes: convertValutesSettings(defaultValutes),
priceAlerts: convertPriceAlerts(defaultAlerts),
debug: false,
};
// MIGRATION: We are using localStorage instead
GM_deleteValue("works");
let frame = document.createElement("div");
let gms = new GM_config({
id: "SkebHelperConfig",
title: "Skeb Helper Settings",
fields: {
conversionEnabled: {
label: "Enable",
section: ["Currency Conversion & Price Alerts"],
type: "checkbox",
default: false,
},
debug: {
label: "Debug (enable only if asked by the developer)",
type: "checkbox",
default: true,
},
conversionValutes: {
label:
'New line for every currency, <span class="tag">currency, correction</span>',
section: ["Conversion Currencies"],
type: "textarea",
default: defaultValutes,
},
Button: {
label: "Open the list of currencies in a new tab",
type: "button",
size: 100,
click: function () {
window
.open(
"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies.json",
"_blank"
)
.focus();
},
},
Button2: {
label: "Restore the default values",
type: "button",
size: 100,
click: function () {
document.getElementById(
"SkebHelperConfig_field_conversionValutes"
).value = defaultValutes;
},
},
////
priceAlerts: {
label:
'Use this to visually alert yourself on the price of the commission in your currency, <span class="tag">currency, warning, danger</span>',
section: ["Price Alerts"],
type: "textarea",
default: defaultAlerts,
},
Button3: {
label: "Restore the default values",
type: "button",
size: 100,
click: function () {
document.getElementById("SkebHelperConfig_field_priceAlerts").value =
defaultAlerts;
},
},
////
blurNsfw: {
label: "Blur NSFW works",
section: ["NSFW settings"],
type: "checkbox",
default: true,
},
Button4: {
label: "Clear Cache",
section: ["Other settings"],
type: "button",
size: 100,
click: function () {
if (
confirm(
"Do you want to clear the cache?\n\nUse this function only if absolutely necessary."
)
) {
window.localStorage.removeItem("helper-works");
window.location.reload();
}
},
},
Button5: {
label: "Helper's Official Page",
type: "button",
size: 100,
click: function () {
window.open("https://cjmaxik.com/skeb-helper", "_blank").focus();
},
},
},
events: {
init: function () {
const conversionEnabled = this.get("conversionEnabled");
settings = {
blurNsfw: this.get("blurNsfw"),
conversionEnabled,
conversionValutes: convertValutesSettings(
this.get("conversionValutes")
),
priceAlerts: convertPriceAlerts(this.get("priceAlerts")),
debug: this.get("debug"),
};
},
open: function (doc) {
doc.querySelectorAll("textarea").forEach((element) => {
element.classList.add(
"textarea",
"is-static",
"is-underline",
"is-p-8"
);
});
doc.querySelectorAll("checkbox").forEach((element) => {
element.classList.add("checkbox");
});
doc.querySelectorAll("button, input[type=button]").forEach((element) => {
element.classList.add("button", "is-primary");
});
},
save: function () {
window.location.reload();
},
},
css: `
#SkebHelperConfig * { font-family: inherit !important; }
#SkebHelperConfig textarea { field-sizing: content; }
#SkebHelperConfig .field_label { font-size: 1em !important; font-weight: 400 !important; line-height: 1.5 !important; }
#SkebHelperConfig #SkebHelperConfig_wrapper { padding: 10px; }
#SkebHelperConfig .section_header_holder { margin-top: 2rem !important; }
#SkebHelperConfig .tag { font-size: inherit !important; }
`,
frame,
});
const print_debug = (...text) => {
if (!settings.debug) return;
console.debug(...text);
};
// Intercept all requests
(function (open) {
window.XMLHttpRequest.prototype.open = function () {
this.addEventListener(
"readystatechange",
function () {
if (this.readyState == 4) {
intercept(this);
}
},
false
);
open.apply(this, arguments);
};
})(window.XMLHttpRequest.prototype.open);
const makeRequest = (url, additional_headers) => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url,
headers: {
"Content-Type": "application/json",
...additional_headers,
},
onload: function (response) {
resolve(response.responseText);
},
onerror: function (error) {
reject(error);
},
});
});
};
const toCurrencyString = (value, currency) => {
const locale = navigator.languages ?? "en-US";
try {
return value.toLocaleString(locale, {
style: "currency",
currency: currency,
});
} catch (e) {
return `${value.toLocaleString(locale)} ${currency.toUpperCase()}`;
}
};
const updateRates = async () => {
const url = `https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/jpy.min.json?${Math.random()}`;
const data = await makeRequest(url);
const rates = JSON.parse(data);
// 12-hour timeout for the value
GM_setValue("timeout", Date.now() + 12 * 3600 * 1000);
GM_setValue("rates", rates);
print_debug("updateCurrency", rates);
return rates;
};
const getRates = async () => {
if (currency) return;
const timeout = GM_getValue("timeout", null);
const cachedRates = GM_getValue("rates", null);
const rateDate = cachedRates ? Date.parse(cachedRates.date) : Date.now();
print_debug("getRates CACHE", timeout, cachedRates);
// No cache OR no timeout OR timeout is after the current date OR rate internal date is after 2 days from now (failsafe)
if (
!cachedRates ||
!timeout ||
timeout <= Date.now() ||
rateDate + 48 * 3600 * 1000 <= Date.now()
) {
currency = await updateRates();
print_debug("getRates NEW", currency);
} else {
currency = cachedRates;
print_debug("getRates CACHED", currency);
}
};
const getCreatorName = (url) => {
return url.split("/")[3].replace("@", "");
};
const fetchCreatorData = async (creatorName) => {
const url = `https://skeb.jp/api/users/${creatorName}`;
if (creatorCache.has(creatorName)) {
const data = creatorCache.get(creatorName);
print_debug("Using cached creator data:", data);
extractData(url, data);
return creatorCache.get(creatorName);
}
const response = await makeRequest(url, getBearerToken());
const data = JSON.parse(response);
creatorCache.set(creatorName, data);
extractData(url, data);
print_debug("Fetched new creator data:", data);
print_debug("Creator cache size:", creatorCache.size);
return data;
};
const toBlur = "img, .is-thumbnail, .plyr__video-wrapper";
const blurNsfwWorks = () => {
if (!settings.blurNsfw) return;
const elements = document.querySelectorAll(".card-wrapper:not(.done)");
if (!elements.length) return;
print_debug(`Looking for something to blur in ${elements.length} cards...`);
elements.forEach((work) => {
work.classList.add("done");
const id = workPage(work.href);
if (!worksCache.has(id)) return;
work.querySelector(toBlur)?.classList.add("nsfw-blur");
});
};
const findElements = async (node) => {
// 0. All cards
blurNsfwWorks();
currentPageUrl = window.location.href;
if (!node) {
print_debug("-- Node is empty");
node = document.querySelector("section.section");
}
if (node === null) {
print_debug("-- No nodes");
return;
}
let pricesToUpdate = [];
let timestampsToUpdate = [];
// 1. Profile page, left column
if (
currentPageUrl.includes("skeb.jp/@") &&
document.querySelector(".cover-image")
) {
print_debug("-- Profile page, left column");
// Recommended amount
const elements = node.querySelectorAll("small:not(.done)");
// Cannot use `forEach` because we need i+1 element
for (let i = 0; i < elements.length; i++) {
if (elements[i].innerText === "Recommended amount")
pricesToUpdate.push([elements[i + 1], true]);
}
}
// 2. Requests
if (
currentPageUrl.includes("skeb.jp/requests") ||
currentPageUrl.includes("skeb.jp/appeals/")
) {
print_debug("-- Requests page");
// Price, timestamps
node.querySelectorAll("span.tag:not(.done)").forEach((element) => {
var text = element.innerText;
// Price
if (text.includes("JPY")) pricesToUpdate.push([element, false]);
// Timestamps
if (text.includes(" AM") || text.includes(" PM"))
timestampsToUpdate.push([element]);
});
}
// 3. Charges
if (currentPageUrl.includes("skeb.jp/charges")) {
print_debug("-- Charges page");
// Timestamps
node
.querySelectorAll("tbody > tr td:nth-child(3):not(.done)")
.forEach((element) => {
if (element.innerText.includes("/"))
timestampsToUpdate.push([element, false]);
});
// Price
node
.querySelectorAll("tbody > tr td:nth-child(6):not(.done)")
.forEach((element) => {
if (element.innerText.includes("JPY"))
pricesToUpdate.push([element, true]);
});
}
// 4. Work page
if (currentPageUrl.includes("/works/")) {
print_debug("-- Works page");
if (document.querySelector("table#miniProfile")) return;
const creatorName = getCreatorName(currentPageUrl);
await fetchCreatorData(creatorName);
injectCreatorData();
}
// 5. Order page
if (currentPageUrl.endsWith("/order")) {
print_debug("-- Order page");
const amountInput = document.querySelector(
"#new-request-panel > div:nth-child(5) > div.field-body > div > div.control > div > div.control.is-flexible > input:not(.done)"
);
const amountQuestion = document.querySelector(
"#new-request-panel > div:nth-child(5) > div.field-body > div > div.container.is-flex.is-justify-content-flex-end.pt-2 > div:not(.done)"
);
if (!amountInput) return;
const amountTemplate = `
<div data-v-46662776 class="m-0 px-2 py-1 question-box is-size-7 has-background-white-ter" id="amountPlaceholder">
0.00 USD
</div>
`;
amountQuestion.insertAdjacentHTML("afterbegin", amountTemplate);
amountQuestion.classList.add("done");
amountInput.addEventListener("input", amountInputHandler);
amountInput.classList.add("done");
amountInput.dispatchEvent(new Event("input"));
}
if (pricesToUpdate.length || timestampsToUpdate.length)
print_debug("Elements to update:", pricesToUpdate, timestampsToUpdate);
pricesToUpdate.forEach((x) => injectPrice(...x));
timestampsToUpdate.forEach((x) => injectTimestamp(...x));
};
const amountInputHandler = (event) => {
const amountPlaceholder = document.querySelector("#amountPlaceholder");
if (!amountPlaceholder) return;
const jpyPrice = event.target.value;
const convertedPrice = convertPrice(jpyPrice, false);
let separator = " / ";
const finalText = Object.values(convertedPrice).map((value) => {
return value.text;
});
amountPlaceholder.innerText = finalText.join(separator);
};
const injectPrice = async (element, full = true) => {
if (!settings.conversionEnabled || !settings.conversionValutes.length) return;
const jpyPrice = element.innerText
.replace(/[^0-9.,-]+/g, "")
.replace(".", "")
.replace(",", "");
const convertedPrice = convertPrice(jpyPrice);
const alerts = settings.priceAlerts;
if (convertedPrice[alerts.valute]?.value >= alerts.danger) {
element.style.color = "#FF0000";
} else if (convertedPrice[alerts.valute]?.value >= alerts.warning) {
element.style.color = "#FFFF00";
}
element.title = `Original price: ${element.innerText}`;
let separator = " / ";
if (full) separator = "<br/>";
const finalText = Object.values(convertedPrice).map((value) => {
return value.text;
});
element.innerHTML = finalText.join(separator);
element.classList.add("done");
};
const convertPrice = (originalPrice, includeJpy = true) => {
originalPrice = Number(originalPrice);
let convertedPrices = {};
if (includeJpy) {
convertedPrices.jpy = {
value: originalPrice,
text: toCurrencyString(originalPrice, "jpy"),
};
}
settings.conversionValutes.forEach((valute) => {
const value = Math.floor(
originalPrice * rates[valute.valute] * valute.correction
);
convertedPrices[valute.valute] = {
value,
text: toCurrencyString(value, valute.valute),
};
});
print_debug("Converted prices", convertedPrices);
return convertedPrices;
};
const formatDays = (time) => {
let text = "Unknown";
if (time) {
const days = Math.floor(time / 60 / 60 / 24);
if (days) {
text = `${days} days`;
} else {
text = "< 1 day";
}
}
return text;
};
const today = new Date();
const injectTimestamp = (element, withTime = true) => {
const date = new Date(element.innerText);
element.title = element.innerText;
if (withTime) {
element.innerText = date.toLocaleString();
} else {
const diff = formatDays((today - date) / 1000);
element.innerText = `${date.toLocaleDateString(
navigator.languages ?? "en-US"
)} (${diff})`;
if (diff >= 150) element.style.color = "#FF0000";
}
element.classList.add("done");
};
const injectCreatorData = () => {
const creatorName = getCreatorName(window.location.href);
if (!creatorCache.has(creatorName)) {
print_debug("No creator data for", creatorName);
return;
}
if (document.querySelector("table#miniProfile")) return;
const element = document.querySelector("section.section div.is-divider");
const table = `
<table class="table is-fullwidth is-narrow" id="miniProfile">
<tbody>
${miniProfile(creatorName)}
</tbody>
</table>
<div class="is-divider"></div>
`;
element.insertAdjacentHTML("afterEnd", table);
};
const skillGenre = {
art: "Artwork",
comic: "Comic",
voice: "Voice",
novel: "Text",
video: "Video",
music: "Music",
correction: "Advice",
};
const miniProfile = (creatorName) => {
let template = "";
const creatorData = creatorCache.get(creatorName);
if (!creatorData.acceptable) {
template += `
<tr>
<td><small style="color: #FF0000">Not seeking</small></td>
<td></td>
</tr>
`;
}
creatorData.skills.forEach((skill) => {
let type = skillGenre[skill.genre] ?? "Unknown";
if (!settings.conversionEnabled) {
const amount = skill.default_amount.toLocaleString(
navigator.languages ?? "en-US",
{
style: "currency",
currency: "jpy",
}
);
template += `
<tr>
<td><small>${type}</small></td>
<td><small>${amount}</small></td>
</tr>
`;
} else {
var convertedPrice = convertPrice(skill.default_amount);
const alerts = settings.priceAlerts;
let style;
if (convertedPrice[alerts.valute]?.value >= alerts.danger) {
style = "color: #FF0000";
} else if (convertedPrice[alerts.valute]?.value >= alerts.warning) {
style = "color: #FFFF00";
}
const finalText = Object.values(convertedPrice).map((value) => {
return value.text;
});
template += `
<tr title="Original price: ${convertedPrice.jpy.text}">
<td><small>${type}</small></td>
<td><small style="${style}">${finalText.join("<br/>")}</small></td>
</tr>
`;
}
});
template += daysTemplate(
creatorData.received_requests_average_response_time,
"Response average"
);
template += daysTemplate(
creatorData.completing_average_time,
"Complete average"
);
template += `
<tr>
<td><small>Total</small></td>
<td><small>${creatorData.received_works_count}</small></td>
</tr>
`;
template += `
<tr>
<td><small>Complete rate</small></td>
<td><small style="${
creatorData.complete_rate < 0.95 ? "color: #ff0000" : ""
}">
${creatorData.complete_rate * 100}%
</small></td>
</tr>
`;
template += `
<tr>
<td><small>Has NSFW works?</small></td>
<td>
<small style="${creatorData.nsfw_acceptable ? "color: #ff0000" : ""}">
${creatorData.nsfw_acceptable ? "Yes" : "No"} (${
creatorData.received_nsfw_works_count
})
</small>
</td>
</tr>
`;
return template;
};
const workPage = (url) => {
return url
.replace("https://skeb.jp", "")
.replace("/@", "")
.replace("/works/", ":");
};
const daysTemplate = (time, text) => {
const days = formatDays(time);
const daysNumber = Number(
days.replace("< ", "").replace(" day", "").replace("s", "")
);
let style = "";
if (time === undefined || daysNumber === NaN || daysNumber > 60) {
style = "color: #FF0000";
}
return `
<tr>
<td><small>${text}</small></td>
<td><small style="${style}">${days}</small></td>
</tr>
`;
};
const getBearerToken = () => {
return {
Authorization: `Bearer ${window.localStorage.token}`,
};
};
const setupUserCardHoverObserver = async () => {
document.body?.addEventListener("mouseover", async (event) => {
let link = null;
// console.log(event.target);
if (event.target.matches("div.card")) {
print_debug("Card hover:", event.target.parentElement);
link = event.target.parentElement?.href;
} else if (event.target.matches("div.title.is-5")) {
print_debug("User link hover:", event.target);
link = event.target.parentElement?.parentElement?.href;
} else if (event.target.matches("div.level-item.is-wrap.is-block")) {
print_debug("User link hover, higher:", event.target);
link = event.target.parentElement?.href;
}
if (!link) return;
print_debug("Links to fetch", link);
const creatorName = getCreatorName(link);
await fetchCreatorData(creatorName);
});
};
const intercept = async (data) => {
if (!data?.responseURL) return;
const url = data.responseURL;
try {
const response = JSON.parse(data.response);
if (url === `${apiEndpoint}/account`) {
currentAccount = response.screen_name;
print_debug("currentAccount is", currentAccount);
return;
}
extractData(url, response);
} catch (e) {
print_debug("Not a JSON:", data);
}
};
const extractData = (url, response) => {
let works = [];
try {
if (url.includes("/friend_works?") || url.includes("/works?")) {
works.push(...response);
} else if (url.includes("/followings")) {
works.push(...response.following_works, ...response.friend_works);
} else if (url.includes("/users/")) {
if ("similar_works" in response) works.push(...response.similar_works);
if ("sent_works" in response) works.push(...response.sent_works);
if ("received_works" in response) works.push(...response.received_works);
} else if (url === apiEndpoint) {
works.push(
...response.new_art_works,
...response.new_comic_works,
...response.new_correction_works,
...response.new_music_works,
...response.new_novel_works,
...response.new_video_works,
...response.new_voice_works,
...response.popular_works
);
} else if (url.includes("/indexes/*/queries?")) {
response.results.forEach((result) => {
if (result.index !== "Request") return;
works.push(...result.hits);
});
}
works.forEach((work) => {
if (work.nsfw || work.hardcore) worksCache.add(workPage(work.path));
});
} catch (e) {
print_debug(e);
}
window.localStorage.setItem("helper-works", JSON.stringify([...worksCache]));
print_debug("Updated the works cache", url, worksCache);
};
let settingsLinkDone = false;
const insertSettingsLink = () => {
if (settingsLinkDone) return;
const dropdown = document.querySelector(
".navbar-menu .navbar-dropdown.is-right"
);
if (!dropdown) {
return;
}
const settingsLink = `<a href="#" data-v-3c8a55f2 class="navbar-item" id="settings-link">Skeb Helper Settings</a><div data-v-3c8a55f2="" class="navbar-divider"></div>`;
// settingsLink.
dropdown.insertAdjacentHTML("afterbegin", settingsLink);
document
.getElementById("settings-link")
.addEventListener("click", (event) => {
gms.open();
});
settingsLinkDone = true;
};
const initSettingsModal = () => {
document.body.appendChild(frame);
// Open settings pane if this is the first time
if (GM_getValue("firstTime") !== true) {
gms.open();
GM_setValue("firstTime", true);
}
insertSettingsLink();
print_debug("Current settings:", settings);
};
const main = async () => {
print_debug("Main...");
// Init settings modal
initSettingsModal();
// Setup card observer
await setupUserCardHoverObserver();
// Injecting prices for the first time
await findElements();
// Dynamically inject prices
const observer = new MutationObserver(async (mutations, _observer) => {
for (const mutation of mutations.filter((x) => x.addedNodes.length > 0)) {
const node = mutation.addedNodes[0];
if (["LINK", "STYLE", "SCRIPT", "NOSCRIPT"].includes(node.tagName))
continue;
if (node.tagName === undefined) continue;
if (node.querySelector(".navbar-dropdown")) insertSettingsLink();
await findElements(node);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
});
console.log("Skeb Helper: main() finished");
};
// Add styles
GM_addStyle(`
.nsfw-blur {
filter: blur(10px);
}
.nsfw-blur:hover {
filter: blur(0);
transition: filter 0.1s;
}
`);
// Get works from the storage (cold start)
const worksStorage =
JSON.parse(window.localStorage.getItem("helper-works")) ?? [];
print_debug("Works grabbed from local storage:", worksStorage);
worksCache = new Set(worksStorage.slice(1, 1000));
// Send request to /account for the homepage
await makeRequest(`${apiEndpoint}/account`, getBearerToken());
// Send request to /api for the homepage
const apiResponse = await makeRequest(apiEndpoint, getBearerToken());
extractData(apiEndpoint, JSON.parse(apiResponse));
// Updating currency
await getRates();
print_debug("Current rates", currency);
// Grabbing the rate
settings.conversionValutes.map((valute) => {
rates[valute.valute] = currency.jpy[valute.valute];
});
print_debug("Effective rate", rates);
console.log("Skeb Helper: Initiated");
window.onload = main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment