Last active
November 17, 2024 08:00
-
-
Save eai04191/0ec5c9847ed4f365b277ff729c1859b1 to your computer and use it in GitHub Desktop.
Boothの購入金額とかを集計するスクリプト
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @ts-check | |
// --- | |
// | |
// Boothの購入金額とかを集計するスクリプト | |
// | |
// --- | |
// | |
// 自己責任で実行してください | |
// | |
// Copyright (c) 2024 Eai | |
// Released under the MIT license | |
// https://opensource.org/licenses/mit-license.php | |
// | |
// 使い方: | |
// 1. https://accounts.booth.pm/orders にアクセス | |
// 2. ブラウザのコンソールにスクリプトを貼り付けて実行 | |
// 3. 待つ | |
// 4. Profit | |
// | |
// --- | |
// types | |
/** | |
* @typedef {Object} Order | |
* @property {string} id | |
* @property {string} purchaseDate | |
* @property {string} paymentMethod | |
* @property {number} shippingFee | |
* @property {number} priceSum | |
* @property {Item[]} items | |
*/ | |
/** | |
* @typedef {Object} Item | |
* @property {string} id | |
* @property {string} url | |
* @property {string} thumbnail | |
* @property {string} title | |
* @property {number} price | |
* @property {number} quantity | |
* @property {number} boost | |
*/ | |
// utils | |
function getConfig() { | |
return { | |
name: "booth-order-analysis", | |
waitPerFetch: 1100, | |
}; | |
} | |
/** | |
* @param {number} ms | |
*/ | |
async function wait(ms) { | |
return new Promise((resolve) => setTimeout(resolve, ms)); | |
} | |
/** | |
* @param {"info" | "warn" | "error"} type | |
* @param {...any} args | |
*/ | |
function log(type, ...args) { | |
const header = `%c[${getConfig().name}]`; | |
const headerStyle = | |
"background: rgb(252, 77, 80); color: white; font-weight: bold;"; | |
switch (type) { | |
case "info": | |
console.info(header, headerStyle, ...args); | |
return; | |
case "warn": | |
console.warn(header, headerStyle, ...args); | |
return; | |
case "error": | |
console.error(header, headerStyle, ...args); | |
return; | |
} | |
} | |
/** | |
* @param {string | number} text | |
* @returns {number} | |
*/ | |
function extractNumber(text) { | |
if (typeof text === "number") { | |
return text; | |
} | |
return Number(text.replace(/[^0-9]/g, "")); | |
} | |
/** | |
* @param {Element | null | undefined} element | |
* @returns {string} | |
*/ | |
function getText(element) { | |
if (!element) { | |
throw new Error("element not found"); | |
} | |
return element.textContent || ""; | |
} | |
/** | |
* /orders のHTMLをparse | |
* @param {string} html | |
* @returns {Promise<Order[]>} | |
*/ | |
async function parseOrders(html) { | |
const dom = new DOMParser().parseFromString(html, "text/html"); | |
const orderUrls = extractOrderUrls(dom); | |
const orders = await orderUrls.reduce( | |
/** | |
* @param {Promise<Order[]>} promise | |
* @param {string} url | |
* @returns {Promise<Order[]>} | |
*/ | |
async (promise, url) => { | |
const results = await promise; | |
log("info", "fetching", url); | |
try { | |
const result = await fetch(url) | |
.then((res) => res.text()) | |
.then(parseOrder); | |
results.push(result); | |
} catch (error) { | |
log("error", "Failed to fetch or parse order from", url, error); | |
} | |
await wait(getConfig().waitPerFetch); | |
return results; | |
}, | |
Promise.resolve([]) | |
); | |
return orders; | |
} | |
/** | |
* @param {Document} dom | |
* @returns {string[]} List of unique order URLs | |
*/ | |
function extractOrderUrls(dom) { | |
const orderLinks = [...dom.querySelectorAll(`a[href^="/orders/"]`)] | |
.map((e) => e.getAttribute("href")) | |
.filter((e) => e !== null); | |
return [...new Set(orderLinks)]; | |
} | |
/** | |
* @param {string} html /orders/1234 のHTML | |
* @returns {Order} | |
*/ | |
function parseOrder(html) { | |
const dom = new DOMParser().parseFromString(html, "text/html"); | |
const meta = dom.querySelector(`.sheet > div > div`); | |
if (!meta) { | |
throw new Error("order meta not found"); | |
} | |
const order = (() => { | |
const shippingFeeElement = dom.querySelector( | |
`.sheet > div > span.particulars-heading` | |
)?.parentElement; | |
const shippingFee = shippingFeeElement | |
? extractNumber(getText(shippingFeeElement)) | |
: 0; | |
const mapping = { | |
ja: { | |
purchaseDate: "注文日時", | |
id: "注文番号", | |
shippingAddress: "お届け先住所", | |
paymentMethod: "お支払方法", | |
paymentFee: "支払手数料", | |
priceSum: "お支払金額", | |
}, | |
}; | |
const lang = dom.documentElement.lang; | |
if (!mapping[lang]) { | |
throw new Error("unsupported language: " + lang); | |
} | |
const extractValue = (/** @type {string} */ key) => { | |
const element = [...meta.querySelectorAll(`div`)].find( | |
(e) => e.textContent === mapping[lang][key] | |
)?.nextElementSibling; | |
if (!element) { | |
if (key === "paymentFee") { | |
return "0"; | |
} | |
throw new Error(`element not found: ${key}`); | |
} | |
if (element.textContent === null) { | |
throw new Error(`element textContent is null: ${key}`); | |
} | |
return element.textContent; | |
}; | |
const purchaseDate = extractValue("purchaseDate"); | |
const id = extractValue("id"); | |
const paymentMethod = extractValue("paymentMethod"); | |
const paymentFee = extractNumber(extractValue("paymentFee")); | |
const priceSum = extractNumber(extractValue("priceSum")); | |
return { | |
purchaseDate, | |
id, | |
shippingFee, | |
paymentMethod, | |
paymentFee, | |
priceSum, | |
}; | |
})(); | |
const itemElements = [ | |
...dom.querySelectorAll(`.sheet pixiv-icon[name="24/XTwitter"]`), | |
] | |
.map((e) => e.closest(`.sheet`)) | |
.filter((e) => e !== null); | |
const items = itemElements.map(extractItem); | |
// 金額の合計が一致しているか確認 | |
const priceSum = | |
items.reduce( | |
(sum, item) => sum + item.price * item.quantity + item.boost, | |
0 | |
) + | |
order.shippingFee + | |
order.paymentFee; | |
if (priceSum !== order.priceSum) { | |
log("error", "price sum mismatch", priceSum, order.priceSum, { | |
...order, | |
items, | |
}); | |
throw new Error("price sum mismatch"); | |
} | |
return { ...order, items }; | |
} | |
/** | |
* @param {Element} e | |
* @returns {Item} | |
*/ | |
function extractItem(e) { | |
const itemMetaEl = e.querySelector(`b a`)?.closest(`div`)?.parentElement; | |
if (!itemMetaEl) { | |
throw new Error("item meta not found"); | |
} | |
const url = e.querySelector(`a`)?.getAttribute("href"); | |
if (!url) { | |
throw new Error("item url not found"); | |
} | |
const id = url.match(/\d+$/)?.[0]; | |
if (!id) { | |
throw new Error("id not found in url"); | |
} | |
const thumbnail = e.querySelector(`img`)?.getAttribute("src") || ""; | |
const title = getText(itemMetaEl.querySelector(`a`)); | |
const price = extractNumber( | |
getText(itemMetaEl.querySelector(`div:nth-child(2)`)) | |
); | |
// 数量の記載があれば、個数を取得 | |
const quantity = | |
itemMetaEl.childElementCount === 4 | |
? extractNumber( | |
getText(itemMetaEl.querySelector(`div:nth-child(3)`)) | |
) | |
: 1; | |
const boost = extractNumber( | |
getText(itemMetaEl.querySelector(`.icon-boost`)?.closest(`div`)) | |
); | |
return { id, url, thumbnail, title, price, quantity, boost }; | |
} | |
function validateLocation() { | |
const expectUrl = `https://accounts.booth.pm/orders`; | |
if (location.href !== expectUrl) { | |
throw new Error(`location is not ${expectUrl}`); | |
} | |
} | |
async function fetchOrderPageHtml(page) { | |
return await fetch(`https://accounts.booth.pm/orders?page=${page}`).then( | |
(res) => res.text() | |
); | |
} | |
function hasNextPage(dom) { | |
return !!dom.querySelector(`[rel="next"]`); | |
} | |
/** | |
* @returns {Promise<Order[]>} | |
*/ | |
async function getAllOrders() { | |
/** @type {Map<string,Order>} */ | |
const orders = new Map(); | |
let page = 1; | |
while (true) { | |
const html = await fetchOrderPageHtml(page); | |
const dom = new DOMParser().parseFromString(html, "text/html"); | |
const pageOrders = await parseOrders(html); | |
pageOrders.forEach((order) => { | |
orders.set(order.id, order); | |
}); | |
log( | |
"info", | |
`Parse page ${page} done`, | |
pageOrders.length, | |
"orders added" | |
); | |
if (!hasNextPage(dom)) break; | |
page++; | |
} | |
return [...orders.values()]; | |
} | |
/** | |
* @param {Order[]} orders | |
*/ | |
function analyzeOrders(orders) { | |
const formatters = { | |
currency: new Intl.NumberFormat("ja-JP", { | |
style: "currency", | |
currency: "JPY", | |
}), | |
number: new Intl.NumberFormat("ja-JP"), | |
}; | |
const totalBoost = orders.reduce( | |
(sum, order) => | |
sum + | |
order.items.reduce((itemSum, item) => itemSum + item.boost, 0), | |
0 | |
); | |
const totalItems = orders.reduce( | |
(sum, order) => sum + order.items.length, | |
0 | |
); | |
const totalPrice = orders.reduce((sum, order) => sum + order.priceSum, 0); | |
const mostExpensiveOrder = orders.reduce((max, order) => | |
max.priceSum > order.priceSum ? max : order | |
); | |
const mostExpensiveItem = mostExpensiveOrder.items.reduce((max, item) => | |
max.price > item.price ? max : item | |
); | |
return { | |
summary: { | |
totalBoost: formatters.currency.format(totalBoost), | |
totalItems: formatters.number.format(totalItems), | |
totalPrice: formatters.currency.format(totalPrice), | |
}, | |
interesting: { | |
mostExpensiveOrder, | |
mostExpensiveItem, | |
}, | |
}; | |
} | |
(async () => { | |
throw new Error( | |
[ | |
"安易なスクリプトの実行は重大なセキュリティリスク等をもたらす可能性があります。", | |
"安全のため、JavaScriptが読めない人はこのスクリプトを使わないでください。", | |
"読める人はこの行を削除して再度実行してください。", | |
].join("\n") | |
); | |
try { | |
validateLocation(); | |
const orders = await getAllOrders(); | |
const analysis = analyzeOrders(orders); | |
log("info", "Orders Analysis:", orders); | |
log("info", "Summary:", analysis.summary); | |
log("info", "Interesting Data:", analysis.interesting); | |
} catch (error) { | |
log("error", "Analysis failed:", error); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment