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