Skeb enhance

Supports multi-currency calculation to optimize user experience

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Skeb enhance
// @name:zh-CN   Skeb 增强
// @name:ja-JP   Skeb強化
// @name:en-US   Skeb enhance
// @namespace    http://tampermonkey.net/
// @version      2026-05-16
// @description  Supports multi-currency calculation to optimize user experience
// @description:zh-CN  支持计算多国汇率优化使用体验
// @description:ja-JP  複数通貨の換算に対応し、使用体験を最適化
// @description:en-US  Supports multi-currency calculation to optimize user experience
// @author       xiyuesaves
// @license      AGPLv3
// @match        https://skeb.jp/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=skeb.jp
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      cdn.jsdelivr.net
// ==/UserScript==

const isDebug = true
const langCode = (navigator.language || "en").split('-')[0].toLowerCase();
const i18nMap = {
  "zh": {
    "currency": "cny",
    "art": "插图",
    "comic": "漫画",
    "genre": "文本",
    "novel": "小说",
    "video": "视频",
    "voice": "语音",
    "correction": "建议",
    "title": "委托开放前作者可随时修改价格",
    "price": "建议金额",
    "referenceOnly": "暂未开放,仅作参考",
    "waiting": "获取中",
    "error": "获取失败",
    "loading": "加载中"
  },
  "en": {
    "currency": "usd",
    "art": "Art",
    "comic": "Comic",
    "genre": "Genre",
    "novel": "Novel",
    "video": "Video",
    "voice": "Voice",
    "correction": "Suggestion",
    "title": "Author can modify the price before the commission opens",
    "price": "Suggested Amount",
    "referenceOnly": "Not yet open, for reference only",
    "waiting": "Waiting",
    "error": "Failed to fetch",
    "loading": "Loading"
  },
  "ja": {
    "currency": "jpy",
    "art": "イラスト",
    "comic": "漫画",
    "genre": "ジャンル",
    "novel": "小説",
    "video": "動画",
    "voice": "音声",
    "correction": "提案",
    "title": "委託が公開される前に作者は価格を変更可能です",
    "price": "推奨金額",
    "referenceOnly": "未公開、参考用のみ",
    "waiting": "取得中",
    "error": "取得失敗",
    "loading": "読み込み中"
  }
};
const localLang = i18nMap[langCode] ? langCode : "en";
const targetCurrency = i18nMap[localLang]?.currency || i18nMap["en"].currency;
const container = createElement("skeb-enhanced-container");
const wrapper = createElement("skeb-enhanced-wrapper");
const ajaxList = [];
const listenList = [];
const usersMap = getUserMap()
const currencies = {
  day: 0,
  data: null
};
const log = (...args) => isDebug && console.log(...args);
const originOpen = XMLHttpRequest.prototype.open;
const originSend = XMLHttpRequest.prototype.send;
const originHeader = XMLHttpRequest.prototype.setRequestHeader;

let isLoading = false
let awaitList = [];

XMLHttpRequest.prototype.open = function () {
  this.addEventListener("load", function (obj) {
    const url = obj.target.responseURL;
    listenList.forEach((el) => {
      if (el.rule.test(url)) {
        const find = ajaxList.find((el) => el.xml === this);
        if (find) {
          find.url = url;
          find.res = JSON.parse(this.response);
          el.callback(find);
        } else {
          el.callback(false);
        }
      }
    });
  });
  originOpen.apply(this, arguments);
};

XMLHttpRequest.prototype.send = function () {
  const xml = ajaxList.find((el) => el.xml === this);
  if (xml) {
    xml.send = JSON.parse(arguments[0]);
  }
  originSend.apply(this, arguments);
};

XMLHttpRequest.prototype.setRequestHeader = function () {
  const xml = ajaxList.find((el) => el.xml === this);
  if (xml) {
    xml.header[arguments[0]] = arguments[1];
  } else {
    ajaxList.push({
      xml: this,
      url: "",
      header: {
        [arguments[0]]: arguments[1],
      },
    });
  }
  originHeader.apply(this, arguments);
};

function listenAjax(rule, callback) {
  listenList.push({
    rule,
    callback,
  });
}

function createElement(className, tagName = "div") {
  const element = document.createElement("div")
  element.className = className
  return element;
}

function insertContainer() {
  const firstSelector = "#root > main > div > section:nth-child(1) > div > div > div.column.is-5 > div:nth-child(1) > div:nth-child(2)"
  const scondSelector = "#root > main > div > section.section > div > div.columns > div.column.is-3 > div > div > table"
  const timer = setInterval(() => {
    if (document.querySelector(".skeb-enhanced")) {
      clearInterval(timer)
      return;
    }
    const divider = document.querySelector(firstSelector)
    if (divider) {
      divider.insertAdjacentElement("afterend", container)
      clearInterval(timer)
      return;
    }
    const table = document.querySelector(scondSelector)
    if (table) {
      table.insertAdjacentElement("beforebegin", container)
      clearInterval(timer)
    }
  }, 100)
}

function formatted(price, lang = localLang) {
  return new Intl.NumberFormat(lang, {
    maximumFractionDigits: 20
  }).format(price);
}

function gmRequest(url, options = {}) {
  const { method = "GET", headers = {}, body, responseType = "text", timeout = 15000 } = options;
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      method,
      url,
      headers,
      data: body,
      responseType,
      timeout,
      withCredentials: true,
      onload: (response) => {
        response.ok = response.status >= 200 && response.status < 300;
        response.text = () => Promise.resolve(response.responseText || "");
        response.json = async () => JSON.parse(await response.text());
        resolve(response);
      },
      onerror: reject,
      ontimeout: () => reject(new Error(`request timeout: ${url}`)),
      onabort: () => reject(new Error(`request abort: ${url}`)),
    });
  });
}

