Boothの購入履歴から累計散財額を計算するツール

お手軽鬱ボタン。Boothの購入履歴の総額を計算できます。同じセッションだけしか計算結果は保存できません。

// ==UserScript==
// @name         Boothの購入履歴から累計散財額を計算するツール
// @namespace    https://x.com/zerukuVRC
// @version      2.0
// @description  お手軽鬱ボタン。Boothの購入履歴の総額を計算できます。同じセッションだけしか計算結果は保存できません。
// @author       zeruku
// @match        https://accounts.booth.pm/orders
// @match        https://accounts.booth.pm/orders?*
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// ==/UserScript==


(function() {
  // Constants
  const STYLES = {
    FIXED_BUTTON: {
      color: '#ffffff',
      borderRadius: '20px',
      padding: '10px 15px',
      border: 'none',
      boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
      cursor: 'pointer',
      position: 'fixed',
      zIndex: '1000'
    },
    COLORS: {
      PRIMARY: '#fc4d50',
      PRIMARY_HOVER: '#ff6669',
      AUTO: '#1b7f8c',
      AUTO_HOVER: '#22a1b2',
      AUTO_STOP: '#f30f4c',
      AUTO_STOP_HOVER: '#c00b3c',
      RESET: '#0077B5',
      RESET_HOVER: '#005588',
      TWEET: '#1DA1F2',
      TWEET_HOVER: '#1A91DA'
    }
  };

  // URL Parameter Management
  class URLManager {
    constructor() {
      this.url = new URL(window.location.href);
      this.initializeParams();
    }

    initializeParams() {
      const params = [
        ['total', '0'],
        ['auto', this.url.searchParams.get('auto') === '1' ? '1' : '0'],
        ['page', this.url.searchParams.get('page') || '1']
      ];

      let changed = false;
      params.forEach(([key, defaultValue]) => {
        if (this.url.searchParams.get(key) === null) {
          this.url.searchParams.set(key, defaultValue);
          changed = true;
        }
      });

      if (changed) {
        window.location.href = this.url.href;
      }
    }

    static processNextPage(url) {
      const parsedURL = new URL(url);
      const currentPage = parseInt(parsedURL.searchParams.get('page'), 10);
      parsedURL.searchParams.set('page', (currentPage + 1).toString());
      window.location.href = parsedURL.href;
    }

    static resetToFirstPage() {
      const parsedURL = new URL(window.location.href);
      parsedURL.searchParams.set('page', '1');
      parsedURL.searchParams.set('auto', '0');
      window.location.href = parsedURL.href;
    }
  }

  // Item Exclusion Management
  class ExclusionManager {
    static updateExclusionList(itemId, isAdding) {
      const currentValue = GM_getValue('exclude_item_ids') || '';
      if (isAdding) {
        GM_setValue('exclude_item_ids', currentValue + ` ${itemId}`);
      } else {
        GM_setValue('exclude_item_ids', currentValue.replace(` ${itemId}`, ''));
      }
    }

    static setupExclusionButtons() {
      const itemElements = Array.from(document.getElementsByClassName("l-orders-index")[0].children);

      itemElements.forEach((item, index) => {
        if (item.classList[0] == "pager") {return}
        const button = this.createExcludeButton(item, index);
        item.firstChild.appendChild(button);
      });

      this.setupKeyboardShortcuts();
    }

    static createExcludeButton(item, index) {
      
      const button = document.createElement('button');
      const itemId = item.href.match(/\d+$/g)[0];
      const isExcluded = String(GM_getValue('exclude_item_ids')).includes(itemId);
      
      button.id = `excludeButton${index}`;
      Object.assign(button.style, {
        marginLeft: '8px',
        color: '#ffffff',
        border: 'none',
        fontSize: '10px',
        padding: '8px 6px',
        background: isExcluded ? '#e1362e' : '#808080'
      });
    
      button.textContent = isExcluded ? '除外解除' : '除外する';
      button.className = isExcluded ? 'ex_true' : 'ex_false';
    
      button.addEventListener('click', (event) => {
        event.preventDefault();  // デフォルトの動作を防ぐ
        event.stopPropagation(); // イベントの伝播を停止
        this.handleExcludeClick(event, index, item);
      });
    
      return button;
    }

    static setupKeyboardShortcuts() {
      const keysPressed = {};

      document.addEventListener('keydown', (event) => {
        keysPressed[event.key] = true;

        if (keysPressed['Shift'] && keysPressed['E'] && keysPressed['L']) {
          if (confirm('除外設定を全てリセットしますか?')) {
            GM_setValue('exclude_item_ids', '');
            window.location.reload();
          }
          Object.keys(keysPressed).forEach(key => keysPressed[key] = false);
        }
      });

      document.addEventListener('keyup', (event) => {
        keysPressed[event.key] = false;
      });
    }

    static handleExcludeClick(event, index, item) {
      const button = document.querySelector(`#excludeButton${index}`);
      // itemId の取得方法を修正
      const itemId = item.href.match(/\d+$/g)[0];  // 直接 item から取得
    
      if (event.ctrlKey) {
        this.toggleAllExclusions();
      } else {
        this.toggleSingleExclusion(button, itemId);
      }
    }

    static unifiedState = true;  // クラス変数として定義

    static toggleAllExclusions() {
      const allButtons = document.querySelectorAll('[id^="excludeButton"]');
      const newState = !this.unifiedState;
      this.unifiedState = newState;
    
      allButtons.forEach(button => {
        const itemId = button.parentElement.parentElement.href.match(/\d+$/g)[0];
        button.style.background = newState ? '#e1362e' : '#808080';
        button.textContent = newState ? '除外解除' : '除外する';
        button.className = newState ? 'ex_true' : 'ex_false';
        this.updateExclusionList(itemId, newState);
      });
    }
    static toggleSingleExclusion(button, itemId) {
      const isCurrentlyExcluded = button.className === 'ex_true';
      button.style.background = isCurrentlyExcluded ? '#808080' : '#e1362e';
      button.textContent = isCurrentlyExcluded ? '除外する' : '除外解除';
      button.className = isCurrentlyExcluded ? 'ex_false' : 'ex_true';
      this.updateExclusionList(itemId, !isCurrentlyExcluded);
    }
    
    static updateExclusionList(itemId, isAdding) {
      const currentValue = GM_getValue('exclude_item_ids') || '';
      console.log('Current exclusion list:', currentValue); // デバッグ用
      console.log('Updating item:', itemId, isAdding); // デバッグ用
      
      if (isAdding) {
        GM_setValue('exclude_item_ids', currentValue + ` ${itemId}`);
      } else {
        GM_setValue('exclude_item_ids', currentValue.replace(` ${itemId}`, ''));
      }
      
      console.log('New exclusion list:', GM_getValue('exclude_item_ids')); // デバッグ用
    }

    static toggleSingleExclusion(button, itemId) {
      const isCurrentlyExcluded = button.className === 'ex_true';
      button.style.background = isCurrentlyExcluded ? '#808080' : '#e1362e';
      button.textContent = isCurrentlyExcluded ? '除外する' : '除外解除';
      button.className = isCurrentlyExcluded ? 'ex_false' : 'ex_true';
      this.updateExclusionList(itemId, !isCurrentlyExcluded);
    }
  }

  // Item Management
  class ItemManager {
    static collectItemInfo(itemElement) {
      console.log('Processing item:', itemElement); // デバッグ用
    
      // itemElementが商品アイテムとして適切な構造を持っているか確認
      if (!itemElement.href || !itemElement.firstChild) {
        console.log('Skipping invalid item element'); // デバッグ用
        return null;
      }
    
      const url = itemElement.href;
      const itemId = url.match(/\d+$/g)[0];
    
      if (String(GM_getValue('exclude_item_ids')).indexOf(itemId) !== -1) {
        console.log(itemId)
        return 'exclude';
      }

      const itemVariation = (itemElement.getElementsByClassName("u-tpg-caption1")[0].innerText.match(/\(([^)]+)\)[^\(]*$/) || [null, null])[1];
      const orderId = Number(itemElement.href.match(/\d+$/)[0]);

      return {
        item_id: itemId,
        item_variation: itemVariation ? itemVariation.replace(/^\(/, '').replace(/\)$/, '') : null,
        order_id: orderId
      };
    }

    static async fetchItemPrice(orderId) {
      try {
        const response = await fetch(`https://accounts.booth.pm/orders/${orderId}`, {
          credentials: 'include',
          headers: { 'Accept': 'text/html' }
        });

        if (response.status === 404) {
          return 'Item deleted or private';
        }

        const text = await response.text();
        const matched = text.match(/お支払金額.*?¥\s*([\d,]+)/);
        return matched ? { item_price: Number(matched[1].replace(/,/g, '')) } : { item_price: undefined };
      } catch (error) {
        throw new Error(`Request error: ${error.message}`);
      }
    }
  }

  // Price Comparison
class PriceComparator {
  static get comparisons() {
    return [
      [114381200000000, "日本の国家予算をまかなえていました!"],
      [23760000000000, "イーロン・マスクよりお金持ちでした!"],
      [9000000000000, "映画「シン・ゴジラ」の被害額を一人で賠償できました!"],
      [1652283360000, "Google社の時価総額を超えていました!"],
      [1510160000000, "Discordを買収できていたかもしれません!"],
      [639000000000, "映画「名探偵コナン 紺青の拳」の被害額を一人で賠償できました!"],
      [395000000000, "イージス艦を一隻買えました!"],
      [90804000000, "マインクラフトの金ブロック1個が買えました!"],
      [1250000000, "VRChatの推定時価総額を超えていました!"],
      [1208280000, "GTA5のバイク「オプレッサー MkⅡ」を1台買えました!"],
      [332277000, "GTA5の潜水艦「コサトカ」を1艇買えました!"],
      [143600000, "首都圏の新築マンション1戸が買えました!"],
      [53200000, "USJの夜間貸し切りが出来ました!"],
      [8920000, "新車のベルファイアが買えました!"],
      [7336000, "エンジニアの平均年収を超えていました!"],
      [6116279, "コンビニ1軒の全商品を購入できていました!"],
      [5000000, "クロマグロ1尾が買えました!"],
      [4610000, "サラリーマンの平均年収を超えていました!"],
      [3214800, "東京大学理Ⅲの1年の学費をまかなえていました!"],
      [2750000, "新型プリウスの新車が買えました!"],
      [2361000, "40人規模の結婚式を挙げられました!相手は付属しません"],
      [1548000, "中古車1台が買えました!"],
      [1500000, "ゲーセンのmaimai筐体が買えました!"],
      [1386000, "GeeScorpion(超高級ゲーミングチェア)が買えました!"],
      [1180872, "ペッパーくんが一人買えました!"],
      [1111400, "大学生の1年の生活費をまかなえていました!"],
      [1000000, "ゲーセンの太鼓の達人の新筐体が買えました!"],
      [940000, "ゲーセンにあるポップンミュージックの旧筐体が買えました!"],
      [917540, "鹿児島駅前から札幌駅前までタクシーで移動できました!"],
      [800000, "ゲーセンのダンエボの筐体が買えました!"],
      [770000, "Valorantの全スキンが買えました!"],
      [650000, "ゲーセンのProject Divaの筐体が買えました!"],
      [588450, "超ハイスペックゲーミングパソコンが1台買えました!"],
      [540000, "公園にある4人乗りブランコが買えました!"],
      [493450, "大阪駅前から青森駅までタクシーで移動できました!"],
      [460000, "公園にあるジャングルジムが買えました!"],
      [400000, "Valve Index VRフルキット + ハイスペックゲーミングパソコンが買えました!"],
      [359777, "Nvidia Quadro RTX 5000が買えました!"],
      [319800, "Nvidia RTX 4090が買えました!"],
      [310000, "公園にある2人乗りブランコが買えました!"],
      [280000, "公園にあるうんていが買えました!"],
      [250000, "4泊6日ハワイ旅行ができました!"],
      [219800, "iPhone 15 Pro Max 512GBが買えました!"],
      [198000, "iMacを1台買えました!"],
      [165980, "Valve Index VRフルキットが買えました!"],
      [159800, "iPhone 15 Pro 128GBが買えました!"],
      [150000, "公園にある鉄棒が1欄買えました!"],
      [149000, "キングサイズのベッドが買えました!"],
      [147000, "このツールの作者の貯金額以上でした......"],
      [139800, "iPhone 15 Plusが買えました!"],
      [124800, "iPad Pro 11インチが買えました!"],
      [104000, "東京都の平均家賃1ヶ月分をまかなえました!"],
      [96800, "Meta Quest 3 512GBが買えました!"],
      [82800, "Valve Index HMDが買えました!"],
      [74800, "Meta Quest 3 128GBが買えました!"],
      [53900, "Meta Quest 2 256GBが買えました!"],
      [49000, "PICO 4が買えました!"],
      [47300, "Meta Quest 2 128GBが買えました!"],
      [38410, "一人暮らしの一ヶ月の食費がまかなえました!"],
      [32890, "Yogibo Maxが買えました!"],
      [17490, "ジェラピケのパジャマが買えました!"],
      [9100, "カイジの月給を超えていました!"],
      [7900, "ディズニーランドで1日遊べていました!"],
      [5368, "焼肉食べ放題に行けました!"],
      [4748, "モンエナ355mlが24本買えました!"],
      [3905, "ストゼロ500mlが24本買えました!"],
      [1999, "ダイの大冒険が買えました!"],
      [1500, "VRChat Plusに1ヶ月加入できました!"],
      [1280, "YouTube Premiumに1ヶ月加入できました!"],
      [700, "スタバのフラペチーノが飲めました!"],
      [300, "ファミマのアイスコーヒーLサイズが飲めました!"],
      [220, "ファミチキが1個買えました!"],
      [100, "ボールペンが1本買えました!"],
      [20, "もやしが1袋買えました!"],
      [3, "レジ袋Mサイズ1枚しか買えませんでした......"]
    ];
  }

  static typicalPrice(totalPrice) {
    const numericPrice = Number(totalPrice);
    for (const [threshold, message] of this.comparisons) {
      if (numericPrice >= threshold) return message;
    }
    return "何も買えませんでした。";
  }
}

  // UI Components
  class UIComponents {
    static createButton(text, options) {
      const button = document.createElement('button');
      button.innerText = text;
      Object.assign(button.style, STYLES.FIXED_BUTTON, options.style);
      button.addEventListener('mouseover', () => button.style.background = options.hoverColor);
      button.addEventListener('mouseout', () => button.style.background = options.baseColor);
      button.onclick = options.onClick;
      document.body.appendChild(button);
      return button;
    }

    static calculateButton = null;

    static addCalculateButton() {
      this.calculateButton = this.createButton('金額計算', {
        style: {
          background: STYLES.COLORS.PRIMARY,
          bottom: '10px',
          left: '10px'
        },
        baseColor: STYLES.COLORS.PRIMARY,
        hoverColor: STYLES.COLORS.PRIMARY_HOVER,
        onClick: main
      });
      this.calculateButton.classList.add('booth-total-price-button');
      return this.calculateButton;
    }

    static addAutoButton(autoCalculate) {
      return this.createButton(autoCalculate ? '自動計算を停止' : '自動計算開始!', {
        style: {
          background: autoCalculate ? STYLES.COLORS.AUTO_STOP : STYLES.COLORS.AUTO,
          bottom: '10px',
          left: '120px'
        },
        baseColor: autoCalculate ? STYLES.COLORS.AUTO_STOP : STYLES.COLORS.AUTO,
        hoverColor: autoCalculate ? STYLES.COLORS.AUTO_STOP_HOVER : STYLES.COLORS.AUTO_HOVER,
        onClick: autoCalculate ? this.stopAuto : this.startAuto
      });
    }

    static addResetButton() {
      return this.createButton('累計金額をリセット', {
        style: {
          background: STYLES.COLORS.RESET,
          bottom: '10px',
          left: '280px'
        },
        baseColor: STYLES.COLORS.RESET,
        hoverColor: STYLES.COLORS.RESET_HOVER,
        onClick: this.resetTotal
      });
    }

    static addTweetButton(totalPrice) {
      return this.createButton('Twitterに共有', {
        style: {
          background: STYLES.COLORS.TWEET,
          bottom: '60px',
          left: '280px'
        },
        baseColor: STYLES.COLORS.TWEET,
        hoverColor: STYLES.COLORS.TWEET_HOVER,
        onClick: () => this.handleTweet(totalPrice)
      });
    }

    static addTotalPriceDisplay(totalPrice) {
      const display = document.createElement('div');
      Object.assign(display.style, {
        position: 'fixed',
        bottom: '60px',
        left: '10px',
        backgroundColor: '#333',
        color: '#fff',
        padding: '6px 18px',
        borderRadius: '20px',
        boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
        border: 'none',
        zIndex: '1000',
        cursor: 'pointer'
      });

      display.textContent = `累計金額: ${Number(totalPrice).toLocaleString()}円`;
      display.addEventListener('mouseover', () => display.style.background = '#444');
      display.addEventListener('mouseout', () => display.style.background = '#333');
      display.onclick = () => this.handleTotalPriceClick(totalPrice);

      document.body.appendChild(display);
    }

    static handleTweet(totalPrice) {
      const tweetText = `私がBoothで使用した合計金額は、『${Number(totalPrice).toLocaleString()}円』でした!\n\n#私がBoothに使った金額`;
      const tweetURL = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}`;
      window.open(tweetURL, '_blank');
    }

    static handleTotalPriceClick(totalPrice) {
      const comparison = PriceComparator.typicalPrice(totalPrice);
      if (confirm(`もし『${Number(totalPrice).toLocaleString()}円』あれば...\n${comparison}\n\nOKを押すと、この文章を入れてツイートします。`)) {
        const tweetText = `私がBoothで使用した合計金額は、『${Number(totalPrice).toLocaleString()}円』でした!\n` +
                         `もし${Number(totalPrice).toLocaleString()}円あれば...\n『${comparison}』\n\n` +
                         `#私がBoothに使った金額`;
        const tweetURL = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}`;
        window.open(tweetURL, '_blank');
      }
    }
     static startAuto() {
      const url = new URL(window.location.href);
      url.searchParams.set('auto', '1');
      window.location.href = url.href;
    }

    static stopAuto() {
      const url = new URL(window.location.href);
      url.searchParams.set('auto', '0');
      window.location.href = url.href;
    }

    static resetTotal() {
      const url = new URL(window.location.href);
      url.searchParams.set('total', '0');
      url.searchParams.set('auto', '0');

      if (confirm('累計金額をリセットしますか?')) {
        window.location.href = url.href;
      }
    }
  }

  // Progress Display
  class ProgressDisplay {
    constructor() {
      this.element = document.createElement('div');
      Object.assign(this.element.style, {
        position: 'fixed',
        bottom: '100px',
        left: '10px',
        color: '#fc4d50',
        zIndex: '1000'
      });
      document.body.appendChild(this.element);
    }

    update(completed, total) {
      this.element.textContent = `進行中: ${completed}/${total}`;
    }

    clear() {
      this.element.textContent = '';
    }
  }

  // Main calculation logic
  async function main() {
    const url = new URL(window.location.href);
    const button = UIComponents.calculateButton;
    if (button) {
      button.disabled = true;
      button.style.cursor = 'wait';
    }

    const itemListElements = Array.from(document.getElementsByClassName("l-orders-index")[0].children);
    itemListElements.pop()
    let itemList = itemListElements.map(ItemManager.collectItemInfo);
    console.log(itemList)

    // Handle excluded items
    if (itemList.every(v => v === 'exclude') && itemList.length !== 0) {
      URLManager.processNextPage(window.location.href);
      return;
    } else if (itemList.length === 0) {
      alert('計算が終了しました');
      URLManager.resetToFirstPage();
      return;
    }

    // Filter and process items
    itemList = itemList.filter(element => element !== 'exclude');

    // Calculate prices
    const priceList = [];
    const progress = new ProgressDisplay();
    const totalItems = itemList.length;
    let completedItems = 0;

    progress.update(completedItems, totalItems);

    for (const itemInfo of itemList) {
      try {
        const itemPrice = await ItemManager.fetchItemPrice(itemInfo.order_id);
        if (itemPrice.item_price) priceList.push(itemPrice.item_price);
      } catch (error) {
        console.error(error);
      }
      completedItems++;
      progress.update(completedItems, totalItems);
      await new Promise(resolve => setTimeout(resolve, 150));
    }

    // Calculate total
    const totalPrice = priceList.reduce((a, b) => a + b, 0);
    const existingTotal = parseFloat(url.searchParams.get('total')) || 0;
    const newTotal = existingTotal + totalPrice;

    url.searchParams.set('total', newTotal);
    url.searchParams.set('last_order_id', itemList[itemList.length - 1].order_id);

    if (!autoCalculate) {
      alert(`このページの合計金額: ${totalPrice}円\n今までの合計金額: ${newTotal}円`);
      window.location.href = url.href;
      progress.clear();
      button.disabled = true;
      button.textContent = '計算済み';
    } else {
      URLManager.processNextPage(url);
    }
  }

  // Initialize
  const urlManager = new URLManager();
  const autoCalculate = urlManager.url.searchParams.get('auto') === '1';
  const totalPrice = urlManager.url.searchParams.get('total');

  ExclusionManager.setupExclusionButtons();
  UIComponents.addCalculateButton();
  UIComponents.addAutoButton(autoCalculate);
  UIComponents.addResetButton();
  UIComponents.addTweetButton(totalPrice);
  UIComponents.addTotalPriceDisplay(totalPrice);

  if (autoCalculate) {
    main();
  }
})();