hbehe / Mandarake EUR Shipping estimate

// ==UserScript==
// @name         Mandarake EUR Shipping estimate
// @namespace    http://order.mandarake.co.jp/
// @version      0.7
// @description  Converts Mandarake pricing to EUR, tracks weight & package extend to estimate the shipping cost.
// @author       Behemoth
// @author       EIREXE
// @match        https://order.mandarake.co.jp/*
// @grant        none
// @require      http://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js
// @license      AGPL-3.0-only
// ==/UserScript==

// jshint esversion: 6

// Stackoverflow format unicorn
String.prototype.formatUnicorn = String.prototype.formatUnicorn ||
    function () {
    "use strict";
    var str = this.toString();
    if (arguments.length) {
        var t = typeof arguments[0];
        var key;
        var args = ("string" === t || "number" === t) ?
            Array.prototype.slice.call(arguments)
        : arguments[0];

        for (key in args) {
            str = str.replace(new RegExp("\\{" + key + "\\}", "gi"), args[key]);
        }
    }

    return str;
};

const STORAGE_KEY_WEIGHT = 'ext_itemWeight';
const STORAGE_KEY_EXTENDS = 'ext_itemExtends';

function price_to_html(price_jpy, exchange_rate) {
    var price_converted = (price_jpy/exchange_rate);
    var price_converted_format = price_converted.toLocaleString(undefined, {maximumFractionDigits: 2});
    return "<strong>{price_euro} €</strong> ({price_jpy} ¥)".formatUnicorn({"price_euro": price_converted_format, "price_jpy": price_jpy.toLocaleString()});
}

// API URL
var JPY_data = "https://api.frankfurter.dev/v1/latest?base=EUR&symbols=JPY";

const EXCHANGE_KEY = 'eur_jpy_rate';
const TIMESTAMP_KEY = 'eur_jpy_timestamp';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;


async function getExchangeRate() {
  const savedRate = localStorage.getItem(EXCHANGE_KEY);
  const savedTimestamp = localStorage.getItem(TIMESTAMP_KEY);

  const now = Date.now();
  const isExpired = !savedTimestamp || now - Number(savedTimestamp) > ONE_DAY_MS;

  if (savedRate && !isExpired) {
    console.log('Using cached rate:', savedRate);
    return parseFloat(savedRate);
  }

  try {
    const response = await fetch(JPY_data);
    const data = await response.json();
    const rate = data.rates.JPY;

    localStorage.setItem(EXCHANGE_KEY, rate);
    localStorage.setItem(TIMESTAMP_KEY, now.toString());

    console.log('Fetched new rate:', rate);
    return rate;
  } catch (error) {
    console.error('Error fetching exchange rate:', error);
    // Fallback to saved rate if available
    return savedRate ? parseFloat(savedRate) : null;
  }
}

function convert_prices(exchange_rate)
{
  $('p:contains(" yen")').filter(function() {
      if ($(this).text().trim().startsWith("Also available")) {
          var text = $(this).text();
          var child = $(this).children("a");
          console.log(child);
          text = text.split("Also available between ")[1];
          var between_1 = text.split(" and")[0];
          var between_2 = text.split("and ")[1];
          between_2 = between_2.split(" yen.")[0];
          between_1 = parseFloat(between_1.replace(/,/g, ""));
          between_2 = parseFloat(between_2.replace(/,/g, ""));

          child.text("");
          child.append("Also available between {between_1} and {between_2}".formatUnicorn({"between_1": price_to_html(between_1, exchange_rate), "between_2": price_to_html(between_2, exchange_rate)}))
      } else {
          var price_jpy_str = $(this).text().split(" ")[0];
          var price_jpy = parseFloat(price_jpy_str.replace(/,/g, ""));
          $(this).text("");
          $(this).append(price_to_html(price_jpy, exchange_rate));
      }

      return;
  });
}

function getItemCodeFromURL() {
  const urlParams = new URLSearchParams(window.location.search);
  return urlParams.get('itemCode');
}

// Step 2: Extract weight from text like "210mm x 148mm x 3mm / 42g"
function extractWeight(text) {
  const match = text.match(/\/\s*([\d.]+)g/);
  return match ? parseInt(match[1]) : null;
}

function extractExtends(text) {
  const match = text.match(/([\d]+)mm x ([\d]+)mm x ([\d]+)mm/);
  return match ? [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])] : null;
}

