Greasy Fork

Greasy Fork is available in English.

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

お手軽鬱ボタン。Boothの購入履歴の総額を計算できます。

当前为 2025-07-04 提交的版本,查看 最新版本

// ==UserScript==
// @name         Boothの購入履歴から累計散財額を計算するツール
// @version      3.0.3
// @description  お手軽鬱ボタン。Boothの購入履歴の総額を計算できます。
// @namespace    https://x.com/zerukuVRC
// @author       zeruku
// @match        https://accounts.booth.pm/orders
// @match        https://accounts.booth.pm/orders?*
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// ==/UserScript==

(function () {
    // Price Comparison List (moved for easier maintenance)
    const PRICE_COMPARISONS = [
        [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枚しか買えませんでした......"],
    ];

    // Helper for consistent Yen formatting
    function formatYen(amount) {
        return `${Number(amount).toLocaleString()}円`;
    }

    // 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: "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);
            });
        }
        // (重複・冗長な toggleSingleExclusion, updateExclusionList メソッドを削除)

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

    // Item Management
    class ItemManager {
        static collectItemInfo(itemElement) {
            // itemElementが商品アイテムとして適切な構造を持っているか確認
            if (!itemElement.href || !itemElement.firstChild) {
                // 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
            ) {
                // Excluded item
                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,]+)/);
                const isGift = text.includes(
                    '<b class="u-tpg-title3">ギフト</b>'
                );
                return matched
                    ? {
                          item_price: Number(matched[1].replace(/,/g, "")),
                          is_gift: isGift,
                      }
                    : { item_price: undefined, is_gift: false };
            } catch (error) {
                throw new Error(`Request error: ${error.message}`);
            }
        }
    }

    // Price Comparison
    class PriceComparator {
        static typicalPrice(totalPrice) {
            const numericPrice = Number(totalPrice);
            for (const [threshold, message] of PRICE_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: "140px",
                    },
                    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, giftTotal) {
            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, giftTotal),
            });
        }
        static addTotalPriceDisplay(totalPrice, giftTotal) {
            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 = `合計金額: ${formatYen(totalPrice)}`;
            if (giftTotal && !isNaN(giftTotal)) {
                display.title = `内ギフト合計: ${formatYen(giftTotal)}`;
            }

            display.addEventListener(
                "mouseover",
                () => (display.style.background = "#444")
            );
            display.addEventListener(
                "mouseout",
                () => (display.style.background = "#333")
            );
            display.onclick = () => this.handleTotalPriceClick(giftTotal);

            document.body.appendChild(display);
        }

        static handleTweet(totalPrice, giftTotal) {
            const comparison = PriceComparator.typicalPrice(totalPrice);
            const includeComparison = confirm(
                `以下のメッセージを含めますか?\n\nもし${formatYen(
                    totalPrice
                )}あれば...\n『${comparison}』\n\nOKを押すと、この文章を入れてツイートします。`
            );

            let tweetText = `私がBoothで使用した合計金額は、『${formatYen(
                totalPrice
            )}』でした!`;
            if (giftTotal > 0) {
                tweetText += `(内ギフト合計: ${formatYen(giftTotal)})`;
            }
            if (includeComparison) {
                tweetText += `\n\nもし${formatYen(
                    totalPrice
                )}あれば...\n『${comparison}』`;
            }

            tweetText += `\n\n#私がBoothに使った金額`;

            const tweetURL = `https://twitter.com/intent/tweet?text=${encodeURIComponent(
                tweetText
            )}`;
            window.open(tweetURL, "_blank");
        }

        static handleTotalPriceClick(giftTotal) {
            alert(`合計金額の内、ギフト合計は ${formatYen(giftTotal)} です`);
        }
        static startAuto() {
            const url = new URL(window.location.href);
            url.searchParams.set("auto", "1");
            url.searchParams.set("page", "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("gift_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("Collected item list:", itemList);

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

        itemList = itemList.filter((element) => element !== "exclude");
        console.log("Filtered item list:", itemList);

        const priceList = [];
        let giftTotal = [];
        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
                );
                console.log("Fetched price:", itemPrice);
                if (itemPrice.item_price) {
                    priceList.push(itemPrice.item_price);
                    if (itemPrice.is_gift) {
                        giftTotal.push(itemPrice.item_price);
                    }
                }
            } catch (error) {
                console.error(error);
            }
            completedItems++;
            progress.update(completedItems, totalItems);
            await new Promise((resolve) => setTimeout(resolve, 150));
        }

        const totalPrice = priceList.reduce((a, b) => a + b, 0);
        const giftTotalPrice = giftTotal.reduce((a, b) => a + b, 0);
        console.log("Total price:", totalPrice, "Gift total:", giftTotalPrice);
        const existingTotal = parseFloat(url.searchParams.get("total")) || 0;
        const existingGiftTotal =
            parseFloat(url.searchParams.get("gift_total")) || 0;
        const newTotal = existingTotal + totalPrice;
        const newGiftTotal = existingGiftTotal + giftTotalPrice;

        url.searchParams.set("total", newTotal);
        url.searchParams.set("gift_total", newGiftTotal);

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

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

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

    if (autoCalculate) {
        (async () => {
            const { totalPrice, totalGiftPrice } = await main();
            UIComponents.addTweetButton(totalPrice, totalGiftPrice);
            UIComponents.addTotalPriceDisplay(totalPrice, totalGiftPrice);
        })();
    }
})();