// ==UserScript==
// @name STOVE 플레이크 전체 자동화 + 상태 패널
// @namespace https://github.com/supsupsupsupsu/stove-auto
// @version 0.5.0
// @description 캡슐 뽑기 → 캡슐 누적 보상 → Daily Shop → 미션 → 인기 게시글 → 라운지 → 보상 수령을 자동 처리합니다. (뽑기 성공 검증 / 단계 워치독 / 자동 복구 포함)
// @homepageURL https://github.com/supsupsupsupsu/stove-auto
// @supportURL https://github.com/supsupsupsupsu/stove-auto/issues
// @updateURL https://raw.githubusercontent.com/supsupsupsupsu/stove-auto/main/stove-auto.user.js
// @downloadURL https://raw.githubusercontent.com/supsupsupsupsu/stove-auto/main/stove-auto.user.js
// @match https://reward.onstove.com/*
// @match https://event.onstove.com/ko/dailyshop/*
// @match https://lounge.onstove.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_openInTab
// @grant GM_registerMenuCommand
// @run-at document-idle
// ==/UserScript==
/* =============================================================================
* 변경 이력
*
* 0.5.0 완료 시 뜨던 요약 토스트 제거 (패널에 같은 내용이 이미 있어 화면만 가림).
* '오늘 획득' 표시 추가 — 사이트는 '이번 달 획득'만 제공하므로
* 오늘 최초 관찰 잔액을 기준선으로 잡아 직접 계산한다.
* 획득 = 잔액 순증감 + 오늘 뽑기 소모(원장 기준).
*
* 0.4.0 설문조사 자동 참여(무작위 선택) 추가.
* 사이트 i18n 번들(flakeshop.mission.button.*)에서 확인한 문구로
* 수령 버튼 분류기(classifyMissionButton)를 만들어 분기를 확정.
* → 'N 플레이크 받기' 형태를 놓치던 버그 수정 (설문·인기글 보상 누락).
* 데일리/위클리는 '미션하기'를 누르지 않고 수령만 처리.
*
* 0.3.0 "오늘 30회 뽑기 완료!" 팝업을 오류가 아닌 정상 종료로 처리.
* 소진 시 뽑기를 건너뛰고 다음 단계로 진행.
* 뽑기 손익 정산(보상 합계 / 소모 / 잔액 실측 변화) 추가.
*
* 0.2.0 '이미 완료' 상태를 실패와 구분해 표시 (✅ / ☑️ / 🔒 / ⏭️ / ⬜).
* 캡슐 누적 보상은 수령 후 버튼에서 금액이 사라지므로
* 달성 배수(x5/x10/x20) 기준으로 카드를 식별하도록 변경.
*
* 0.1.0 전면 재작성.
* Daily Shop URL 연월 하드코딩 제거(KST 기준 자동 계산),
* '보상받기' 공백 불일치 수정, phase 라우터 + 워치독/자동 복구,
* 뽑기 성공을 버튼 상태가 아닌 팝업/잔액 이중 신호로 검증.
* ========================================================================== */
/* eslint-disable no-console */
(() => {
'use strict';
// ===========================================================================
// 0. 설정
// ===========================================================================
// 버전은 위 메타데이터 @version 한 곳만 고치면 된다.
// (상수로 따로 적어두면 반드시 한쪽이 어긋난다)
const VERSION =
(typeof GM_info !== 'undefined' && GM_info?.script?.version) || 'dev';
// 저장소 키는 스키마가 바뀌면 뒤 숫자를 올린다. (이전 버전 잔여 상태와 충돌 방지)
const KEY = {
MAIN: '__stove_auto_main_s1',
RETRY: '__stove_daily_retry_s1',
LOG: '__stove_auto_status_log_s1',
LEDGER: '__stove_draw_ledger_s1',
};
const CFG = {
// 캡슐 뽑기 목표 횟수
DRAW_COUNT: 30,
// 1회 뽑기 결과를 기다리는 최대 시간
DRAW_RESULT_TIMEOUT: 12000,
// 결과 확인 실패 시 같은 회차를 다시 시도할 횟수
DRAW_RETRY_PER_ROUND: 2,
// 오늘의 아이템 재시도 간격(4시간) / 당일 최종 시도 시각
RETRY_INTERVAL_MS: 4 * 60 * 60 * 1000,
FINAL_RETRY_TIME: '23:50:00',
// 방문 미션 체류 시간
VISIT_WAIT: 4000,
// 워치독: 이 시간 동안 상태 갱신이 없으면 멈춘 것으로 판단
STALL_TIMEOUT_MS: 90 * 1000,
WATCHDOG_INTERVAL_MS: 10 * 1000,
// 같은 phase 에서 페이지 이동을 이 횟수 이상 반복하면 중단(무한 리다이렉트 방지)
MAX_NAV_PER_PHASE: 4,
// 단계 전체 타임아웃 (이 시간을 넘기면 해당 단계는 실패 처리하고 다음으로)
STEP_TIMEOUT_MS: 5 * 60 * 1000,
// 페이지 리로드로 뽑기가 끊겼을 때 남은 횟수를 자동 재개할지 여부
// (중복 차감 위험이 있어 기본 false — 이후 단계는 그대로 이어서 진행)
RESUME_DRAW_AFTER_RELOAD: false,
};
// 자주 바뀌는 셀렉터는 한 곳에 모아둔다. 사이트 개편 시 여기만 고치면 된다.
const SEL = {
flake: [
'span.whitespace-nowrap.block.overflow-ellipsis',
'[class*="flake"] span.whitespace-nowrap',
],
rewardPopup: '.l1l2-flakehub-popup-common-received_reward',
drawButtonText: 'span.button-draw-hover-text',
repeatButtonText: 'span.block.whitespace-nowrap',
popularAnchor: 'a[href*="page.onstove.com"]',
loungeTitle: 'textarea.sc-feed-editor-form-title',
loungeBody: 'div.fr-element.fr-view',
loungeSubmit: 'button.sc-feed-editor-submit-button',
feedTitle: '.sc-feed-detail-header-title',
commentOpener: '.sc-feed-comment-editor-form-button',
commentBox: '.sc-feed-comment-editor-content',
commentSubmit: '.sc-feed-comment-editor-submit-button',
likeButton: '.sc-feed-detail-like-button',
};
const BODY_TEXT = '오늘도 좋은 하루 보내세요!';
const COMMENT_TEXT = '좋은 글 잘 봤습니다';
const LOUNGE_URL =
'https://lounge.onstove.com/feed/%ED%94%8C%EB%A0%88%EC%9D%B4%ED%81%AC%EB%AF%B8%EC%85%98';
const REWARD_URL = 'https://reward.onstove.com/ko/event';
// Daily Shop URL 의 연월은 매달 바뀐다. 하드코딩하면 다음 달에 반드시 멈춘다.
const DAILY_SHOPS = [
{ code: 'RIICHICITY_IND', label: '마작일번가' },
{ code: 'STOVEINDIE', label: '스토브 스토어' },
];
const VISIT_MISSIONS = [
'365일 특가 게임 구경하기',
'MY홈 방문하기',
'스토브 메인 방문하기',
];
const LOUNGE_MISSIONS = [
'라운지 글쓰기',
'라운지 좋아요 누르기',
'라운지 댓글 쓰기',
];
/**
* 미션 버튼 문구 사전.
*
* 추측이 아니라 사이트 i18n 번들(_nuxt/D8HRc0Qn.js)의
* `flakeshop.mission.button.*` 키에서 그대로 확인한 값이다.
*
* go_to_mission "미션하기"
* receive "받기" ← 수령 가능
* receive_reward "{count} 플레이크 받기" ← 수령 가능 (금액 표시형)
* receive_complete "받기 완료" ← 이미 수령
* join_survey "설문 참여하고 {count} 플레이크 받기"
* see_more_achievement "업적 자세히 보기"
*
* 뱃지(flakeshop.mission.badge.*): "도전 중" / "달성 완료" / "미션 완료" / "최고 단계"
*
* 즉 데일리·위클리를 포함한 모든 미션의 수령 버튼은 `받기` 또는 `N 플레이크 받기` 둘 뿐이고,
* 끝이 `완료` 면 이미 받은 것이다.
*/
const SURVEY_HEADING = '설문조사 참여하고 플레이크 받기!';
const MISSION_BUTTON = {
goToMission: '미션하기',
receive: '받기',
receiveComplete: '받기 완료',
seeAchievement: '업적 자세히 보기',
// squash(공백 제거) 기준 패턴
receiveRewardRe: /^[\d,]*플레이크받기$/,
joinSurveyRe: /^설문참여하고[\d,]*플레이크받기$/,
percentRe: /\d(\.\d+)?%$/,
};
/**
* 캡슐 누적 보상.
*
* 주의: 수령을 마치면 버튼 문구에서 금액이 사라지고 그냥 '플레이크 받기 완료' 가 된다.
* 그래서 버튼 라벨만으로는 어느 칸이 완료됐는지 알 수 없다.
* 옆에 붙은 달성 조건 문구로 카드를 특정하고, 그 카드 안의 버튼 상태를 읽는다.
*/
const CAPSULE_REWARDS = [
{ label: '2,000 플레이크 받기', multiplier: 5 },
{ label: '5,000 플레이크 받기', multiplier: 10 },
{ label: '20,000 플레이크 받기', multiplier: 20 },
];
// ===========================================================================
// 1. 기본 유틸
// ===========================================================================
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const random = (min, max) =>
Math.floor(Math.random() * (max - min + 1)) + min;
const normalize = text =>
String(text ?? '').replace(/,/g, '').replace(/\s+/g, ' ').trim();
/**
* 버튼 텍스트 비교용. 공백까지 전부 제거한다.
*
* 실제 사이트는 같은 기능을 페이지마다 다르게 띄어쓴다.
* (예: Daily Shop 은 "보상받기", 리워드 페이지는 "플레이크 받기")
* 공백을 남겨두면 이런 버튼을 영영 못 찾아 조용히 아무것도 안 하게 된다.
*/
const squash = text => normalize(text).replace(/\s/g, '');
const signedNumber = n => `${n >= 0 ? '+' : ''}${Number(n).toLocaleString()}`;
const escapeHtml = value =>
String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
function kstDateKey(nowMs = Date.now()) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(nowMs));
}
/** KST 기준 YYYYMM (Daily Shop URL 용) */
function kstYearMonth(nowMs = Date.now()) {
return kstDateKey(nowMs).slice(0, 7).replace('-', '');
}
function shopUrl(shop, ym = kstYearMonth()) {
return `https://event.onstove.com/ko/dailyshop/${shop.code}/${ym}`;
}
function kstTime(ms, withSeconds = false) {
return new Date(ms).toLocaleTimeString('ko-KR', {
timeZone: 'Asia/Seoul',
hour: '2-digit',
minute: '2-digit',
...(withSeconds ? { second: '2-digit' } : {}),
hour12: false,
});
}
function isVisible(el) {
if (!el || !document.contains(el)) return false;
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
/**
* getter 가 truthy 를 반환할 때까지 대기.
* onTick 이 있으면 매 폴링마다 호출해 워치독 하트비트를 유지한다.
*/
async function waitFor(getter, timeout = 12000, interval = 250) {
const end = Date.now() + timeout;
while (Date.now() < end) {
let value = null;
try {
value = getter();
} catch (error) {
console.warn('[stove] waitFor getter 오류', error);
}
if (value) return value;
beat();
await delay(interval);
}
return null;
}
/** 어떤 promise 든 지정 시간 안에 반드시 끝나게 만든다 (영구 대기 방지). */
function withTimeout(promise, ms, label) {
let timer = null;
const guard = new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error(`${label} 단계가 ${Math.round(ms / 1000)}초 안에 끝나지 않았습니다.`)),
ms
);
});
return Promise.race([promise, guard]).finally(() => clearTimeout(timer));
}
// ===========================================================================
// 2. 저장소 (GM storage)
// ===========================================================================
const DEFAULT_MAIN = {
active: false,
phase: 'idle',
progress: {},
};
function getMain() {
const value = GM_getValue(KEY.MAIN, null);
return value && typeof value === 'object' ? { ...DEFAULT_MAIN, ...value } : { ...DEFAULT_MAIN };
}
function setMain(patch) {
const next = { ...getMain(), ...patch, updatedAt: Date.now() };
GM_setValue(KEY.MAIN, next);
markPanelDirty();
return next;
}
/**
* 상태 값은 그대로 두고 "살아있다"는 신호만 갱신 (워치독용).
* 폴링마다 저장하면 낭비라 5초에 한 번으로 제한한다.
*/
let lastBeatAt = 0;
function beat() {
const now = Date.now();
if (now - lastBeatAt < 5000) return;
const current = getMain();
if (!current.active) return;
lastBeatAt = now;
GM_setValue(KEY.MAIN, { ...current, updatedAt: now });
}
function patchProgress(patch) {
const current = getMain();
return setMain({ progress: { ...(current.progress || {}), ...patch } });
}
function patchLounge(patch) {
return patchProgress({
lounge: { ...(getMain().progress?.lounge || {}), ...patch },
});
}
function clearMain() {
GM_deleteValue(KEY.MAIN);
markPanelDirty();
}
function getRetries() {
const value = GM_getValue(KEY.RETRY, null);
return value && typeof value === 'object' ? value : {};
}
function setRetries(value) {
GM_setValue(KEY.RETRY, value);
markPanelDirty();
}
function getStatusLog() {
const value = GM_getValue(KEY.LOG, null);
return Array.isArray(value) ? value : [];
}
function clearStatusLog() {
GM_deleteValue(KEY.LOG);
markPanelDirty();
}
/**
* 로그는 명시적으로 기록한다.
* (기존 버전은 console.log 를 가로채 이모지 정규식으로 걸렀는데,
* 새 이모지를 쓸 때마다 조용히 누락되는 구조라 제거했다.)
*/
function logStatus(message) {
if (!message) return;
console.log(`[stove] ${message}`);
const logs = getStatusLog();
logs.push({ time: Date.now(), message: String(message) });
while (logs.length > 80) logs.shift();
GM_setValue(KEY.LOG, logs);
markPanelDirty();
}
// --- 뽑기 기록(원장): "오늘 이미 뽑았는지"를 버튼 상태가 아니라 기록으로 판단 ---
function getLedger() {
const value = GM_getValue(KEY.LEDGER, null);
const today = kstDateKey();
if (!value || typeof value !== 'object' || value.date !== today) {
return {
date: today,
total: 0,
byCost: {},
runs: [],
firstFlake: null,
lastFlake: null,
};
}
return value;
}
/**
* 오늘의 플레이크 증감을 추적한다.
*
* 사이트는 '이번 달 획득'만 보여주고 일간 수치는 제공하지 않는다.
* 그래서 오늘 처음 관찰한 잔액을 기준선으로 잡고 직접 계산한다.
* (기준선은 KST 자정에 자동으로 초기화된다)
*/
function observeFlake() {
const flake = getFlakeCount();
if (flake === null) return;
const ledger = getLedger();
let changed = false;
if (ledger.firstFlake == null) {
ledger.firstFlake = flake;
ledger.firstAt = Date.now();
changed = true;
}
if (ledger.lastFlake !== flake) {
ledger.lastFlake = flake;
changed = true;
}
if (changed) {
GM_setValue(KEY.LEDGER, ledger);
markPanelDirty();
}
}
/**
* 오늘 정산.
* spent — 오늘 뽑기에 쓴 플레이크 (원장 기준, 정확)
* net — 잔액 순증감 (기준선 대비)
* earned — net + spent = 실제로 벌어들인 양
*/
function getTodaySummary() {
const ledger = getLedger();
const spent = Object.entries(ledger.byCost || {}).reduce(
(sum, [cost, count]) => sum + Number(cost) * Number(count),
0
);
const net =
ledger.firstFlake != null && ledger.lastFlake != null
? ledger.lastFlake - ledger.firstFlake
: null;
return {
spent,
net,
earned: net == null ? null : net + spent,
baseline: ledger.firstFlake,
};
}
function recordDraw(cost) {
const ledger = getLedger();
ledger.total += 1;
ledger.byCost[cost] = (ledger.byCost[cost] || 0) + 1;
ledger.lastAt = Date.now();
GM_setValue(KEY.LEDGER, ledger);
return ledger;
}
function recordRun(cost, completed) {
const ledger = getLedger();
ledger.runs.push({ cost, completed, at: Date.now() });
while (ledger.runs.length > 20) ledger.runs.shift();
GM_setValue(KEY.LEDGER, ledger);
markPanelDirty();
}
// ===========================================================================
// 3. Daily Shop 재시도 상태
// ===========================================================================
function cleanupOldRetries() {
const today = kstDateKey();
const retries = getRetries();
let changed = false;
for (const [code, item] of Object.entries(retries)) {
if (!item || item.date !== today) {
delete retries[code];
changed = true;
}
}
if (changed) setRetries(retries);
return retries;
}
function getFinalRetryMs(dateKey = kstDateKey()) {
return new Date(`${dateKey}T${CFG.FINAL_RETRY_TIME}+09:00`).getTime();
}
function getNextRetryAt() {
const now = Date.now();
const finalMs = getFinalRetryMs();
if (now >= finalMs) return null;
return Math.min(now + CFG.RETRY_INTERVAL_MS, finalMs);
}
function scheduleDailyRetry(shopCode) {
const retries = cleanupOldRetries();
const nextAt = getNextRetryAt();
const prev = retries[shopCode] || {};
if (nextAt === null) {
delete retries[shopCode];
setRetries(retries);
logStatus(`⏭️ ${shopCode}: 최종 시도 시각(${CFG.FINAL_RETRY_TIME})이 지나 재시도를 종료합니다.`);
return null;
}
retries[shopCode] = {
pending: true,
date: kstDateKey(),
nextAt,
retryCount: (prev.retryCount || 0) + 1,
inProgress: false,
returnUrl: prev.returnUrl || '',
};
setRetries(retries);
logStatus(`⏰ ${shopCode}: 오늘의 아이템 재시도 예약 → ${kstTime(nextAt)}`);
return nextAt;
}
function clearDailyRetry(shopCode) {
const retries = getRetries();
if (!(shopCode in retries)) return;
delete retries[shopCode];
setRetries(retries);
}
function findDueRetry() {
const retries = cleanupOldRetries();
const now = Date.now();
return (
Object.entries(retries)
.filter(([, item]) => item?.pending && item.nextAt <= now)
.sort((a, b) => a[1].nextAt - b[1].nextAt)[0] || null
);
}
function armRetryTimer() {
cleanupOldRetries();
if (getMain().active) return;
const pending = Object.entries(getRetries())
.filter(([, item]) => item?.pending && item.date === kstDateKey())
.sort((a, b) => a[1].nextAt - b[1].nextAt)[0];
if (!pending) return;
const wait = Math.max(0, pending[1].nextAt - Date.now());
clearTimeout(window.__stoveRetryTimer);
window.__stoveRetryTimer = setTimeout(launchDueRetry, Math.min(wait, 2147483000));
}
function launchDueRetry() {
if (getMain().active) return;
const due = findDueRetry();
if (!due) {
armRetryTimer();
return;
}
const [code] = due;
const shop = DAILY_SHOPS.find(item => item.code === code);
if (!shop) {
clearDailyRetry(code);
return;
}
const retries = getRetries();
retries[code] = {
...retries[code],
inProgress: true,
returnUrl: location.href,
};
setRetries(retries);
location.href = shopUrl(shop);
}
// ===========================================================================
// 4. DOM 헬퍼
// ===========================================================================
function allButtons() {
return [...document.querySelectorAll('button')];
}
/** 텍스트가 (공백 무시하고) 정확히 일치하는, 보이고 활성화된 버튼 */
function findButtonExact(text) {
const expected = squash(text);
return (
allButtons().find(
btn => !btn.disabled && isVisible(btn) && squash(btn.innerText) === expected
) || null
);
}
/** 여러 표기 중 먼저 발견되는 버튼 하나 */
function findButtonAny(texts) {
for (const text of texts) {
const button = findButtonExact(text);
if (button) return button;
}
return null;
}
function tabButton(text) {
const expected = squash(text);
return allButtons().find(btn => squash(btn.innerText) === expected) || null;
}
/**
* 텍스트가 정확히 일치하는 "말단 요소"를 찾는다.
* 기존 구현은 document.querySelectorAll('*') 로 전체 DOM 을 배열화해 매우 느렸다.
* TreeWalker 로 텍스트 노드만 훑어 같은 결과를 훨씬 싸게 얻는다.
*/
function findLeafByText(text, root = document.body) {
if (!root) return null;
const expected = normalize(text);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (normalize(node.nodeValue) !== expected) continue;
const el = node.parentElement;
if (el && el.children.length === 0) return el;
}
return null;
}
function findAllLeavesByText(text, root = document.body) {
if (!root) return [];
const expected = normalize(text);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const result = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (normalize(node.nodeValue) !== expected) continue;
const el = node.parentElement;
if (el && el.children.length === 0) result.push(el);
}
return result;
}
/** 라벨 텍스트에서 위로 올라가며 가장 가까운 버튼을 찾는다. */
function findMissionButton(title) {
for (const node of findAllLeavesByText(title)) {
let current = node;
for (let i = 0; i < 10 && current; i++) {
current = current.parentElement;
if (!current) break;
const button = current.querySelector('button');
if (button) return button;
}
}
return null;
}
function getFlakeCount() {
for (const selector of SEL.flake) {
const el = document.querySelector(selector);
if (!el) continue;
const raw = (el.innerText || '').replace(/[^\d]/g, '');
if (!raw) continue;
const num = parseInt(raw, 10);
if (!Number.isNaN(num)) return num;
}
return null;
}
async function closeVisiblePopup() {
for (const label of ['확인', '닫기', 'Close', 'OK']) {
const button = findButtonExact(label);
if (button) {
button.click();
await delay(500);
return true;
}
}
const ariaButton = allButtons().find(
btn =>
!btn.disabled &&
isVisible(btn) &&
/close|닫기/i.test(btn.getAttribute('aria-label') || '')
);
if (ariaButton) {
ariaButton.click();
await delay(500);
return true;
}
return false;
}
async function clickWithScroll(el, ms = 250) {
if (!el) return false;
try {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} catch (_) {
/* 무시 */
}
await delay(ms);
el.click();
return true;
}
async function waitAndClick(getter, label, timeout = 10000) {
const el = await waitFor(() => {
const found = getter();
return found && !found.disabled ? found : null;
}, timeout, 250);
if (!el) return false;
await clickWithScroll(el);
logStatus(`🖱️ 자동 클릭: ${label}`);
return true;
}
// ===========================================================================
// 5. 상태 패널
// ===========================================================================
let panelDirty = true;
let lastPanelSignature = '';
function markPanelDirty() {
panelDirty = true;
}
const PHASE_LABEL = {
idle: '대기',
draw_start: '뽑기 시작',
draw_running: '캡슐 뽑기',
capsule_rewards: '캡슐 누적 보상',
daily_shop: 'Daily Shop',
missions_start: '플레이크 미션 준비',
missions_running: '플레이크 미션',
go_lounge: '라운지 이동',
lounge_running: '라운지 미션',
return_reward: '리워드 페이지 복귀',
reward_finalize: '최종 보상 수령',
done: '전체 완료',
error: '오류 발생',
};
const PHASE_ORDER = {
draw_start: 0,
draw_running: 0,
capsule_rewards: 1,
daily_shop: 2,
missions_start: 3,
missions_running: 3,
go_lounge: 4,
lounge_running: 4,
return_reward: 5,
reward_finalize: 5,
done: 6,
};
function getStageStatus(stage, phase) {
if (phase === 'error') return '❌';
if (phase === 'done') return '✅';
const current = PHASE_ORDER[phase];
if (current === undefined) return '⬜';
if (current > stage) return '✅';
if (current === stage) return '⏳';
return '⬜';
}
function getRetryDisplay() {
const retries = cleanupOldRetries();
const today = kstDateKey();
const rows = [];
for (const [code, item] of Object.entries(retries)) {
if (!item?.pending || item.date !== today) continue;
const shop = DAILY_SHOPS.find(x => x.code === code);
rows.push(`${shop?.label || code} → ${kstTime(item.nextAt)}`);
}
return rows;
}
/**
* 결과 표기 규칙 (전 항목 공통)
* done ✅ 이번 실행에서 처리함
* already ☑️ 실행 전부터 이미 완료돼 있었음 ← 실패와 구분되어야 하는 값
* locked 🔒 아직 조건 미달 (버튼이 비활성)
* retry 🔁 재시도 예약됨
* none ⏭️ 처리할 대상이 없었음
* pending ⬜ 아직 진행 전
*/
const RESULT_TEXT = {
done: '✅ 완료',
already: '☑️ 이미 완료',
locked: '🔒 조건 미달',
none: '⏭️ 대상 없음',
pending: '⬜ 대기',
};
function resultText(value, fallback = 'pending') {
return RESULT_TEXT[value] || RESULT_TEXT[fallback];
}
function formatTodayItemStatus(value, code) {
const retry = getRetries()[code];
if (value === 'retry' && retry?.nextAt) return `🔁 ${kstTime(retry.nextAt)} 재시도`;
if (value === 'retry') return '🔁 재시도 예정';
return resultText(value);
}
function createStatusPanel() {
if (!document.body) return;
if (document.getElementById('__stove_status_panel')) {
renderStatusPanel();
return;
}
const panel = document.createElement('div');
panel.id = '__stove_status_panel';
panel.style.cssText = [
'position:fixed',
'right:18px',
'bottom:90px',
'z-index:2147483645',
'width:380px',
'background:rgba(17,17,17,.96)',
'color:#fff',
'border-radius:14px',
'padding:15px',
'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Malgun Gothic",sans-serif',
'box-shadow:0 8px 30px rgba(0,0,0,.35)',
'max-height:72vh',
'overflow:auto',
].join(';');
// 이벤트 위임 — innerHTML 을 다시 그려도 핸들러가 살아있다.
panel.addEventListener('click', event => {
const action = event.target?.dataset?.stoveAction;
if (!action) return;
if (action === 'stop') {
stopAutomation('사용자가 중지했습니다.');
} else if (action === 'resume') {
logStatus('🩺 사용자 요청으로 현재 단계를 다시 시도합니다.');
void dispatchPhase({ forced: true });
} else if (action === 'toggle-log') {
panel.dataset.logOpen = panel.dataset.logOpen === '1' ? '0' : '1';
markPanelDirty();
renderStatusPanel();
}
});
document.body.appendChild(panel);
renderStatusPanel();
}
function renderStatusPanel() {
const panel = document.getElementById('__stove_status_panel');
if (!panel) return;
const state = getMain();
const progress = state.progress || {};
const phase = state.phase || 'idle';
const logOpen = panel.dataset.logOpen !== '0';
const recentLogs = getStatusLog().slice(logOpen ? -9 : -3);
// 잔액을 먼저 기록해야 오늘 증감이 최신 값으로 계산된다.
observeFlake();
const currentFlake = getFlakeCount();
const retryRows = getRetryDisplay();
const ledger = getLedger();
const today = getTodaySummary();
const capsule = progress.capsuleRewards || {};
const daily = progress.dailyShop || {};
const lounge = progress.lounge || {};
const daily1 = daily.RIICHICITY_IND || {};
const daily2 = daily.STOVEINDIE || {};
const capsuleValues = Object.values(capsule);
const capsuleDone = capsuleValues.filter(v => v === 'done').length;
const capsuleAlready = capsuleValues.filter(v => v === 'already').length;
const capsuleLocked = capsuleValues.filter(v => v === 'locked').length;
const capsuleSummary = capsuleValues.length
? [
capsuleDone ? `✅ ${capsuleDone}개 수령` : '',
capsuleAlready ? `☑️ ${capsuleAlready}개 이미 완료` : '',
capsuleLocked ? `🔒 ${capsuleLocked}개 조건 미달` : '',
]
.filter(Boolean)
.join(' · ') || '⏭️ 대상 없음'
: '⬜ 대기';
const visitTotal = VISIT_MISSIONS.length;
const visitDone = progress.visitMissionCount || 0;
const visitAlready = progress.visitAlready || 0;
const visitSummary = visitDone
? `${visitDone}/${visitTotal}개 처리` +
(visitAlready ? ` (☑️ ${visitAlready}개는 이미 완료)` : '')
: '⬜ 대기';
const popularTotalCount = progress.popularTotal || 3;
const popularSummary =
progress.popularState === 'already'
? `☑️ ${popularTotalCount}개 모두 이미 완료`
: progress.popularState === 'none'
? '⏭️ 대상 없음'
: progress.popularVisited
? `✅ ${progress.popularVisited}/${popularTotalCount}개 방문`
: '⬜ 대기';
// lounge 값은 done | already | true(구버전) | false
const loungeIcon = value => {
if (value === 'already') return '☑️';
if (value === 'done' || value === true) return '✅';
return '⬜';
};
// 뽑기 손익: 보상 문구 합계(파싱)와 잔액 실측을 함께 보여준다.
const signed = signedNumber;
// 오늘 벌어들인 플레이크 (뽑기 소모를 되더한 값)
const todayEarnedRow =
today.earned == null
? `
🪙 오늘 획득: 측정 중…
`
: `🪙 오늘 획득:
${signed(today.earned)}
${
today.spent
? `
(뽑기 -${today.spent.toLocaleString()} · 순증감 ${signed(today.net)})
`
: ''
}
`;
const drawProfitRow =
state.drawsCompleted
? `🎰 뽑기 정산:
+${(state.drawFlakeGained || 0).toLocaleString()}
/ -${(state.drawFlakeSpent || 0).toLocaleString()}
${
state.drawBalanceChange != null
? `(잔액 ${signed(state.drawBalanceChange)})`
: ''
}
`
: '';
// 값이 실제로 바뀌었을 때만 다시 그린다. (기존: 1초마다 무조건 innerHTML 재구성)
const signature = JSON.stringify([
state.active,
phase,
state.drawCost,
state.drawsCompleted,
state.drawExhausted,
state.drawFlakeGained,
state.drawFlakeSpent,
state.drawBalanceChange,
state.error,
currentFlake,
progress,
retryRows,
recentLogs.length,
recentLogs[recentLogs.length - 1]?.time,
ledger.total,
today.earned,
today.spent,
today.net,
logOpen,
]);
if (!panelDirty && signature === lastPanelSignature) return;
panelDirty = false;
lastPanelSignature = signature;
const stageRows = [
['캡슐 뽑기', 0],
['캡슐 누적 보상', 1],
['Daily Shop', 2],
['플레이크 미션', 3],
['라운지 미션', 4],
['최종 보상', 5],
]
.map(([name, no]) => `${getStageStatus(no, phase)} ${no + 1}. ${name}
`)
.join('');
const logsHtml = recentLogs.length
? recentLogs
.map(
log =>
`` +
`${kstTime(log.time, true)}` +
`${escapeHtml(log.message)}` +
`
`
)
.join('')
: '아직 실행 기록이 없습니다.
';
const statusText = state.active
? '실행 중'
: phase === 'done'
? '완료'
: phase === 'error'
? '오류'
: '대기';
const buttonStyle =
'border:0;border-radius:8px;padding:6px 10px;font-size:11px;font-weight:700;cursor:pointer';
panel.innerHTML = `
STOVE 자동화 현황
v${VERSION} · ${statusText}
📍 현재 단계: ${escapeHtml(PHASE_LABEL[phase] || phase)}
🎯 선택: ${state.drawCost ? `${state.drawCost.toLocaleString()} 뽑기` : '-'}
🔢 뽑기: ${
state.drawExhausted
? `☑️ 오늘 소진 완료${state.drawsCompleted ? ` (이번 실행 ${state.drawsCompleted}회)` : ' (건너뜀)'}`
: `${state.drawsCompleted || 0} / ${CFG.DRAW_COUNT}`
}
📒 오늘 누적 뽑기: ${ledger.total}회
${todayEarnedRow}
${drawProfitRow}
${
currentFlake !== null
? `
💰 현재 플레이크: ${currentFlake.toLocaleString()}
`
: ''
}
${
state.error
? `
❌ ${escapeHtml(state.error)}
`
: ''
}
${stageRows}
결과 요약
🎁 캡슐 누적 보상: ${capsuleSummary}
🛍️ 마작일번가 오늘 아이템: ${formatTodayItemStatus(daily1.todayItem, 'RIICHICITY_IND')}
└ 보상 받기: ${daily1.rewardCount || 0}개
🛍️ 스토브 스토어 오늘 아이템: ${formatTodayItemStatus(daily2.todayItem, 'STOVEINDIE')}
└ 보상 받기: ${daily2.rewardCount || 0}개
🔗 방문 미션: ${visitSummary}
📰 인기 게시글: ${popularSummary}
🗳️ 설문조사: ${
progress.survey === 'done'
? '✅ 참여 완료 (무작위 선택)'
: resultText(progress.survey)
}
✍️ 글쓰기: ${loungeIcon(lounge.post)}
💬 댓글: ${loungeIcon(lounge.comment)}
👍 좋아요: ${loungeIcon(lounge.like)}
${progress.loungeAlready ? '(실행 전 이미 완료)' : ''}
🎁 일반 [받기]: ${
progress.genericRewardCount
? `${progress.genericRewardCount}개`
: PHASE_ORDER[phase] >= 3
? '⏭️ 수령 가능한 항목 없음'
: '⬜ 대기'
}
✅ 이번 실행에서 처리 · ☑️ 이미 완료돼 있었음 · 🔒 조건 미달 · ⏭️ 대상 없음
${
retryRows.length
? `
🔁 오늘의 아이템 재시도
${retryRows.map(x => `
• ${escapeHtml(x)}
`).join('')}
4시간 간격 / 마지막 ${CFG.FINAL_RETRY_TIME} / 자정 이후 폐기
`
: ''
}
`;
panel.style.bottom = isRewardEventPage() ? '90px' : '18px';
}
function toast(message, ms = 7000) {
if (!document.body) return;
let box = document.getElementById('__stove_auto_toast');
if (!box) {
box = document.createElement('div');
box.id = '__stove_auto_toast';
box.style.cssText = [
'position:fixed',
'top:16px',
'right:16px',
'z-index:2147483647',
'background:#111',
'color:#fff',
'padding:14px 18px',
'border-radius:10px',
'font:14px/1.6 -apple-system,BlinkMacSystemFont,"Malgun Gothic",sans-serif',
'max-width:430px',
'white-space:pre-wrap',
'box-shadow:0 6px 24px rgba(0,0,0,.35)',
].join(';');
document.body.appendChild(box);
}
box.textContent = message;
clearTimeout(box._timer);
box._timer = setTimeout(() => box.remove(), ms);
logStatus(message.split('\n')[0]);
}
// ===========================================================================
// 6. 시작 패널
// ===========================================================================
/**
* 플레이크 샵 화면인지 판단.
* 이 사이트는 SPA 라서 '샵/미션' 탭을 누르면 경로가 /ko/event → /ko 로 바뀐다.
* 경로를 /ko/event 로만 좁히면 탭 전환 후 새로고침했을 때 시작 패널이 사라진다.
*/
function isRewardEventPage() {
if (location.hostname !== 'reward.onstove.com') return false;
return /^\/ko(\/event.*)?\/?$/.test(location.pathname);
}
function createDrawPanel() {
if (!isRewardEventPage() || !document.body) return;
if (document.getElementById('__stove_draw_panel')) return;
const panel = document.createElement('div');
panel.id = '__stove_draw_panel';
panel.style.cssText = [
'position:fixed',
'right:18px',
'bottom:18px',
'z-index:2147483646',
'background:#111',
'color:#fff',
'padding:14px',
'border-radius:14px',
'font:14px/1.4 -apple-system,BlinkMacSystemFont,"Malgun Gothic",sans-serif',
'box-shadow:0 6px 22px rgba(0,0,0,.32)',
'display:flex',
'gap:8px',
'align-items:center',
].join(';');
for (const cost of [100, 1000]) {
const button = document.createElement('button');
button.type = 'button';
button.dataset.drawCost = String(cost);
button.dataset.confirm = '0';
button.textContent = `${cost.toLocaleString()} 뽑기`;
button.style.cssText = [
'border:0',
'border-radius:10px',
'padding:10px 14px',
'background:#fff',
'color:#111',
'font-weight:700',
'cursor:pointer',
].join(';');
button.addEventListener('click', () => void onStartClick(button, cost));
panel.appendChild(button);
}
document.body.appendChild(panel);
updateDrawPanel();
}
/**
* 오늘 이미 뽑기 기록이 있으면 한 번 더 확인을 받는다.
* (기존에는 "이미 진행됐는지"를 버튼 활성 상태로만 추정해 중복 실행을 막지 못했다.)
*/
async function onStartClick(button, cost) {
if (getMain().active) return;
const ledger = getLedger();
// 오늘 몫을 이미 소진했다면 어차피 뽑기를 건너뛰므로 확인 절차를 묻지 않는다.
const exhausted = isDailyDrawExhausted();
if (exhausted) {
logStatus('☑️ 오늘 뽑기가 이미 완료된 상태입니다. 나머지 단계만 진행합니다.');
}
if (!exhausted && ledger.total > 0 && button.dataset.confirm !== '1') {
button.dataset.confirm = '1';
button.textContent = `⚠️ 오늘 ${ledger.total}회 기록 · 그래도 진행`;
clearTimeout(button._confirmTimer);
button._confirmTimer = setTimeout(() => {
button.dataset.confirm = '0';
button.textContent = `${cost.toLocaleString()} 뽑기`;
}, 8000);
return;
}
clearTimeout(button._confirmTimer);
button.dataset.confirm = '0';
button.textContent = `${cost.toLocaleString()} 뽑기`;
clearMain();
clearStatusLog();
setMain({
active: true,
phase: 'draw_start',
drawCost: cost,
drawsCompleted: 0,
rewardUrl: location.href,
postTitle: makeRunTitle(),
startedAt: Date.now(),
navCount: 0,
navPhase: '',
progress: {
capsuleRewards: {},
dailyShop: {},
visitMissionCount: 0,
popularVisited: 0,
lounge: { post: false, comment: false, like: false },
genericRewardCount: 0,
},
});
updateDrawPanel();
renderStatusPanel();
await dispatchPhase({ forced: true });
}
function updateDrawPanel() {
const panel = document.getElementById('__stove_draw_panel');
if (!panel) return;
const active = Boolean(getMain().active);
for (const button of panel.querySelectorAll('button[data-draw-cost]')) {
button.disabled = active;
button.style.opacity = active ? '0.55' : '1';
button.style.cursor = active ? 'default' : 'pointer';
}
}
function makeRunTitle() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
return (
`오늘의 한 줄 ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
);
}
// ===========================================================================
// 7. 캡슐 뽑기 — 결과를 "검증"하는 루프
// ===========================================================================
function findInitialDrawButton(cost) {
const expected = normalize(`${cost.toLocaleString()} 뽑기`);
return (
allButtons().find(btn => {
if (btn.disabled) return false;
const span = btn.querySelector(SEL.drawButtonText);
return span && normalize(span.innerText) === expected;
}) || null
);
}
function findRepeatDrawButton(cost) {
const expected = normalize(`${cost.toLocaleString()} 뽑기 한번 더!`);
const span = [...document.querySelectorAll(SEL.repeatButtonText)].find(
node => normalize(node.innerText) === expected
);
if (!span) return null;
const button = span.closest('button');
if (button) return button.disabled ? null : button;
return isVisible(span) ? span : null;
}
/**
* 뽑기 보상 문구에서 플레이크 액수를 뽑아낸다.
* 예) "1,000 플레이크" → 1000 / "○○ 쿠폰" → 0
*/
function parseRewardFlake(text) {
const matched = String(text || '').match(/([\d,]+)\s*플레이크/);
if (!matched) return 0;
const num = parseInt(matched[1].replace(/,/g, ''), 10);
return Number.isNaN(num) ? 0 : num;
}
/** 현재 화면의 "뽑기 결과 지문". 팝업 노드/텍스트/잔액 3가지를 함께 본다. */
function drawSignature() {
const popup = document.querySelector(SEL.rewardPopup);
return {
node: popup || null,
text: popup ? (popup.innerText || '').trim() : '',
flake: getFlakeCount(),
};
}
/**
* 클릭 이후 실제로 1회가 소모되었는지 확인한다.
* - 보상 팝업 노드가 교체되었거나 문구가 바뀌었다 → 성공
* - 플레이크 잔액이 변했다 → 성공
* 둘 중 하나도 없으면 "클릭은 됐지만 뽑히지 않은" 상태로 본다.
*/
async function waitForDrawResult(before, timeout = CFG.DRAW_RESULT_TIMEOUT) {
const end = Date.now() + timeout;
while (Date.now() < end) {
const now = drawSignature();
const popupChanged =
Boolean(now.node) &&
Boolean(now.text) &&
(now.node !== before.node || now.text !== before.text);
const flakeChanged =
now.flake !== null && before.flake !== null && now.flake !== before.flake;
if (popupChanged || flakeChanged) {
return { ok: true, via: popupChanged ? 'popup' : 'flake', ...now };
}
beat();
await delay(150);
}
return { ok: false, via: 'none', ...drawSignature() };
}
function hasInsufficientFlakeMessage() {
const text = document.body?.innerText || '';
return /플레이크가 부족|보유.*부족|잔액이 부족/.test(text);
}
/**
* 오늘 뽑기 기회를 모두 소진했는지 판정한다.
*
* 소진하면 이런 팝업이 뜬다.
* "오늘 30회 뽑기 완료!"
* "내일 또 만나요!"
* [확인] [미션 둘러보기]
*
* 이건 오류가 아니라 정상적인 종료 조건이다.
* 여기서 자동화를 멈추면 Daily Shop·미션·라운지가 통째로 안 돌아간다.
*/
function isDailyDrawExhausted() {
const text = normalize(document.body?.innerText || '');
if (/오늘\s*\d+회\s*뽑기\s*완료/.test(text)) return true;
if (/내일\s*또\s*만나요/.test(text)) return true;
// 이 팝업에만 등장하는 버튼
return Boolean(findButtonExact('미션 둘러보기'));
}
/** 소진 팝업을 닫는다. '미션 둘러보기'는 페이지를 옮기므로 '확인'을 우선한다. */
async function dismissDrawExhaustedPopup() {
const confirm = findButtonAny(['확인', '닫기', 'OK']);
if (confirm) {
confirm.click();
await delay(700);
return true;
}
return closeVisiblePopup();
}
/** 뽑기 단계를 마무리하고 다음 단계로 넘긴다. (성공·스킵·중단 모두 이 경로) */
async function finishDrawPhase({ cost, completed, exhausted, stopReason, gained = 0 }) {
const endFlake = getFlakeCount();
const startFlake = getMain().drawStartFlake;
const spent = cost * completed;
// 잔액 실측 변화 — 보상 문구 파싱과 별개로 신뢰할 수 있는 값
const balanceChange =
startFlake != null && endFlake != null ? endFlake - startFlake : null;
recordRun(cost, completed);
setMain({
phase: 'capsule_rewards',
drawEndFlake: endFlake,
missionStartFlake: endFlake,
drawsCompleted: completed,
drawExhausted: Boolean(exhausted),
drawStopReason: stopReason || '',
drawFlakeGained: gained,
drawFlakeSpent: spent,
drawBalanceChange: balanceChange,
});
if (completed > 0) {
logStatus(
`💰 뽑기 정산 — 보상 +${gained.toLocaleString()} / 소모 -${spent.toLocaleString()}` +
(balanceChange != null
? ` / 잔액 변화 ${balanceChange >= 0 ? '+' : ''}${balanceChange.toLocaleString()}`
: '')
);
}
if (exhausted) {
toast(
completed > 0
? `☑️ 오늘 뽑기 기회를 모두 소진했습니다. (이번 실행 ${completed}회)\n➡️ 다음 단계로 넘어갑니다.`
: '☑️ 오늘 뽑기는 이미 모두 완료된 상태입니다.\n➡️ 뽑기를 건너뛰고 다음 단계로 넘어갑니다.',
9000
);
} else {
toast(
`✅ 뽑기 ${completed}/${CFG.DRAW_COUNT}회 완료\n➡️ 캡슐 누적 보상을 확인합니다.`,
9000
);
}
await delay(1200);
// 팝업이 남아 있으면 이후 단계의 버튼 탐색을 방해하므로 새로고침 후 진행한다.
navigate(getMain().rewardUrl || REWARD_URL, 'capsule_rewards', { reloadIfSame: true });
}
async function runDrawLoop(cost) {
const startFlake = getFlakeCount();
setMain({
phase: 'draw_running',
drawCost: cost,
drawStartFlake: startFlake,
drawsCompleted: 0,
drawExhausted: false,
});
// --- 시작 전 점검: 오늘 몫을 이미 다 돌린 경우 ---
if (isDailyDrawExhausted()) {
logStatus('☑️ 오늘 30회 뽑기를 이미 모두 소진한 상태입니다. 뽑기를 건너뜁니다.');
await dismissDrawExhaustedPopup();
await finishDrawPhase({ cost, completed: 0, exhausted: true });
return;
}
if (startFlake === null) {
logStatus('⚠️ 플레이크 보유량 표시를 찾지 못했습니다. 뽑기 결과 검증 정확도가 떨어질 수 있습니다.');
}
toast(`🎯 ${cost.toLocaleString()} 뽑기 ${CFG.DRAW_COUNT}회를 시작합니다.`, 10000);
let completed = 0;
let exhausted = false;
let stopReason = '';
let gained = 0;
for (let drawNo = 1; drawNo <= CFG.DRAW_COUNT; drawNo++) {
const isFirst = drawNo === 1;
const button = await waitFor(
() =>
isDailyDrawExhausted() ||
(isFirst ? findInitialDrawButton(cost) : findRepeatDrawButton(cost)),
isFirst ? 12000 : 8000,
200
);
if (isDailyDrawExhausted()) {
exhausted = true;
break;
}
if (!button) {
stopReason = isFirst
? `"${cost.toLocaleString()} 뽑기" 버튼을 찾지 못했습니다.`
: `${drawNo - 1}회차 후 "한번 더" 버튼이 나타나지 않았습니다.`;
break;
}
let result = null;
for (let attempt = 0; attempt <= CFG.DRAW_RETRY_PER_ROUND; attempt++) {
const before = drawSignature();
const target = isFirst
? findInitialDrawButton(cost) || button
: findRepeatDrawButton(cost) || button;
if (!target || typeof target.click !== 'function') break;
target.click();
result = await waitForDrawResult(before);
if (result.ok) break;
// 결과가 안 잡히면 먼저 "소진"인지부터 본다. (오류로 오인하면 안 됨)
if (isDailyDrawExhausted()) {
exhausted = true;
break;
}
if (hasInsufficientFlakeMessage()) {
stopReason = '플레이크가 부족해 뽑기를 중단합니다.';
break;
}
// 결과 팝업이 앞을 가려 클릭이 먹지 않는 경우가 있어 한 번 닫고 재시도한다.
logStatus(`⚠️ ${drawNo}회차 결과 확인 실패 — 재시도 ${attempt + 1}/${CFG.DRAW_RETRY_PER_ROUND}`);
await closeVisiblePopup();
await delay(random(900, 1400));
}
if (exhausted || stopReason) break;
if (!result?.ok) {
stopReason = `${drawNo}회차 뽑기 결과를 확인하지 못했습니다. 중복 소모를 막기 위해 중단합니다.`;
break;
}
completed = drawNo;
const reward = parseRewardFlake(result.text);
gained += reward;
recordDraw(cost);
setMain({
drawsCompleted: completed,
drawFlakeGained: gained,
drawFlakeSpent: cost * completed,
});
logStatus(
result.text
? `🎁 ${drawNo}/${CFG.DRAW_COUNT}회차 보상: ${result.text}` +
(reward ? ` (+${reward.toLocaleString()})` : '') +
` (확인:${result.via})`
: `🎁 ${drawNo}/${CFG.DRAW_COUNT}회차 완료 (확인:${result.via})`
);
if (drawNo < CFG.DRAW_COUNT) {
await delay(random(2400, 3000));
// 마지막 회차 직후에도 소진 팝업이 뜬다.
if (isDailyDrawExhausted()) {
exhausted = true;
break;
}
}
}
if (exhausted) {
logStatus(
completed > 0
? `☑️ 오늘 뽑기 기회를 모두 소진했습니다. (이번 실행 ${completed}회) 다음 단계로 넘어갑니다.`
: '☑️ 오늘 뽑기는 이미 완료된 상태였습니다. 다음 단계로 넘어갑니다.'
);
await dismissDrawExhaustedPopup();
} else if (stopReason) {
logStatus(`⚠️ ${stopReason}`);
}
await finishDrawPhase({ cost, completed, exhausted, stopReason, gained });
}
// ===========================================================================
// 8. 캡슐 누적 보상
// ===========================================================================
/** 누적 보상 칸에 들어가는 버튼인지 (수령 전/후 문구를 모두 포함) */
function isMilestoneButton(button) {
return /플레이크받기(완료)?$/.test(squash(button.innerText));
}
/**
* 화면의 누적 보상 카드 목록.
*
* 카드는 '30번x5 이상 뽑기 시' 같은 조건을 갖는데, 이 문구가 여러 엘리먼트로
* 쪼개져 있어 텍스트 완전일치로는 못 찾는다.
* 그래서 버튼에서 위로 올라가다 조건(x숫자)이 처음 등장하는 조상을 카드로 보고,
* 그 배수로 어느 칸인지 식별한다.
*/
function milestoneCards() {
return allButtons()
.filter(isMilestoneButton)
.map((button, index) => {
let node = button;
let text = '';
for (let i = 0; i < 5 && node; i++) {
node = node.parentElement;
if (!node) break;
text = normalize(node.innerText);
if (/x\s*\d+/i.test(text)) break;
}
const matched = text.match(/x\s*(\d+)/i);
return { button, index, multiplier: matched ? Number(matched[1]) : null };
});
}
/**
* 1순위 — 달성 배수(x5 / x10 / x20)로 카드를 특정
* 2순위 — 화면에 나타난 순서로 특정
*/
function findMilestoneButton(item, index) {
const cards = milestoneCards();
const byMultiplier = cards.find(card => card.multiplier === item.multiplier);
if (byMultiplier) return byMultiplier.button;
return cards[index]?.button || null;
}
/**
* 누적 보상 한 칸의 상태.
* available — 지금 누를 수 있음
* already — 이미 수령함
* locked — 아직 누적 횟수 미달
* none — 화면에서 찾지 못함
*/
function milestoneState(item, index) {
const button = findMilestoneButton(item, index);
if (!button) return { state: 'none', button: null };
if (/완료$/.test(squash(button.innerText))) return { state: 'already', button };
if (button.disabled || !isVisible(button)) return { state: 'locked', button };
return { state: 'available', button };
}
function setCapsuleResult(label, value) {
patchProgress({
capsuleRewards: {
...(getMain().progress?.capsuleRewards || {}),
[label]: value,
},
});
}
async function claimCapsuleMilestoneRewards() {
let count = 0;
for (let index = 0; index < CAPSULE_REWARDS.length; index++) {
const item = CAPSULE_REWARDS[index];
const { state, button } = milestoneState(item, index);
if (state !== 'available') {
setCapsuleResult(item.label, state);
logStatus(
state === 'already'
? `☑️ 캡슐 누적 보상 이미 수령됨: ${item.label}`
: state === 'locked'
? `🔒 캡슐 누적 보상 조건 미달: ${item.label}`
: `⏭️ 캡슐 누적 보상 항목 없음: ${item.label}`
);
continue;
}
await clickWithScroll(button);
count++;
setCapsuleResult(item.label, 'done');
logStatus(`🎁 캡슐 누적 보상 수령: ${item.label}`);
await delay(1200);
await closeVisiblePopup();
}
return count;
}
// ===========================================================================
// 9. Daily Shop
// ===========================================================================
function currentDailyShop() {
return (
DAILY_SHOPS.find(shop => location.href.includes(`/dailyshop/${shop.code}`)) || null
);
}
function hasNoPlayRecordMessage() {
const text = document.body?.innerText || '';
return text.includes('당일 플레이 기록이 있는 회원만') && text.includes('게임 플레이 기록');
}
function updateDailyProgress(shopCode, patch) {
const daily = getMain().progress?.dailyShop || {};
patchProgress({
dailyShop: {
...daily,
[shopCode]: { ...(daily[shopCode] || {}), ...patch },
},
});
}
/**
* 수령 버튼이 없을 때 "이미 받은 것"인지 "아직 안 열린 것"인지 구분한다.
* 사이트는 수령을 마치면 버튼 문구가 '오늘의 아이템 확인' 으로 바뀐다.
*/
function todayItemAlreadyDone() {
if (findButtonAny(['오늘의 아이템 확인', '오늘의 아이템 받기 완료'])) return true;
return /오늘의 아이템\s*(받기\s*)?완료/.test(document.body?.innerText || '');
}
async function claimTodayItem(shop, isRetry = false) {
const button = await waitFor(() => findButtonExact('오늘의 아이템 받기'), 8000, 250);
if (!button) {
const already = todayItemAlreadyDone();
logStatus(
already
? `☑️ ${shop.label}: 오늘의 아이템은 이미 수령된 상태입니다.`
: `⏭️ ${shop.label}: 오늘의 아이템 버튼을 찾지 못했습니다.`
);
clearDailyRetry(shop.code);
updateDailyProgress(shop.code, { todayItem: already ? 'already' : 'none' });
return { status: already ? 'already' : 'none' };
}
await clickWithScroll(button);
logStatus(`🎁 ${shop.label}: 오늘의 아이템 받기 클릭${isRetry ? ' (재시도)' : ''}`);
await delay(1300);
if (hasNoPlayRecordMessage()) {
logStatus(`⏳ ${shop.label}: 당일 플레이 기록이 없어 수령 실패.`);
await closeVisiblePopup();
const nextAt = scheduleDailyRetry(shop.code);
updateDailyProgress(shop.code, { todayItem: 'retry' });
if (nextAt) {
toast(
`⏳ ${shop.label}: 플레이 기록 미반영\n오늘 ${kstTime(nextAt)}에 다시 시도합니다.`,
9000
);
}
return { status: 'retry' };
}
clearDailyRetry(shop.code);
updateDailyProgress(shop.code, { todayItem: 'done' });
logStatus(`✅ ${shop.label}: 오늘의 아이템 수령 처리 완료`);
await closeVisiblePopup();
return { status: 'done' };
}
async function collectDailyShopRewards(shop, maxClicks = 30) {
let count = 0;
for (let i = 0; i < maxClicks; i++) {
const button = findButtonAny(['보상받기', '보상 받기', '받기']);
if (!button) break;
await clickWithScroll(button);
count++;
logStatus(`🎁 ${shop.label}: [보상 받기] 자동 수령 (${count})`);
updateDailyProgress(shop.code, { rewardCount: count });
await delay(1300);
await closeVisiblePopup();
}
updateDailyProgress(shop.code, { rewardCount: count });
return count;
}
/** Daily Shop 페이지가 실제로 열렸는지 (빈 페이지/만료 URL 감지) */
async function dailyShopLoaded() {
return waitFor(
() =>
findButtonAny(['오늘의 아이템 받기', '보상받기', '보상 받기']) ||
(document.body && document.body.innerText.includes('오늘의 아이템')),
12000,
300
);
}
async function runMainDailyShop(shop) {
toast(`🛍️ Daily Shop 처리 중\n${shop.label}`, 8000);
const loaded = await dailyShopLoaded();
if (!loaded) {
logStatus(`⚠️ ${shop.label}: 페이지 내용을 확인하지 못해 건너뜁니다.`);
} else {
await claimTodayItem(shop, false);
await collectDailyShopRewards(shop);
}
const index = DAILY_SHOPS.findIndex(item => item.code === shop.code);
if (index >= 0 && index < DAILY_SHOPS.length - 1) {
const next = DAILY_SHOPS[index + 1];
setMain({ phase: 'daily_shop', dailyIndex: index + 1 });
navigate(shopUrl(next), 'daily_shop');
return;
}
setMain({ phase: 'missions_start', dailyIndex: DAILY_SHOPS.length });
navigate(getMain().rewardUrl || REWARD_URL, 'missions_start');
}
async function runDailyRetry(shop) {
const item = cleanupOldRetries()[shop.code];
if (!item || !item.inProgress || item.date !== kstDateKey()) return;
toast(`🔁 ${shop.label}\n오늘의 아이템 받기만 재시도합니다.`, 8000);
await dailyShopLoaded();
const result = await claimTodayItem(shop, true);
const updated = getRetries();
if (updated[shop.code]) {
updated[shop.code].inProgress = false;
setRetries(updated);
}
if (['done', 'already', 'none'].includes(result.status)) {
toast(`✅ ${shop.label}: 오늘의 아이템 재시도 종료`, 7000);
}
if (item.returnUrl && item.returnUrl !== location.href) {
await delay(900);
location.href = item.returnUrl;
}
}
// ===========================================================================
// 10. 리워드 미션
// ===========================================================================
async function refreshMissionList() {
const shop = tabButton('샵');
if (shop) {
shop.click();
await delay(1500);
}
const mission = await waitFor(() => tabButton('미션'), 6000, 200);
if (mission) {
mission.click();
await delay(2500);
}
}
/**
* 미션 버튼을 i18n 사전 기준으로 분류한다.
* 반환: receive | receive_complete | go_to_mission | join_survey | see_achievement | other
*/
function classifyMissionButton(button) {
const text = squash(button.innerText);
if (!text) return 'other';
// '받기 완료', '100 플레이크 받기 완료' 등 — 끝이 완료면 무조건 수령 끝
if (/완료$/.test(text)) return 'receive_complete';
if (MISSION_BUTTON.joinSurveyRe.test(text)) return 'join_survey';
if (text === squash(MISSION_BUTTON.receive)) return 'receive';
if (MISSION_BUTTON.receiveRewardRe.test(text)) return 'receive';
if (text === squash(MISSION_BUTTON.goToMission)) return 'go_to_mission';
if (text === squash(MISSION_BUTTON.seeAchievement)) return 'see_achievement';
return 'other';
}
/** 지금 누를 수 있는 수령 버튼 하나 (데일리·위클리·설문·인기글 모두 포함) */
function findClaimableRewardButton(root = document) {
return (
[...root.querySelectorAll('button')].find(
btn =>
!btn.disabled && isVisible(btn) && classifyMissionButton(btn) === 'receive'
) || null
);
}
async function collectAllAvailableRewards(maxClicks = 60) {
let total = 0;
for (let i = 0; i < maxClicks; i++) {
const button = findClaimableRewardButton();
if (!button) break;
await clickWithScroll(button);
total++;
const currentCount = (getMain().progress?.genericRewardCount || 0) + 1;
patchProgress({ genericRewardCount: currentCount });
logStatus(`🎁 활성 [받기] 자동 수령 (${currentCount})`);
await delay(1500);
await closeVisiblePopup();
}
return total;
}
function popularSection() {
const heading = findLeafByText('인기 게시글 보고 플레이크 받기!');
if (!heading) return null;
let section = heading;
for (let i = 0; i < 10 && section; i++) {
section = section.parentElement;
if (section && section.querySelector(SEL.popularAnchor)) return section;
}
return null;
}
/**
* 개별 게시글 카드가 이미 수령 완료인지 판단한다.
*
* 주의: 부모를 끝까지 거슬러 올라가면 섹션 전체 텍스트에 다른 카드의
* "받기 완료" 가 섞여 들어와 멀쩡한 카드까지 완료로 오판한다.
* 그래서 섹션에 도달하면 탐색을 멈춘다.
*/
function isPopularComplete(anchor, section) {
let card = anchor;
for (let i = 0; i < 8 && card; i++) {
if (section && card === section) break;
const badge = card.querySelector?.('[class*="flakeshop-mission-badge"]');
if (badge && /receive_complete/.test(badge.className)) return true;
// 카드 단위(자식 수가 적은 구간)에서만 텍스트 판정을 신뢰한다.
if (i <= 3 && /받기 완료|플레이크 받기 완료/.test(card.innerText || '')) return true;
card = card.parentElement;
}
return false;
}
function pendingPopularPosts() {
const section = popularSection();
if (!section) return [];
const unique = new Map();
for (const anchor of section.querySelectorAll(SEL.popularAnchor)) {
if (!anchor.href || unique.has(anchor.href) || isPopularComplete(anchor, section)) continue;
unique.set(anchor.href, anchor);
}
return [...unique.values()].slice(0, 3);
}
async function collectPopular() {
const section = popularSection();
if (!section) return 0;
let count = 0;
// 공백 표기가 페이지마다 달라 squash 로 비교한다.
// '...받기 완료' 처럼 이미 수령한 버튼은 제외해야 하므로 끝이 '받기' 인 것만 고른다.
const buttons = [...section.querySelectorAll('button')].filter(btn => {
if (btn.disabled || !isVisible(btn)) return false;
const text = squash(btn.innerText);
return text === '받기' || /플레이크받기$/.test(text);
});
for (const button of buttons) {
if (!document.contains(button) || button.disabled) continue;
button.click();
count++;
logStatus(`🎁 인기 게시글 보상 수령 (${count})`);
await delay(1500);
}
return count;
}
/** 섹션에 걸린 인기 게시글 전체 개수 (분모 표시용) */
function popularTotal() {
const section = popularSection();
if (!section) return 0;
return new Set(
[...section.querySelectorAll(SEL.popularAnchor)].map(a => a.href).filter(Boolean)
).size;
}
async function runPopular() {
const posts = pendingPopularPosts();
const total = popularTotal();
if (!posts.length) {
// 섹션은 있는데 남은 글이 없다 = 이미 전부 처리된 상태
const already = total > 0;
patchProgress({
popularTotal: total,
popularVisited: already ? total : 0,
popularState: already ? 'already' : 'none',
});
logStatus(
already
? `☑️ 인기 게시글 미션: ${total}개 모두 이미 완료된 상태입니다.`
: '⏭️ 인기 게시글 미션: 대상 게시글을 찾지 못했습니다.'
);
return 0;
}
logStatus(`📰 인기 게시글 ${posts.length}/3개 자동 방문`);
const hrefs = posts.map(post => post.href);
for (let i = 0; i < hrefs.length; i++) {
let opened = null;
try {
opened = GM_openInTab(hrefs[i], { active: false, insert: true, setParent: true });
} catch (error) {
logStatus(`⚠️ 새 탭 열기 실패: ${error?.message || error}`);
continue;
}
logStatus(`🔗 ${i + 1}/${hrefs.length} 방문 중`);
await delay(CFG.VISIT_WAIT);
try {
opened?.close();
} catch (_) {
/* 이미 닫힌 경우 무시 */
}
await delay(400);
}
patchProgress({
popularVisited: hrefs.length,
popularTotal: total || hrefs.length,
popularState: 'done',
});
await refreshMissionList();
return collectPopular();
}
// =========================================================================
// 설문조사 참여하고 플레이크 받기
// =========================================================================
function surveySection() {
const heading = findLeafByText(SURVEY_HEADING);
if (!heading) return null;
let section = heading;
for (let i = 0; i < 8 && section; i++) {
section = section.parentElement;
if (section && section.querySelectorAll('button').length >= 2) return section;
}
return null;
}
/**
* 이미 투표했는지 판정.
* 투표를 마치면 선택지 버튼에 득표율(41.41%)이 붙고, 수령/완료 버튼이 생긴다.
*/
function surveyAlreadyVoted(section) {
const buttons = [...section.querySelectorAll('button')];
if (buttons.some(btn => MISSION_BUTTON.percentRe.test(squash(btn.innerText)))) return true;
return buttons.some(btn => classifyMissionButton(btn) === 'receive_complete');
}
/** 선택지 버튼들 (수령/완료/설문참여 버튼은 제외) */
function surveyChoices(section) {
return [...section.querySelectorAll('button')].filter(btn => {
if (btn.disabled || !isVisible(btn)) return false;
if (classifyMissionButton(btn) !== 'other') return false;
return squash(btn.innerText).length > 0;
});
}
/**
* 설문조사 자동 참여.
* 선택지는 무작위로 고른다 (항상 1번만 찍어 집계를 왜곡하지 않기 위함).
* 수령 버튼은 이후 collectAllAvailableRewards 가 일괄 처리한다.
*/
async function runSurvey() {
const section = surveySection();
if (!section) {
patchProgress({ survey: 'none' });
logStatus('⏭️ 설문조사 섹션이 없습니다.');
return 'none';
}
if (surveyAlreadyVoted(section)) {
patchProgress({ survey: 'already' });
logStatus('☑️ 설문조사는 이미 참여한 상태입니다.');
return 'already';
}
// '설문 참여하고 N 플레이크 받기' 가 먼저 뜨는 경우 선택지를 펼친다.
const opener = [...section.querySelectorAll('button')].find(
btn => classifyMissionButton(btn) === 'join_survey' && !btn.disabled
);
if (opener) {
await clickWithScroll(opener);
await delay(1500);
}
const choices = surveyChoices(surveySection() || section);
if (!choices.length) {
patchProgress({ survey: 'none' });
logStatus('⏭️ 설문조사 선택지를 찾지 못했습니다.');
return 'none';
}
const picked = choices[random(0, choices.length - 1)];
const pickedText = normalize(picked.innerText).slice(0, 40);
await clickWithScroll(picked);
logStatus(`🗳️ 설문조사 참여 (무작위 선택): ${pickedText}`);
await delay(1800);
await closeVisiblePopup();
patchProgress({ survey: 'done' });
return 'done';
}
/** 미션 카드 버튼 문구 → 상태 (i18n 사전 기준) */
function missionStateOf(title) {
const button = findMissionButton(title);
if (!button) return { state: 'none', label: '' };
const label = squash(button.innerText);
const kind = classifyMissionButton(button);
if (kind === 'go_to_mission') return { state: 'todo', label, button };
if (kind === 'receive') return { state: 'claimable', label, button };
if (kind === 'receive_complete') return { state: 'already', label, button };
return { state: 'unknown', label, button };
}
function loungeMissionLeft() {
return LOUNGE_MISSIONS.filter(title =>
['todo', 'claimable'].includes(missionStateOf(title).state)
);
}
async function runRewardMissions() {
setMain({ phase: 'missions_running' });
toast('▶️ Daily Shop 완료\n플레이크 미션을 자동 진행합니다.', 9000);
await refreshMissionList();
// 실행 시작 시점의 라운지 미션 상태를 먼저 찍어둔다.
// (이걸 안 해두면 "원래 완료였던 것"과 "내가 방금 한 것"을 구분할 수 없다.)
const loungeBefore = {};
for (const title of LOUNGE_MISSIONS) {
loungeBefore[title] = missionStateOf(title).state;
}
const loungeAlreadyDone = LOUNGE_MISSIONS.every(t => loungeBefore[t] === 'already');
patchProgress({ loungeAlready: loungeAlreadyDone });
let visitCount = 0;
let visitAlready = 0;
for (const title of VISIT_MISSIONS) {
const { state, label } = missionStateOf(title);
if (state === 'todo') {
missionStateOf(title).button?.click();
visitCount++;
patchProgress({ visitMissionCount: visitCount, visitAlready });
logStatus(`🔗 방문 미션: ${title}`);
await delay(CFG.VISIT_WAIT);
} else if (state === 'claimable' || state === 'already') {
visitCount++;
if (state === 'already') visitAlready++;
patchProgress({ visitMissionCount: visitCount, visitAlready });
logStatus(`☑️ 방문 미션 이미 진행됨: ${title} [${label}]`);
}
}
patchProgress({ visitMissionCount: visitCount, visitAlready });
await refreshMissionList();
await collectAllAvailableRewards();
// runPopular() 내부에서 이미 collectPopular() 를 호출한다. (중복 호출 제거)
await runPopular();
// 설문 참여 → 수령 버튼이 활성화되면 아래 일괄 수령에서 처리된다.
await runSurvey();
await refreshMissionList();
await collectAllAvailableRewards();
if (!loungeMissionLeft().length) {
// 실행 전부터 완료였는지, 이번에 채워진 건지를 구분해 기록한다.
const value = loungeAlreadyDone ? 'already' : 'done';
patchLounge({ post: value, comment: value, like: value });
logStatus(
loungeAlreadyDone
? '☑️ 라운지 미션 3종은 이미 완료된 상태라 건너뜁니다.'
: '✅ 라운지 미션이 이미 충족되어 바로 마무리합니다.'
);
await finalizeReward();
return;
}
setMain({ phase: 'go_lounge' });
toast('➡️ 라운지로 이동합니다.\n글쓰기 → 댓글 → 좋아요를 자동 처리합니다.', 9000);
await delay(1000);
navigate(LOUNGE_URL, 'go_lounge');
}
// ===========================================================================
// 11. 라운지
// ===========================================================================
function setTextarea(el, text) {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
)?.set;
el.click();
el.focus();
if (setter) {
setter.call(el, text);
} else {
el.value = text;
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function setEditor(el, text) {
el.innerHTML = '
';
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
document.execCommand('insertText', false, text);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
async function ensureLoungeEditor() {
const existing = document.querySelector(SEL.loungeTitle);
if (existing) return existing;
const writeButton = allButtons().find(btn => btn.innerText.trim().includes('글쓰기'));
if (writeButton) {
writeButton.click();
await delay(1200);
}
return waitFor(() => document.querySelector(SEL.loungeTitle), 8000, 200);
}
function findCreatedPostNow(title) {
const titleEl = [...document.querySelectorAll(SEL.feedTitle)].find(
el => el.innerText.trim() === title
);
if (!titleEl) return null;
return (
titleEl.closest('.sc-feed') ||
titleEl.closest('.sc-feed-detail')?.parentElement ||
null
);
}
async function fillPost(titleText) {
const title = await ensureLoungeEditor();
if (!title) throw new Error('라운지 글쓰기 제목 입력창을 찾지 못했습니다.');
setTextarea(title, titleText);
const body = await waitFor(() => document.querySelector(SEL.loungeBody), 10000, 200);
if (!body) throw new Error('라운지 본문 입력창을 찾지 못했습니다.');
const submit = () => document.querySelector(SEL.loungeSubmit);
for (let i = 0; i < 3; i++) {
setEditor(body, BODY_TEXT);
await delay(500);
const button = submit();
if (button && !button.disabled) break;
}
if (!(await waitAndClick(submit, '글 [등록]'))) {
throw new Error('글 [등록] 자동 처리에 실패했습니다.');
}
patchLounge({ post: 'done' });
logStatus('✍️ 라운지 글쓰기 완료');
await delay(2500);
}
async function fillCommentAndLike(titleText) {
const post = await waitFor(() => findCreatedPostNow(titleText), 15000, 400);
if (!post) throw new Error(`방금 작성한 글("${titleText}")을 찾지 못했습니다.`);
post.scrollIntoView({ behavior: 'smooth', block: 'center' });
await delay(500);
const opener = post.querySelector(SEL.commentOpener);
if (opener) {
opener.click();
await delay(1000);
}
const editor = await waitFor(
() => {
for (const box of post.querySelectorAll(SEL.commentBox)) {
const ed = box.querySelector('.fr-element.fr-view');
const sub = box.querySelector(SEL.commentSubmit);
if (ed && sub) return { ed, sub };
}
return null;
},
10000,
250
);
if (!editor) throw new Error('댓글 입력창을 찾지 못했습니다.');
setEditor(editor.ed, COMMENT_TEXT);
await delay(500);
const commentOK = await waitAndClick(
() =>
document.contains(editor.sub) ? editor.sub : post.querySelector(SEL.commentSubmit),
'댓글 [등록]'
);
if (!commentOK) throw new Error('댓글 [등록] 자동 처리에 실패했습니다.');
patchLounge({ comment: 'done' });
logStatus(`💬 댓글 작성 완료: ${COMMENT_TEXT}`);
await delay(1000);
const like = post.querySelector(SEL.likeButton);
if (!like) throw new Error('좋아요 버튼을 찾지 못했습니다.');
const alreadyLiked =
like.classList.contains('active') ||
like.getAttribute('aria-pressed') === 'true' ||
/좋아요 취소|좋아요 해제/.test(like.innerText.trim());
if (!alreadyLiked) {
const likeOK = await waitAndClick(
() => post.querySelector(SEL.likeButton),
'[좋아요]'
);
if (!likeOK) throw new Error('[좋아요] 자동 처리에 실패했습니다.');
}
patchLounge({ like: alreadyLiked ? 'already' : 'done' });
logStatus('👍 좋아요 처리 완료');
}
async function runLoungePhase() {
setMain({ phase: 'lounge_running' });
toast('✍️ 라운지 글쓰기 → 댓글 → 좋아요 자동 처리 중...', 11000);
const state = getMain();
const titleText = state.postTitle || makeRunTitle();
if (!state.postTitle) setMain({ postTitle: titleText });
let post = findCreatedPostNow(titleText);
if (!post) {
await fillPost(titleText);
post = await waitFor(() => findCreatedPostNow(titleText), 15000, 400);
}
if (!post) throw new Error('작성한 게시글을 확인하지 못했습니다.');
await fillCommentAndLike(titleText);
setMain({ phase: 'return_reward' });
toast('✅ 라운지 미션 완료\n리워드 페이지로 자동 복귀합니다.', 7000);
await delay(1200);
navigate(getMain().rewardUrl || REWARD_URL, 'return_reward');
}
// ===========================================================================
// 12. 최종 보상
// ===========================================================================
async function finalizeReward() {
setMain({ phase: 'reward_finalize' });
toast('🔄 완료된 미션의 활성 [받기]를 모두 수령합니다.', 10000);
for (let attempt = 1; attempt <= 4; attempt++) {
await refreshMissionList();
await collectPopular();
await collectAllAvailableRewards();
if (!loungeMissionLeft().length) break;
logStatus(`⏳ 라운지 미션 반영 대기 ${attempt}/4`);
await delay(3500);
}
await refreshMissionList();
await collectAllAvailableRewards();
const finalFlake = getFlakeCount();
const current = getMain();
const overallChange =
current.drawStartFlake != null && finalFlake != null
? finalFlake - current.drawStartFlake
: null;
setMain({
active: false,
phase: 'done',
finalFlake,
overallChange,
finishedAt: Date.now(),
});
// 완료 요약은 토스트로 띄우지 않는다.
// 같은 내용이 상태 패널에 이미 다 있어서 화면만 가렸다.
logStatus('✅ STOVE 전체 자동화 완료');
if (current.drawStopReason) logStatus(`⚠️ ${current.drawStopReason}`);
if (finalFlake != null) logStatus(`💰 최종 플레이크: ${finalFlake.toLocaleString()}`);
if (overallChange != null) logStatus(`📊 시작 대비 변화: ${signedNumber(overallChange)}`);
const today = getTodaySummary();
if (today.earned != null) {
logStatus(`🪙 오늘 획득: ${signedNumber(today.earned)} (뽑기 소모 -${today.spent.toLocaleString()})`);
}
updateDrawPanel();
armRetryTimer();
}
// ===========================================================================
// 13. 오류 / 중지
// ===========================================================================
function handleFatal(error) {
console.error('[stove] ❌ 자동화 오류:', error);
const message = String(error?.message || error);
setMain({ active: false, phase: 'error', error: message });
logStatus(`❌ ${message}`);
toast(`❌ 자동화가 중단되었습니다.\n${message}`, 20000);
updateDrawPanel();
armRetryTimer();
}
function stopAutomation(reason) {
running = false;
setMain({ active: false, phase: 'idle', error: '', stoppedAt: Date.now() });
logStatus(`🛑 ${reason}`);
updateDrawPanel();
renderStatusPanel();
}
// ===========================================================================
// 14. 페이지 이동 / phase 라우터
// ===========================================================================
const HOST = {
reward: 'reward.onstove.com',
dailyshop: 'event.onstove.com',
lounge: 'lounge.onstove.com',
};
/**
* phase 별 담당 페이지와 실행 함수.
* 담당 호스트가 아니면 알아서 해당 페이지로 이동한다.
* → "리워드 페이지인데 phase 가 go_lounge" 같은 조합에서 조용히 멈추던 문제 해결.
*/
const PHASE_PLAN = {
draw_start: {
host: HOST.reward,
url: () => getMain().rewardUrl || REWARD_URL,
run: () => runDrawLoop(getMain().drawCost || 100),
},
draw_running: {
host: HOST.reward,
url: () => getMain().rewardUrl || REWARD_URL,
run: resumeInterruptedDraw,
},
capsule_rewards: {
host: HOST.reward,
url: () => getMain().rewardUrl || REWARD_URL,
run: async () => {
await delay(1500);
await claimCapsuleMilestoneRewards();
setMain({ phase: 'daily_shop', dailyIndex: 0 });
navigate(shopUrl(DAILY_SHOPS[0]), 'daily_shop');
},
},
daily_shop: {
host: HOST.dailyshop,
url: () => shopUrl(DAILY_SHOPS[Math.min(getMain().dailyIndex || 0, DAILY_SHOPS.length - 1)]),
run: async () => {
const shop = currentDailyShop();
if (!shop) {
logStatus('⚠️ Daily Shop 페이지를 식별하지 못해 미션 단계로 넘어갑니다.');
setMain({ phase: 'missions_start' });
navigate(getMain().rewardUrl || REWARD_URL, 'missions_start');
return;
}
await runMainDailyShop(shop);
},
},
missions_start: {
host: HOST.reward,
url: () => getMain().rewardUrl || REWARD_URL,
run: async () => {
await delay(1500);
await runRewardMissions();
},
},
missions_running: {
host: HOST.reward,
url: () => getMain().rewardUrl || REWARD_URL,
run: async () => {
await delay(1500);
await runRewardMissions();
},
},
go_lounge: {
host: HOST.lounge,
url: () => LOUNGE_URL,
run: runLoungePhase,
},
lounge_running: {
host: HOST.lounge,
url: () => LOUNGE_URL,
run: runLoungePhase,
},
return_reward: {
host: HOST.reward,
url: () => getMain().rewardUrl || REWARD_URL,
run: async () => {
await delay(1700);
await finalizeReward();
},
},
reward_finalize: {
host: HOST.reward,
url: () => getMain().rewardUrl || REWARD_URL,
run: async () => {
await delay(1700);
await finalizeReward();
},
},
};
/** 뽑기 도중 페이지가 새로고침된 경우의 처리 */
async function resumeInterruptedDraw() {
const state = getMain();
const done = state.drawsCompleted || 0;
if (CFG.RESUME_DRAW_AFTER_RELOAD && done < CFG.DRAW_COUNT) {
logStatus(`🔁 뽑기가 끊겨 ${done}회 이후부터 재개합니다.`);
await runDrawLoop(state.drawCost || 100);
return;
}
logStatus(
`⚠️ 뽑기 도중 페이지가 새로고침되었습니다. (${done}/${CFG.DRAW_COUNT}회 기록) ` +
'중복 차감을 막기 위해 남은 뽑기는 건너뛰고 다음 단계로 진행합니다.'
);
setMain({ phase: 'capsule_rewards', drawStopReason: '페이지 새로고침으로 뽑기 중단' });
await dispatchPhase({ forced: true });
}
/**
* @param {string} url 이동할 주소
* @param {string} phaseHint 이동 후 수행할 단계 (무한 이동 감지에 사용)
* @param {{reloadIfSame?: boolean}} [options]
* 이미 같은 주소에 있을 때 새로고침할지 여부.
* DOM 이 낡아 있으면 다음 단계가 오작동하므로 단계 전환 직후에는 true 를 쓴다.
*/
function navigate(url, phaseHint, { reloadIfSame = false } = {}) {
const state = getMain();
const samePhase = state.navPhase === phaseHint;
const navCount = samePhase ? (state.navCount || 0) + 1 : 1;
if (navCount > CFG.MAX_NAV_PER_PHASE) {
handleFatal(
new Error(`'${PHASE_LABEL[phaseHint] || phaseHint}' 단계에서 페이지 이동이 반복되어 중단했습니다.`)
);
return;
}
setMain({ navPhase: phaseHint, navCount, navAt: Date.now() });
if (location.href === url) {
if (reloadIfSame) {
location.reload();
return;
}
// 이미 목적지에 있으면 이동 대신 바로 실행한다.
void dispatchPhase({ forced: true });
return;
}
location.href = url;
}
let running = false;
async function dispatchPhase({ forced = false } = {}) {
const state = getMain();
if (!state.active) return;
if (running && !forced) return;
const plan = PHASE_PLAN[state.phase];
if (!plan) {
logStatus(`⚠️ 알 수 없는 단계(${state.phase}) — 자동화를 정리합니다.`);
stopAutomation('알 수 없는 단계라 중단했습니다.');
return;
}
// 담당 페이지가 아니면 이동시킨다.
if (location.hostname !== plan.host) {
logStatus(`➡️ '${PHASE_LABEL[state.phase]}' 단계 페이지로 이동합니다.`);
navigate(plan.url(), state.phase);
return;
}
running = true;
beat();
try {
await withTimeout(
Promise.resolve(plan.run()),
CFG.STEP_TIMEOUT_MS,
PHASE_LABEL[state.phase] || state.phase
);
} catch (error) {
handleFatal(error);
} finally {
running = false;
}
}
// ===========================================================================
// 15. 워치독
// ===========================================================================
function startWatchdog() {
clearInterval(window.__stovePanelTimer);
clearInterval(window.__stoveWatchdog);
// 패널은 값이 바뀐 경우에만 다시 그린다 (renderStatusPanel 내부에서 판단).
window.__stovePanelTimer = setInterval(renderStatusPanel, 1000);
window.__stoveWatchdog = setInterval(() => {
const state = getMain();
if (!state.active) return;
if (running) return;
const idleFor = Date.now() - (state.updatedAt || 0);
if (idleFor < CFG.STALL_TIMEOUT_MS) return;
const plan = PHASE_PLAN[state.phase];
if (!plan) {
stopAutomation(`알 수 없는 단계(${state.phase})에서 멈춰 중단했습니다.`);
return;
}
logStatus(
`🩺 ${Math.round(idleFor / 1000)}초간 진행이 없어 '${PHASE_LABEL[state.phase]}' 단계를 다시 시도합니다.`
);
beat();
void dispatchPhase({ forced: true });
}, CFG.WATCHDOG_INTERVAL_MS);
}
// ===========================================================================
// 16. 부트스트랩
// ===========================================================================
async function bootIdlePage() {
armRetryTimer();
// Daily Shop 페이지에서 재시도가 진행 중이면 그것부터 처리
if (location.hostname === HOST.dailyshop) {
const shop = currentDailyShop();
const retryItem = shop ? getRetries()[shop.code] : null;
if (shop && retryItem?.inProgress && retryItem.date === kstDateKey()) {
await runDailyRetry(shop);
armRetryTimer();
return;
}
}
if (findDueRetry()) launchDueRetry();
}
async function main() {
try {
createStatusPanel();
createDrawPanel();
cleanupOldRetries();
startWatchdog();
const state = getMain();
if (!state.active) {
updateDrawPanel();
await bootIdlePage();
return;
}
updateDrawPanel();
await dispatchPhase({ forced: true });
} catch (error) {
handleFatal(error);
}
}
// 페이지 이탈 시 실행 플래그가 남지 않도록 정리
window.addEventListener('beforeunload', () => {
running = false;
});
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('STOVE 자동화 강제 초기화', () => {
stopAutomation('메뉴에서 강제 초기화했습니다.');
});
GM_registerMenuCommand('STOVE 로그 지우기', () => {
clearStatusLog();
renderStatusPanel();
});
}
void main();
})();