// Step 3: Loop over all <tr class="size"> and extract weight
function storeWeightsByItemCode() {
  const itemCode = getItemCodeFromURL();
  if (!itemCode) {
    console.warn('Item code not found in URL');
    return;
  }

  const row = document.querySelector('tr.size > td');

  if (!row)
  {
    console.warn('item weight not found');
    return;
  }

  // Store weight
  const lastTdText = row.textContent.trim();
  const itemWeight = extractWeight(lastTdText);
  if (!itemWeight)
  {
    console.warn('Failed to parse weight');
    return;
  }

  const weights = JSON.parse(localStorage.getItem(STORAGE_KEY_WEIGHT) || '{}');
  weights[itemCode] = itemWeight;
  localStorage.setItem(STORAGE_KEY_WEIGHT, JSON.stringify(weights));
  console.log(`Stored weights for ${itemCode}:`, itemWeight);

  // Store extends (used to estimate box weight)
  const itemSize = extractExtends(lastTdText);
  if (!itemSize) {
      console.warn('no extends');
  }
  const sizes = JSON.parse(localStorage.getItem(STORAGE_KEY_EXTENDS) || '{}');
  sizes[itemCode] = itemSize;
  localStorage.setItem(STORAGE_KEY_EXTENDS, JSON.stringify(sizes));
  console.log(`Stored extends for ${itemCode}:`, itemSize);
}

function calculateShippingcostInBasket(exchange_rate) {
  'use strict';

  // Parse item weights from localStorage
  const weights = JSON.parse(localStorage.getItem(STORAGE_KEY_WEIGHT) || '{}');
  const sizes = JSON.parse(localStorage.getItem(STORAGE_KEY_EXTENDS) || '{}');

  // Monkey-patch XHR to detect when basket is loaded
  const origOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function (method, url) {
    if (url.includes('https://tools.mandarake.co.jp/basket/get/')) {
      this.addEventListener('load', function () {
        console.log('loaded basket');
        // Wait a bit for DOM to update
        setTimeout(() => {
          getExchangeRate().then(rate => {
            if (rate !== null) {
              console.log(`Current EUR to JPY rate: ${rate}`);
              patchBasketWeights();
            } else {
              console.log('Unable to get exchange rate.');
            }
          });
        }, 200); // Adjust if needed
      });
    }
    return origOpen.apply(this, arguments);
  };

  function patchBasketWeights() {
    const totalWeightsByShop = {};
    const totalSizeByShop = {};

    // Loop through all item divs
    document.querySelectorAll('div[id^="item_webshop_"]').forEach(div => {
      const match = div.id.match(/^item_webshop_(\d+)$/);
      if (!match) return;

      const itemCode = match[1];
      const weight = weights[itemCode];
      if (!weight) {
        console.warn(`weight not known for ${itemCode}. Find out by clicking here: https://order.mandarake.co.jp/order/detailPage/item?itemCode=${itemCode}&lang=en`);
        return;
      }

      const count = parseInt(document.querySelector(`span[id="item_webshop_${itemCode}_count"]`).textContent);
      console.log(`${itemCode} x ${count}`);

      const li = div.querySelector('li.basic');
      if (li) {
        const weightDiv = li.querySelector('.weight') || document.createElement('div');
        weightDiv.setAttribute('class', 'weight');
        weightDiv.textContent = `${weight}g`;
        if (count != 1)
          {
            weightDiv.textContent = `${count}x${weight}g (${weight * count}g)`;
          }
        li.appendChild(weightDiv);
      }

      const shop_id = div.closest('div.shop').querySelector('input#shop_id').value;
      if (!shop_id){
        console.warn('shop_id not found!');
        return;
      }

      var subTotal = totalWeightsByShop[shop_id] || 0;
      subTotal += weight * count;
      totalWeightsByShop[shop_id] = subTotal;
      console.info(`Shop ${shop_id} subtotal ${subTotal}`);

      const itemSize = sizes[itemCode];
      if (!itemSize || itemSize.length != 3) {
        console.warn(`item size not known for ${itemCode}. Find out by clicking here: https://order.mandarake.co.jp/order/detailPage/item?itemCode=${itemCode}&lang=en`);
        return;
      }
      const floatSize = new Float64Array(itemSize);
      floatSize.sort();

      var subTotalExtend = totalSizeByShop[shop_id] || new Float64Array([0,0,0]);
      subTotalExtend[0] += floatSize[0];
      subTotalExtend[1] = Math.max(subTotalExtend[1], floatSize[1]);
      subTotalExtend[2] = Math.max(subTotalExtend[2], floatSize[2]);
      totalSizeByShop[shop_id] = subTotalExtend;
      console.info(`Shop ${shop_id} subtotalExtend ${subTotalExtend}`);
    });

    for (const [shop_id, totalWeight] of Object.entries(totalWeightsByShop)) {
      const shop = document.querySelector(`div#shop_${shop_id}`);
      const total = shop.querySelector('.nextpage');

      // Order price
      const orderValueElm = shop.querySelector(`span#shop_${shop_id}_price`);
      const orderValueYen = parseFloat(orderValueElm.textContent.replace(/,/g, ""));
      orderValueElm.innerHTML = price_to_html(orderValueYen, exchange_rate);

      // Import cost
      const orderValueEur = (orderValueYen / exchange_rate)
      const eUSt = (orderValueEur * 0.07).toLocaleString(undefined, {maximumFractionDigits: 2}); // Guess 7% for books
      const eUStDiv = total.querySelector('.eUSt') || document.createElement('p');
      eUStDiv.setAttribute('class', 'eUSt');
      eUStDiv.innerHTML = `EUSt: ${eUSt}€`;
      total.prepend(eUStDiv);

      // Shipping weight
      const itemSize = totalSizeByShop[shop_id];
      const boxWeight = itemSize ? getMinBoxWeight(itemSize) : 0;
      const totalWeightDiv = total.querySelector('.totalWeight') || document.createElement('p');
      totalWeightDiv.setAttribute('class', 'totalWeight');
      totalWeightDiv.textContent = `Weight ${totalWeight}g + ${boxWeight}g box (${itemSize[0]}x${itemSize[1]}x${itemSize[2]}mm)`;
      total.prepend(totalWeightDiv);

      // Shipping price
      const shippingPriceYen = getShippingPrice(totalWeight + boxWeight);
      const shippingPrice = price_to_html(shippingPriceYen, exchange_rate);
      const shippingPriceDiv = total.querySelector('.shippingPrice') || document.createElement('p');
      shippingPriceDiv.setAttribute('class', 'shippingPrice');
      shippingPriceDiv.innerHTML = 'Estimated shipping: ' + shippingPrice;
      total.prepend(shippingPriceDiv);
    }
  }
}

