팁과 노하우 게시판

전체보기

모바일 상단 메뉴

본문 페이지

[일반] 로아 플레이타임 + 결제정보 확인 코드(수정)

디그당
댓글: 245 개
조회: 130848
추천: 50
2025-05-06 00:20:29
- 좀 더 개선 -
디자인을 위해 코드 가독성 폭망
최종 업데이트 : 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();
}
})();

모바일 게시판 하단버튼

댓글

새로고침
새로고침

모바일 게시판 하단버튼

지금 뜨는 인벤

더보기+

모바일 게시판 리스트

모바일 게시판 하단버튼

글쓰기

모바일 게시판 페이징

최근 HOT한 콘텐츠

  • 로아
  • 게임
  • IT
  • 유머
  • 연예
AD