|
2025-05-06 00:20
조회: 130,852
추천: 50
로아 플레이타임 + 결제정보 확인 코드(수정)- 좀 더 개선 - 디자인을 위해 코드 가독성 폭망 최종 업데이트 : 2026.07.07 로아 플레이타임 + 결제 확인 코드 누구나 다 알겠지만, 둘을 합쳐 봄 LOST ARK의 결제만이 아닌 " STOVE "의 총 결제 내역 총합입니다 사용법 1. 로스트아크 공식 홈페이지 로그인 2. F12 눌러서 Console 탭 클릭 3. 코드 붙혀넣기 엔터 4. 만약 안되면(오류) allow pasting 엔터 후 붙혀넣기 [ 코드 입력 후 알림 창 뜨면 자동으로 내용의 텍스트가 복사 됩니다. ] (() => { "use strict"; const CONFIG = { gameName: "LOST ARK", startYear: 2017, memberLogsUrl: "https://api.onstove.com/game/v2.0/member/logs", chargeListUrl: "/Cash/GetChargeList", requestTimeoutMs: 15000, }; class LostArkStatsError extends Error { constructor(message, details = "") { super(message); this.name = "LostArkStatsError"; this.details = details; } } const getCookieValue = (name) => { const cookie = String(document.cookie || ""); const target = `${name}=`; const row = cookie .split(";") .map((part) => part.trim()) .find((part) => part.startsWith(target)); if (!row) { return ""; } try { return decodeURIComponent(row.slice(target.length)); } catch (_error) { return row.slice(target.length); } }; const formatWon = (amount) => { const value = Math.max(0, Math.trunc(Number(amount) || 0)); const eok = Math.floor(value / 100000000); const man = Math.floor((value % 100000000) / 10000); const rest = value % 10000; const parts = []; if (eok > 0) { parts.push(`${eok}억`); } if (man > 0) { parts.push(`${man}만`); } if (rest > 0 || parts.length === 0) { parts.push(`${rest}`); } return `${parts.join(" ")}원`; }; const formatDuration = (minutes) => { const totalMinutes = Math.max(0, Math.trunc(Number(minutes) || 0)); const totalHours = Math.floor(totalMinutes / 60); const totalDays = Math.floor(totalHours / 24); const totalMonths = Math.floor(totalDays / 30); const totalYears = Math.floor(totalMonths / 12); return { totalMinutes, totalHours, totalDays, totalMonths, totalYears, minutes: totalMinutes % 60, hours: totalHours % 24, days: totalDays % 30, months: totalMonths % 12, }; }; const toAmount = (text, { sumAll = false } = {}) => { const value = String(text || "").replace(/u00a0/g, " "); const wonAmounts = Array.from(value.matchAll(/([0-9][0-9,]*)s*원/g)) .map((match) => Number(match[1].replace(/[^0-9]/g, "")) || 0) .filter((amount) => amount > 0); if (wonAmounts.length > 0) { return sumAll ? wonAmounts.reduce((sum, amount) => sum + amount, 0) : wonAmounts[0]; } const plainAmounts = Array.from(value.matchAll(/[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+/g)) .map((match) => Number(match[0].replace(/[^0-9]/g, "")) || 0) .filter((amount) => amount > 0); if (plainAmounts.length > 0) { return sumAll ? plainAmounts.reduce((sum, amount) => sum + amount, 0) : plainAmounts[0]; } return 0; }; const isPaymentMetaLine = (line) => /복합s*결제|주문|번호|일시|날짜|승인/.test(line) || /^[0-9]{4}[.-][0-9]{1,2}[.-][0-9]{1,2}/.test(line); const normalizePaymentMethodParts = (line) => { const cleaned = String(line || "") .replace(/([0-9][0-9,]*)s*원/g, "") .replace(/[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+/g, "") .replace(/결제s*금액|금액|결제s*수단|수단/g, "") .replace(/[/:|]/g, " ") .replace(/s+/g, " ") .trim(); if (!cleaned || isPaymentMetaLine(cleaned)) { return []; } if (cleaned.includes("포인트")) { const baseMethod = cleaned.replace(/포인트/g, "").trim(); return baseMethod ? [baseMethod, "포인트"] : ["포인트"]; } return [cleaned]; }; const parseGroupedAmount = (lines) => { const paymentLines = lines.filter((line) => !isPaymentMetaLine(line)); const explicitWonAmount = toAmount(paymentLines.join("n"), { sumAll: true }); if (explicitWonAmount > 0 && paymentLines.some((line) => /원/.test(line))) { return explicitWonAmount; } return paymentLines.reduce((sum, line) => sum + toAmount(line, { sumAll: true }), 0); }; const parseGroupedPayment = (text) => { const lines = String(text || "") .split(/s*n+s*/) .map((line) => line.trim()) .filter(Boolean); const methods = [ ...new Set( lines .flatMap(normalizePaymentMethodParts) .filter((line) => line && !/^[0-9.-s]+$/.test(line)), ), ]; return { amount: parseGroupedAmount(lines), method: methods.length > 0 ? methods.join(" + ") : "복합 결제", }; }; const parsePageCount = (documentFragment) => { const last = documentFragment.querySelector(".pagination__last"); const onClick = last?.getAttribute("onclick") || last?.getAttribute("onClick") || ""; const fromLast = Number(onClick.match(/d+/g)?.at(-1) || ""); if (Number.isFinite(fromLast) && fromLast > 0) { return fromLast; } const pageNumbers = Array.from(documentFragment.querySelectorAll(".pagination a, .pagination button")) .map((element) => Number(String(element.textContent || "").replace(/[^0-9]/g, ""))) .filter((page) => Number.isFinite(page) && page > 0); return pageNumbers.length > 0 ? Math.max(...pageNumbers) : 1; }; const parseChargeHtml = (html) => { const parsed = new DOMParser().parseFromString(String(html || ""), "text/html"); const rows = Array.from(parsed.querySelectorAll("tr")); const entries = rows .map((row) => { const group = row.querySelector("td.list__group"); const groupedPayment = group ? parseGroupedPayment(group.textContent) : null; const priceAmount = toAmount(row.querySelector("td.list__price")?.textContent); const amount = priceAmount > 0 ? priceAmount : groupedPayment?.amount || 0; const date = String(row.querySelector("td.list__date")?.textContent || "").trim(); const method = groupedPayment?.method || String(row.querySelector("td.list__way")?.textContent || "").trim(); return { date, method, amount }; }) .filter((entry) => entry.amount > 0); return { entries, pageCount: parsePageCount(parsed), }; }; const fetchText = async (url, options = {}) => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs); try { const response = await fetch(url, { credentials: "include", ...options, signal: controller.signal, }); const text = await response.text(); if (!response.ok) { throw new LostArkStatsError(`요청 실패 (${response.status})`, text.slice(0, 200)); } return text; } catch (error) { if (error instanceof LostArkStatsError) { throw error; } if (error instanceof DOMException && error.name === "AbortError") { throw new LostArkStatsError("요청 시간이 초과되었습니다."); } throw new LostArkStatsError("네트워크 요청 중 오류가 발생했습니다.", error instanceof Error ? error.message : String(error)); } finally { clearTimeout(timeoutId); } }; const fetchJson = async (url, options = {}) => JSON.parse(await fetchText(url, options)); const fetchPlayTimeMinutes = async () => { const token = getCookieValue("SUAT"); if (!token) { throw new LostArkStatsError("로그인 토큰(SUAT)을 찾을 수 없습니다.", "STOVE에 로그인한 뒤 다시 실행하세요."); } const data = await fetchJson(CONFIG.memberLogsUrl, { method: "GET", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, }); const games = Array.isArray(data?.value) ? data.value : []; const lostArk = games.find((game) => game?.game_name === CONFIG.gameName); if (!lostArk || !Number.isFinite(Number(lostArk.play_time))) { throw new LostArkStatsError("LOST ARK 플레이 시간 정보를 찾을 수 없습니다."); } return Number(lostArk.play_time); }; const fetchChargeYear = async (year) => { const params = new URLSearchParams({ Page: "1", StartDate: `${year}.01.01`, EndDate: `${year}.12.31`, }); const firstPage = parseChargeHtml(await fetchText(`${CONFIG.chargeListUrl}?${params.toString()}`)); const pages = [firstPage]; for (let page = 2; page <= firstPage.pageCount; page += 1) { params.set("Page", String(page)); pages.push(parseChargeHtml(await fetchText(`${CONFIG.chargeListUrl}?${params.toString()}`))); } return pages.flatMap((page) => page.entries); }; const fetchAllCharges = async (startYear, endYear) => { const charges = []; const failedYears = []; for (let year = startYear; year <= endYear; year += 1) { try { charges.push(...(await fetchChargeYear(year))); } catch (error) { console.warn(`결제 내역 조회 실패: ${year}`, error); failedYears.push(year); } } return { charges, failedYears }; }; const groupTotals = (entries, keyOf) => { const totals = new Map(); for (const entry of entries) { const key = keyOf(entry); const current = totals.get(key) || { key, amount: 0, count: 0 }; totals.set(key, { key, amount: current.amount + entry.amount, count: current.count + 1 }); } return Array.from(totals.values()).sort((left, right) => right.amount - left.amount || right.count - left.count); }; const summarize = ({ playTimeMinutes, charges, failedYears, yearRange }) => { if (playTimeMinutes === null || playTimeMinutes === undefined || !Number.isFinite(Number(playTimeMinutes))) { throw new LostArkStatsError("LOST ARK 플레이 시간 정보가 올바르지 않습니다."); } const sortedCharges = [...charges].sort((left, right) => left.date.localeCompare(right.date)); const totalAmount = sortedCharges.reduce((sum, charge) => sum + charge.amount, 0); const byMethod = groupTotals(sortedCharges, (entry) => entry.method || "알 수 없음").map((entry) => ({ method: entry.key, amount: entry.amount, count: entry.count, })); const byYear = groupTotals(sortedCharges, (entry) => entry.date.slice(0, 4)).map((entry) => ({ year: entry.key, amount: entry.amount, count: entry.count, })); const byMonth = groupTotals(sortedCharges, (entry) => entry.date.slice(0, 7)).map((entry) => ({ month: entry.key, amount: entry.amount, count: entry.count, })); return { playTime: formatDuration(playTimeMinutes), payment: { totalAmount, count: sortedCharges.length, averageAmount: sortedCharges.length > 0 ? Math.round(totalAmount / sortedCharges.length) : 0, firstDate: sortedCharges[0]?.date || "", latestDate: sortedCharges.at(-1)?.date || "", largest: sortedCharges.reduce((largest, charge) => (charge.amount > largest.amount ? charge : largest), { date: "", method: "", amount: 0, }), byMethod, byYear, byMonth, }, failedYears, yearRange, }; }; const createPlayTimeLines = (playTime) => [ `총 플레이 시간: ${playTime.totalHours}시간 ${playTime.minutes}분`, ` (${playTime.totalYears}년 ${playTime.months}개월)`, ]; const createTextReport = (stats) => { const paymentLines = [ `총 결제 금액: ${formatWon(stats.payment.totalAmount)}`, `총 결제 횟수: ${stats.payment.count}회`, `첫 결제일: ${stats.payment.firstDate || "없음"}`, `최근 결제일: ${stats.payment.latestDate || "없음"}`, `최대 단일 결제: ${formatWon(stats.payment.largest.amount)} (${stats.payment.largest.date || "없음"} / ${stats.payment.largest.method || "없음"})`, "", "연도별 결제:", ...stats.payment.byYear.map((entry) => `- ${entry.year}: ${formatWon(entry.amount)} / ${entry.count}회`), "", "월별 결제 TOP 5:", ...stats.payment.byMonth.slice(0, 5).map((entry) => `- ${entry.month}: ${formatWon(entry.amount)} / ${entry.count}회`), ]; if (stats.failedYears.length > 0) { paymentLines.push("", `조회 실패 연도: ${stats.failedYears.join(", ")}`); } paymentLines.push( "", "결제 방법별 분석:", ...stats.payment.byMethod.map((entry) => `- ${entry.method}: ${formatWon(entry.amount)} / ${entry.count}회`), ); return [ "플레이 시간", ...createPlayTimeLines(stats.playTime), "", `로스트아크 결제 정보 (${stats.yearRange.start}~${stats.yearRange.end}):`, ...paymentLines, ].join("n"); }; const appendText = (parent, tagName, text, style = {}) => { const element = document.createElement(tagName); element.textContent = text; Object.assign(element.style, style); parent.appendChild(element); return element; }; const appendList = (parent, items, emptyText) => { if (items.length === 0) { appendText(parent, "p", emptyText, { margin: "6px 0", color: "#777" }); return; } const list = document.createElement("ul"); Object.assign(list.style, { margin: "6px 0 14px 0", paddingLeft: "18px" }); for (const item of items) { appendText(list, "li", item); } parent.appendChild(list); }; const appendPlayTime = (parent, playTime) => { const wrapper = document.createElement("div"); Object.assign(wrapper.style, { margin: "6px 0 14px 0" }); appendText(wrapper, "div", `총 플레이 시간: ${playTime.totalHours}시간 ${playTime.minutes}분`, { margin: "0", }); appendText(wrapper, "div", `(${playTime.totalYears}년 ${playTime.months}개월)`, { margin: "2px 0 0 0", paddingLeft: "132px", }); parent.appendChild(wrapper); }; const renderModal = (stats, reportText, copyResult = { ok: false, method: "notAttempted" }) => { document.getElementById("lostark-stats-modal")?.remove(); const modal = document.createElement("div"); modal.id = "lostark-stats-modal"; Object.assign(modal.style, { position: "fixed", inset: "0", backgroundColor: "rgba(0,0,0,0.68)", zIndex: "10000", display: "flex", justifyContent: "center", alignItems: "center", padding: "18px", boxSizing: "border-box", }); const content = document.createElement("section"); Object.assign(content.style, { backgroundColor: "#fff", color: "#222", width: "680px", maxWidth: "100%", maxHeight: "88vh", overflowY: "auto", borderRadius: "10px", padding: "24px", fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Malgun Gothic', sans-serif", boxShadow: "0 12px 36px rgba(0,0,0,0.28)", lineHeight: "1.55", }); appendText(content, "h2", "로스트아크 통계", { margin: "0 0 16px 0", paddingBottom: "12px", borderBottom: "1px solid #e8e8e8", textAlign: "center", }); appendText(content, "h3", "플레이 시간", { margin: "16px 0 8px 0" }); appendPlayTime(content, stats.playTime); appendText(content, "h3", `결제 정보 (${stats.yearRange.start}~${stats.yearRange.end})`, { margin: "16px 0 8px 0" }); appendList(content, [ `총 결제 금액: ${formatWon(stats.payment.totalAmount)}`, `총 결제 횟수: ${stats.payment.count}회`, `첫 결제일: ${stats.payment.firstDate || "없음"}`, `최근 결제일: ${stats.payment.latestDate || "없음"}`, `최대 단일 결제: ${formatWon(stats.payment.largest.amount)} (${stats.payment.largest.date || "없음"} / ${stats.payment.largest.method || "없음"})`, ], "결제 내역이 없습니다."); appendText(content, "h3", "연도별 결제", { margin: "16px 0 8px 0" }); appendList(content, stats.payment.byYear.map((entry) => `${entry.year}: ${formatWon(entry.amount)} / ${entry.count}회`), "연도별 결제 데이터가 없습니다."); appendText(content, "h3", "월별 결제 TOP 5", { margin: "16px 0 8px 0" }); appendList(content, stats.payment.byMonth.slice(0, 5).map((entry) => `${entry.month}: ${formatWon(entry.amount)} / ${entry.count}회`), "월별 결제 데이터가 없습니다."); if (stats.failedYears.length > 0) { appendText(content, "p", `조회 실패 연도: ${stats.failedYears.join(", ")}`, { color: "#9a5b00", backgroundColor: "#fff7e6", padding: "10px", borderRadius: "6px", }); } appendText(content, "h3", "결제 방법별 분석", { margin: "16px 0 8px 0" }); appendList(content, stats.payment.byMethod.map((entry) => `${entry.method}: ${formatWon(entry.amount)} / ${entry.count}회`), "결제 방법 데이터가 없습니다."); appendText( content, "p", copyResult.ok ? "텍스트 보고서가 클립보드에 복사되었습니다." : "자동 복사는 실패했습니다. 아래 버튼으로 다시 복사할 수 있습니다.", { color: copyResult.ok ? "#166534" : "#9a5b00", backgroundColor: copyResult.ok ? "#ecfdf3" : "#fff7e6", padding: "10px", borderRadius: "6px", }, ); const actions = document.createElement("div"); Object.assign(actions.style, { display: "flex", gap: "8px", justifyContent: "flex-end", marginTop: "18px" }); const copyButton = document.createElement("button"); copyButton.type = "button"; copyButton.textContent = "텍스트 복사"; Object.assign(copyButton.style, buttonStyle("#2563eb")); copyButton.onclick = async () => { const retryResult = await copyText(reportText); copyButton.textContent = retryResult.ok ? "복사됨" : "복사 실패"; }; const closeButton = document.createElement("button"); closeButton.type = "button"; closeButton.textContent = "닫기"; Object.assign(closeButton.style, buttonStyle("#333")); closeButton.onclick = () => modal.remove(); actions.append(copyButton, closeButton); content.appendChild(actions); modal.appendChild(content); document.body.appendChild(modal); }; const renderError = (error) => { document.getElementById("lostark-stats-modal")?.remove(); const modal = document.createElement("div"); modal.id = "lostark-stats-modal"; Object.assign(modal.style, { position: "fixed", inset: "0", backgroundColor: "rgba(0,0,0,0.68)", zIndex: "10000", display: "flex", justifyContent: "center", alignItems: "center", padding: "18px", boxSizing: "border-box", }); const content = document.createElement("section"); Object.assign(content.style, { backgroundColor: "#fff", color: "#222", width: "520px", maxWidth: "100%", borderRadius: "10px", padding: "22px", fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Malgun Gothic', sans-serif", boxShadow: "0 12px 36px rgba(0,0,0,0.28)", }); appendText(content, "h2", "로스트아크 통계 조회 실패", { margin: "0 0 12px 0" }); appendText(content, "p", error instanceof Error ? error.message : String(error), { margin: "0 0 8px 0" }); if (error instanceof LostArkStatsError && error.details) { appendText(content, "p", error.details, { margin: "0 0 14px 0", color: "#666" }); } const closeButton = document.createElement("button"); closeButton.type = "button"; closeButton.textContent = "닫기"; Object.assign(closeButton.style, buttonStyle("#333")); closeButton.onclick = () => modal.remove(); content.appendChild(closeButton); modal.appendChild(content); document.body.appendChild(modal); }; const buttonStyle = (backgroundColor) => ({ padding: "9px 16px", border: "0", borderRadius: "6px", backgroundColor, color: "#fff", cursor: "pointer", fontSize: "14px", fontWeight: "600", }); const copyText = async (text) => { if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return { ok: true, method: "clipboardApi" }; } catch (error) { console.warn("Clipboard API 복사 실패, textarea fallback을 시도합니다.", error); } } const textarea = document.createElement("textarea"); textarea.value = text; Object.assign(textarea.style, { position: "fixed", left: "-9999px", top: "0", }); document.body.appendChild(textarea); textarea.select(); const copied = document.execCommand("copy"); textarea.remove(); return copied ? { ok: true, method: "textareaFallback" } : { ok: false, method: "textareaFallback" }; }; const run = async () => { try { const endYear = new Date().getFullYear(); const [playTimeMinutes, chargeResult] = await Promise.all([ fetchPlayTimeMinutes(), fetchAllCharges(CONFIG.startYear, endYear), ]); const stats = summarize({ playTimeMinutes, charges: chargeResult.charges, failedYears: chargeResult.failedYears, yearRange: { start: CONFIG.startYear, end: endYear }, }); const reportText = createTextReport(stats); const copyResult = await copyText(reportText); renderModal(stats, reportText, copyResult); console.log(copyResult.ok ? "로스트아크 통계가 복사되었습니다." : "로스트아크 통계 조회는 완료됐지만 자동 복사는 실패했습니다."); } catch (error) { console.error(error); renderError(error); } }; window.__lostArkStatsSnippetTest = { copyText, createPlayTimeLines, createTextReport, formatWon, getCookieValue, parseChargeHtml, summarize, }; if (!window.__LOSTARK_STATS_SNIPPET_TEST_MODE__) { void run(); } })();
|