function patchSite(exchange_rate) {
  'use strict';

  convert_prices(exchange_rate);
  storeWeightsByItemCode();
  calculateShippingcostInBasket(exchange_rate);
}

// Entry Point
// Fetch exchange rate, run everything else
(function() {
  'use strict';

  getExchangeRate().then(rate => {
    if (rate === null) {
      console.log('Unable to get exchange rate.');
      return;
    }

    console.log(`Current EUR to JPY rate: ${rate}`);
    patchSite(rate);
  });
})();

// Determine used box in shipping (margin 5cm)
function getMinBoxWeight(totalSize) {
  var minWeight = 1100;
  const neededPackageSize = [totalSize[0] + 50, totalSize[1] + 50, totalSize[2] + 50];
  totalSize.sort();
  console.info(`searching for box with extends ${totalSize}`);
  for (const [size, weight] of box_sizes) {
    const floatSize = new Float64Array(size);
    floatSize.sort();
    if (weight < minWeight &&
      totalSize[0] < floatSize[0] &&
      totalSize[1] < floatSize[1] &&
      totalSize[2] < floatSize[2]) {
      console.info(`box ${size} x ${weight}g is lighter`);
      minWeight = weight;
    }
  }

  console.info(`lightest box: ${minWeight}g`);
  return minWeight;
}

const box_sizes =
[
  [ [ 210, 180, 135 ], 120 ],
  [ [ 575, 390, 435 ], 1100 ],
  [ [ 640, 210, 170 ], 440 ],
  [ [ 330, 235, 310 ], 420 ],
  [ [ 300, 210, 165 ], 180 ],
  [ [ 350, 350, 270 ], 540 ],
  [ [ 410, 290, 85 ], 250 ],
  [ [ 500, 345, 330 ], 580 ],
  [ [ 745, 245, 270 ], 680 ],
  [ [ 430, 362, 97 ], 340 ],
  [ [ 410, 330, 140 ], 460 ],
  [ [ 365, 240, 215 ], 260 ],
  [ [ 455, 320, 250 ], 440 ]
];

// Determine Shipping cost for basket weight
function getShippingPrice(totalWeight) {
  for (const [weight, price] of shipping_table) {
    if (totalWeight <= weight) {
      return price;
    }
  }
  // If weight exceeds all brackets, return the highest price or handle as needed
  return shipping_table[shipping_table.length - 1][1];
}

