// ==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 // @downloadURL none // ==/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(); } })();