async function updateCurrencies() {
  try {
    if (isLoading) return
    isLoading = true
    const res = await gmRequest("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/jpy.json", {
      responseType: "json"
    });
    const data = await res.json()

    currencies.data = data.jpy;
    currencies.day = new Date().getDay();

    log("updateCurrencies", currencies.data)

    awaitList.forEach(item => {
      const span = document.querySelector(`#${item.id}`)
      if (span) {
        span.innerText = getExchangeRate(item.price, targetCurrency)
        delete span.id
      }
    })
    awaitList = [];
  } catch (err) {
    console.error(err)
    awaitList.forEach(item => {
      const span = document.querySelector(`#${item.id}`)
      if (span) {
        span.innerText = i18n("error")
        span.title = err.message || err
        delete span.id
      }
    })
    awaitList = [];
  } finally {
    isLoading = false
  }
}

function exchangeRate(price) {
  const span = document.createElement("span");
  if (!currencies.data || new Date().getDay() !== currencies.day) {
    span.innerText = i18n("waiting");
    span.id = `id-${crypto.randomUUID()}`
    awaitList.push({
      id: span.id,
      price
    });
    updateCurrencies();
  } else {
    span.innerText = getExchangeRate(price, targetCurrency);
  }
  return span.outerHTML;
}

function getExchangeRate(price, targetCurrency) {
  const rate = currencies.data[targetCurrency.toLowerCase()];
  const convertedAmount = rate * price;
  return formatted(convertedAmount.toFixed(2));
}

function i18n(key, lang = localLang) {
  if (i18nMap?.[lang]?.[key]) {
    return i18nMap[lang][key]
  }
  return key
}

function updateInfo(userInfo) {
  container.innerHTML = "";
  container.insertAdjacentElement("afterbegin",
    html`
<table style="background: transparent" class="table is-narrow is-fullwidth">
    <thead>
        <tr>
            <th colspan="2">
                <small title="${userInfo.acceptable ? '' : i18n("title")}">
                    ${i18n("price")}${userInfo.acceptable ? "" : " (" + i18n("referenceOnly") + ")"}
                </small>
            </th>
        </tr>
    </thead>
    <tbody>
        ${createSkillsList(userInfo)}
    </tbody>
</table>`)
}

function createSkillsList(userInfo) {
  return userInfo.skills.map(el => {
    const amountStr = `JPY ${formatted(el.default_amount, "ja")}` +
      (localLang !== 'ja'
        ? ` ~ ${targetCurrency.toUpperCase()} ${exchangeRate(el.default_amount)}`
        : '');
    return `<tr>
                <td><small>${i18n(el.genre)}</small></td>
                <td><small>${amountStr}</small></td>
            </tr>`;
  }).join("")
}

async function getUserInfo(userId) {
  let data = usersMap.get(userId)
  log(data)
  if (!data || new Date().getTime() - (data?.time ?? 0) > 24 * 60 * 60 * 1000) {
    userInfo = await (await fetch(`/api/users/${userId}`, {
      headers: {
        authorization: `Bearer ${localStorage.getItem("token")} `
      }
    })).json()
    data = {
      userInfo,
      time: new Date().getTime()
    }
  }

  usersMap.set(userId, data)
  updateUserMap()
  return data.userInfo
}

function updateUserMap() {
  localStorage.setItem("usersMap", JSON.stringify(Array.from(usersMap)))
}

function getUserMap() {
  try {
    const localMap = new Map(JSON.parse(localStorage.getItem("usersMap") || "[]"))
    const newMap = new Map()
    localMap.forEach((value, key) => {
      if (new Date().getTime() - value.time < 24 * 60 * 60 * 1000) {
        newMap.set(key, value)
      }
    })
    return localMap
  } catch (e) {
    return new Map()
  }
}

function initStyle() {
  GM_addStyle(css`
.skeb-enhanced-container {
    border: solid 1px #cbd0d7;
    border-radius: 8px;
    background: #e4e6ea;
    box-sizing: border-box;
    padding: 8px;
    width: 100%;
    display: flex;
    justify-content: space-between;
    height: auto;
    margin-bottom: 1.5rem;
}`);
}

function html(strings, ...values) {
  let result = "";
  for (let i = 0; i < strings.length; i++) {
    result += strings[i];
    if (i < values.length) {
      result += values[i];
    }
  }
  const domParser = new DOMParser().parseFromString(result, "text/html");
  const element = domParser.body;
  return element;
}

function css(strings, ...values) {
  let result = "";
  for (let i = 0; i < strings.length; i++) {
    result += strings[i];
    if (i < values.length) {
      result += values[i];
    }
  }
  return result;
}

(async function () {
  'use strict';

  initStyle();

  listenAjax(/api\/users\/[^\/]+\/works\/\d+$/, async (args) => {
    log("api/users/works", args)
    insertContainer();
    container.innerHTML = i18n("loading");
    const pathname = location.pathname
    const userInfo = await getUserInfo(args.res.creator.screen_name)
    usersMap.set(userInfo.screen_name, {
      userInfo,
      time: new Date().getTime()
    })
    updateUserMap()
    updateInfo(userInfo)
  })

  listenAjax(/api\/users\/[^\/]+$/, async (args) => {
    log("api/users", args)
    insertContainer();
    container.innerHTML = i18n("loading");
    usersMap.set(args.res.screen_name, {
      userInfo: args.res,
      time: new Date().getTime()
    })
    updateUserMap()
    updateInfo(args.res)
  })
})()