const shipping_table =
[
  [500, 2400],
  [1000, 2900],
  [1500, 3300],
  [2000, 3700],
  [2500, 4200],
  [3000, 4700],
  [3500, 5100],
  [4000, 5500],
  [4500, 5900],
  [5000, 6400],
  [5500, 7000],
  [6000, 7500],
  [6500, 8100],
  [7000, 8700],
  [7500, 9300],
  [8000, 9900],
  [8500, 10500],
  [9000, 11000],
  [9500, 11600],
  [10000, 12200],
  [10500, 12800],
  [11000, 13400],
  [11500, 14000],
  [12000, 14500],
  [12500, 15100],
  [13000, 15700],
  [13500, 16300],
  [14000, 16900],
  [14500, 17500],
  [15000, 18000],
  [15500, 18600],
  [16000, 19200],
  [16500, 19800],
  [17000, 20400],
  [17500, 21000],
  [18000, 21500],
  [18500, 22100],
  [19000, 22700],
  [19500, 23300],
  [20000, 23900],
  [20500, 25100],
  [21000, 26400],
  [21500, 27700],
  [22000, 28900],
  [22500, 30200],
  [23000, 31500],
  [23500, 32700],
  [24000, 34000],
  [24500, 35300],
  [25000, 36500],
  [25500, 37800],
  [26000, 39100],
  [26500, 40300],
  [27000, 41600],
  [27500, 42900],
  [28000, 44100],
  [28500, 45400],
  [29000, 46700],
  [29500, 47900],
  [30000, 49200],
  [30500, 50600],
  [31000, 52000],
  [31500, 53300],
  [32000, 54700],
  [32500, 56100],
  [33000, 57500],
  [33500, 58800],
  [34000, 60200],
  [34500, 61600],
  [35000, 63000],
  [35500, 64400],
  [36000, 65700],
  [36500, 67100],
  [37000, 68500],
  [37500, 69900],
  [38000, 71300],
  [38500, 72600],
  [39000, 74000],
  [39500, 75400],
  [40000, 76800],
  [40500, 78100],
  [41000, 79500],
  [41500, 80900],
  [42000, 82300],
  [42500, 83700],
  [43000, 85000],
  [43500, 86400],
  [44000, 87800],
  [44500, 89200],
  [45000, 90600],
  [45500, 91900],
  [46000, 93300],
  [46500, 94700],
  [47000, 96100],
  [47500, 97400],
  [48000, 98800],
  [48500, 100200],
  [49000, 101600],
  [49500, 103000],
  [50000, 104300],
  [50500, 105700],
  [51000, 107100],
  [51500, 108500],
  [52000, 109900],
  [52500, 111200],
  [53000, 112600],
  [53500, 114000],
  [54000, 115400],
  [54500, 116700],
  [55000, 118100],
  [55500, 119500],
  [56000, 120900],
  [56500, 122300],
  [57000, 123600],
  [57500, 125000],
  [58000, 126400],
  [58500, 127800],
  [59000, 129100],
  [59500, 130500],
  [60000, 131900],
  [60500, 133300],
  [61000, 134700],
  [61500, 136000],
  [62000, 137400],
  [62500, 138800],
  [63000, 140200],
  [63500, 141600],
  [64000, 142900],
  [64500, 144300],
  [65000, 145700],
  [65500, 147100],
  [66000, 148400],
  [66500, 149800],
  [67000, 151200],
  [67500, 152600],
  [68000, 154000],
  [68500, 155300],
  [69000, 156700],
  [69500, 158100],
  [70000, 159500],
  [70500, 160900],
  [71000, 162200],
  [71500, 163600],
  [72000, 165000],
  [72500, 166400],
  [73000, 167700],
  [73500, 169100],
  [74000, 170500],
  [74500, 171900],
  [75000, 173300],
  [75500, 174600],
  [76000, 176000],
  [76500, 177400],
  [77000, 178800],
  [77500, 180200],
  [78000, 181500],
  [78500, 182900],
  [79000, 184300],
  [79500, 185700],
  [80000, 187000],
  [80500, 188400],
  [81000, 189800],
  [81500, 191200],
  [82000, 192600],
  [82500, 193900],
  [83000, 195300],
  [83500, 196700],
  [84000, 198100],
  [84500, 199500],
  [85000, 200800],
  [85500, 202200],
  [86000, 203600],
  [86500, 205000],
  [87000, 206300],
  [87500, 207700],
  [88000, 209100],
  [88500, 210500],
  [89000, 211900],
  [89500, 213200],
  [90000, 214600],
  [90500, 216000],
  [91000, 217400],
  [91500, 218700],
  [92000, 220100],
  [92500, 221500],
  [93000, 222900],
  [93500, 224300],
  [94000, 225600],
  [94500, 227000],
  [95000, 228400],
  [95500, 229800],
  [96000, 231200],
  [96500, 232500],
  [97000, 233900],
  [97500, 235300],
  [98000, 236700],
  [98500, 238000],
  [99000, 239400],
  [99500, 240800],
  [100000, 242200]
];