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