- triggerLazyLoading, getImagesWithLazyLoad에 doc/win 파라미터 추가 → iframe의 document/window로도 lazy 로딩 트리거 가능 - processEpisodeInFrame() 추가: iframe 생성 후 에피소드 순차 처리 - startQueue: window.location.href 이동 대신 iframe 생성/재사용 - processAutoQueue 유지 (기존 탭 방식 fallback) - downloadCurrentEpisode에도 getImagesWithLazyLoad(document, window) 명시
565 lines
23 KiB
JavaScript
565 lines
23 KiB
JavaScript
// ==UserScript==
|
|
// @name sbxh6Downloader
|
|
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
|
|
// @version 0.1.0
|
|
// @description sbxh6.com (뉴토끼) 만화 다운로더
|
|
// @author hehaho
|
|
// @match https://sbxh*.com/manhwa/*
|
|
// @match https://sbxh*.com/manhwa/*/*
|
|
// @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad
|
|
// @grant GM_registerMenuCommand
|
|
// @grant GM_unregisterMenuCommand
|
|
// @grant GM_xmlhttpRequest
|
|
// @grant GM_download
|
|
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
|
|
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip-utils/0.1.0/jszip-utils.js
|
|
// @run-at document-start
|
|
// @license MIT
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ---------------------------------------------------------------
|
|
// Phase 1: IntersectionObserver 후킹 (document-start 에서 즉시 실행)
|
|
// 백그라운드 탭에서 IO가 동작하지 않아도 observe() 즉시 콜백 실행
|
|
// ---------------------------------------------------------------
|
|
const OrigIO = window.IntersectionObserver;
|
|
if (OrigIO) {
|
|
const HookedIO = function (callback, options) {
|
|
const instance = new OrigIO(callback, options);
|
|
const origObserve = instance.observe.bind(instance);
|
|
instance.observe = function (target) {
|
|
origObserve(target);
|
|
setTimeout(() => {
|
|
try {
|
|
callback([{
|
|
target: target,
|
|
isIntersecting: true,
|
|
intersectionRatio: 1,
|
|
boundingClientRect: target.getBoundingClientRect(),
|
|
intersectionRect: target.getBoundingClientRect(),
|
|
rootBounds: null,
|
|
time: performance.now()
|
|
}], instance);
|
|
} catch (_) { }
|
|
}, 50);
|
|
};
|
|
return instance;
|
|
};
|
|
HookedIO.prototype = OrigIO.prototype;
|
|
window.IntersectionObserver = HookedIO;
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Phase 2: DOM 로딩 후 나머지 로직
|
|
// ---------------------------------------------------------------
|
|
const main = () => {
|
|
// 사이트가 console을 모니터링/차단하므로 자체 로거 사용
|
|
const log = (...args) => {
|
|
try { console.log(...args); } catch (_) {}
|
|
};
|
|
|
|
// 메뉴 명령 ID 관리 (중복 등록 방지 → 배지 숫자 증가 방지)
|
|
let menuCmdIds = [];
|
|
|
|
const registerMenu = () => {
|
|
menuCmdIds.forEach(id => {
|
|
try { GM_unregisterMenuCommand(id); } catch (_) {}
|
|
});
|
|
menuCmdIds = [];
|
|
|
|
const { isListPage, isEpisodePage } = getListPageInfo();
|
|
if (isListPage) {
|
|
menuCmdIds.push(GM_registerMenuCommand('[목록] 전체 에피소드 다운로드', () => downloadAllEpisodes()));
|
|
menuCmdIds.push(GM_registerMenuCommand('[목록] n화 이후 다운로드', () => downloadAfterEpisode()));
|
|
menuCmdIds.push(GM_registerMenuCommand('[목록] 범위 다운로드', () => downloadRangeEpisodes()));
|
|
} else if (isEpisodePage) {
|
|
menuCmdIds.push(GM_registerMenuCommand('현재 에피소드 다운로드', () => downloadCurrentEpisode()));
|
|
}
|
|
};
|
|
|
|
const downloadZip = (blob, filename) => {
|
|
const url = URL.createObjectURL(blob);
|
|
if (typeof GM_download !== "undefined") {
|
|
GM_download({
|
|
url: url,
|
|
name: filename,
|
|
onload: () => URL.revokeObjectURL(url),
|
|
onerror: () => {
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
});
|
|
} else {
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
};
|
|
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// 자동 연속 다운로드 큐 (sessionStorage 기반)
|
|
const QUEUE_KEY = 'sbxh6_q';
|
|
const IDX_KEY = 'sbxh6_i';
|
|
const SERIES_KEY = 'sbxh6_s';
|
|
const LIST_KEY = 'sbxh6_list';
|
|
|
|
const getQueue = () => {
|
|
try { return JSON.parse(sessionStorage.getItem(QUEUE_KEY) || 'null'); } catch (_) { return null; }
|
|
};
|
|
const getQueueIndex = () => {
|
|
try { return parseInt(sessionStorage.getItem(IDX_KEY) || '0', 10); } catch (_) { return 0; }
|
|
};
|
|
const cleanupQueue = () => {
|
|
sessionStorage.removeItem(QUEUE_KEY);
|
|
sessionStorage.removeItem(IDX_KEY);
|
|
sessionStorage.removeItem(SERIES_KEY);
|
|
sessionStorage.removeItem(LIST_KEY);
|
|
};
|
|
|
|
function generateComicInfo(title, series, writer, number, pageCount, imageCount = 0) {
|
|
let pagesXml = '';
|
|
if (imageCount > 0) {
|
|
for (let i = 0; i < imageCount; i++) {
|
|
pagesXml += ` <Page Image="${i}" Type="Story"/>\n`;
|
|
}
|
|
}
|
|
|
|
const comicInfo = `<?xml version="1.0" encoding="utf-8"?>
|
|
<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
|
<Title>${title}</Title>
|
|
<Series>${series}</Series>
|
|
<Number>${number}</Number>
|
|
<Count>-1</Count>
|
|
<Volume>-1</Volume>
|
|
<Summary></Summary>
|
|
<Notes></Notes>
|
|
<Year>-1</Year>
|
|
<Month>-1</Month>
|
|
<Writer>${writer}</Writer>
|
|
<Penciller></Penciller>
|
|
<Inker></Inker>
|
|
<Colorist></Colorist>
|
|
<Letterer></Letterer>
|
|
<CoverArtist></CoverArtist>
|
|
<Editor></Editor>
|
|
<Publisher></Publisher>
|
|
<Imprint></Imprint>
|
|
<Genre></Genre>
|
|
<Web></Web>
|
|
<PageCount>${pageCount}</PageCount>
|
|
<LanguageISO>ko</LanguageISO>
|
|
<Format></Format>
|
|
<BlackAndWhite>Unknown</BlackAndWhite>
|
|
<Manga>Yes</Manga>
|
|
${pagesXml ? ` <Pages>\n${pagesXml} </Pages>\n` : ''}
|
|
</ComicInfo>`;
|
|
return comicInfo;
|
|
}
|
|
|
|
function getEpisodeImages(container) {
|
|
const root = container || document;
|
|
const imgs = root.querySelectorAll(
|
|
'.vw-imgs img, .vw-imgs--double img, .vw-imgs--single img, ' +
|
|
'.webtoon-page img, .manhwa-page img, ' +
|
|
'[class*="vw-imgs"] img'
|
|
);
|
|
const urls = [];
|
|
const seen = new Set();
|
|
imgs.forEach(img => {
|
|
const src = img.src || img.getAttribute('src') || '';
|
|
if (src && src.startsWith('http') && !seen.has(src)) {
|
|
seen.add(src);
|
|
urls.push(src);
|
|
}
|
|
});
|
|
return urls;
|
|
}
|
|
|
|
function getSeriesName() {
|
|
const metaEl = document.querySelector('div.vw-bot-meta strong');
|
|
if (metaEl) return metaEl.textContent.trim();
|
|
|
|
const titleEl = document.querySelector('h1');
|
|
if (titleEl && !titleEl.closest('.hero-v2')) {
|
|
return titleEl.textContent.trim();
|
|
}
|
|
|
|
const ogTitle = document.querySelector('meta[property="og:title"]');
|
|
if (ogTitle) {
|
|
const content = ogTitle.getAttribute('content') || '';
|
|
return content.split('|')[0].trim() || content;
|
|
}
|
|
|
|
return document.title.split('|')[0].trim() || '제목없음';
|
|
}
|
|
|
|
function getEpisodeTitle() {
|
|
const metaEl = document.querySelector('div.vw-bot-meta span');
|
|
if (metaEl) return metaEl.textContent.trim();
|
|
|
|
const titleEl = document.querySelector('h1');
|
|
if (titleEl && !titleEl.closest('.hero-v2')) {
|
|
return titleEl.textContent.trim();
|
|
}
|
|
|
|
return document.title.split('|')[0].trim() || '제목없음';
|
|
}
|
|
|
|
function getListPageInfo() {
|
|
const currentURL = window.location.href;
|
|
const pathParts = window.location.pathname.split('/').filter(Boolean);
|
|
const isListPage = pathParts.length === 2;
|
|
const isEpisodePage = pathParts.length >= 3;
|
|
|
|
return { isListPage, isEpisodePage };
|
|
}
|
|
|
|
// 이미지의 data-* 속성을 src로 강제 복사
|
|
const forceLoadImages = (root) => {
|
|
const container = root || document;
|
|
container.querySelectorAll('img').forEach(img => {
|
|
img.setAttribute('loading', 'eager');
|
|
['data-src', 'data-original', 'data-lazy-src', 'data-lazy', 'data-srcset'].forEach(attr => {
|
|
const val = img.getAttribute(attr);
|
|
if (val && !img.src) img.src = val;
|
|
});
|
|
});
|
|
};
|
|
|
|
// lazy loading 트리거: data-src 복사 + loading=eager + 스크롤
|
|
const triggerLazyLoading = async (doc, win) => {
|
|
const d = doc || document;
|
|
const w = win || window;
|
|
const viewer = d.querySelector('.vw-imgs, [class*="vw-imgs"]') || d;
|
|
forceLoadImages(viewer);
|
|
let prevHeight = 0;
|
|
let stuckCount = 0;
|
|
while (stuckCount < 3) {
|
|
const maxScroll = Math.max(
|
|
d.documentElement.scrollHeight,
|
|
d.body.scrollHeight, 0
|
|
);
|
|
if (maxScroll === prevHeight) {
|
|
stuckCount++;
|
|
} else {
|
|
stuckCount = 0;
|
|
}
|
|
prevHeight = maxScroll;
|
|
const step = Math.min(300, maxScroll);
|
|
for (let s = w.scrollY; s <= maxScroll; s += step) {
|
|
w.scrollTo(0, Math.min(s, maxScroll));
|
|
await sleep(300);
|
|
}
|
|
w.scrollTo(0, maxScroll);
|
|
forceLoadImages(viewer);
|
|
await sleep(1500);
|
|
}
|
|
forceLoadImages(viewer);
|
|
await sleep(2000);
|
|
w.scrollTo(0, 0);
|
|
await sleep(1000);
|
|
};
|
|
|
|
// 공통: 이미지 URL 목록을 받아 ZIP 생성 + 다운로드
|
|
const downloadImagesAsZip = async (imageUrls, seriesName, episodeTitle, episodeNumber) => {
|
|
const zip = new JSZip();
|
|
const protocolDomain = window.location.origin;
|
|
|
|
for (let i = 0; i < imageUrls.length; i++) {
|
|
const url = imageUrls[i];
|
|
await new Promise((resolve) => {
|
|
GM_xmlhttpRequest({
|
|
method: 'GET', url, responseType: 'arraybuffer',
|
|
headers: {
|
|
'Referer': protocolDomain,
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
},
|
|
onload: (resp) => {
|
|
if (resp.status === 200) {
|
|
const ext = url.split('.').pop().split('?')[0] || 'jpg';
|
|
zip.file(`image${String(i).padStart(4, '0')}.${ext}`, new Blob([resp.response], { type: 'image/' + ext }));
|
|
}
|
|
resolve();
|
|
},
|
|
onerror: () => resolve(),
|
|
ontimeout: () => resolve()
|
|
});
|
|
});
|
|
if ((i + 1) % 5 === 0) await sleep(1000);
|
|
}
|
|
|
|
const comicInfo = generateComicInfo(episodeTitle, seriesName, '', episodeNumber, imageUrls.length, imageUrls.length);
|
|
zip.file('ComicInfo.xml', comicInfo);
|
|
const blob = await zip.generateAsync({ type: "blob" });
|
|
const filename = `${seriesName} - ${episodeTitle}.zip`.replace(/[/\\:*?"<>|]/g, '_');
|
|
downloadZip(blob, filename);
|
|
log(`저장: ${filename} (${imageUrls.length}개)`);
|
|
};
|
|
|
|
// 공통: 페이지/iframe에서 이미지 획득 + lazy 로딩 트리거
|
|
const getImagesWithLazyLoad = async (doc, win) => {
|
|
const d = doc || document;
|
|
const w = win || window;
|
|
let urls = getEpisodeImages(d);
|
|
if (urls.length === 0) {
|
|
for (let i = 0; i < 10; i++) {
|
|
await sleep(500);
|
|
urls = getEpisodeImages(d);
|
|
if (urls.length > 0) break;
|
|
}
|
|
}
|
|
if (urls.length <= 3) {
|
|
log(`lazy loading 트리거 (${urls.length}개 → 전체)...`);
|
|
await triggerLazyLoading(d, w);
|
|
urls = getEpisodeImages(d);
|
|
}
|
|
return urls;
|
|
};
|
|
|
|
async function downloadCurrentEpisode() {
|
|
try {
|
|
log('에피소드 다운로드 시작...');
|
|
const imageUrls = await getImagesWithLazyLoad(document, window);
|
|
|
|
if (imageUrls.length === 0) {
|
|
alert('이미지를 찾을 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
const seriesName = getSeriesName();
|
|
const episodeTitle = getEpisodeTitle();
|
|
log(`시리즈: ${seriesName}, 에피소드: ${episodeTitle}, 이미지: ${imageUrls.length}개`);
|
|
|
|
await downloadImagesAsZip(imageUrls, seriesName, episodeTitle, '1');
|
|
log('완료');
|
|
} catch (error) {
|
|
alert('다운로드 중 오류 발생:\n' + error);
|
|
log(error);
|
|
}
|
|
}
|
|
|
|
// 목록 페이지에서 에피소드 목록 추출
|
|
const getEpisodesFromList = () => {
|
|
const episodeList = document.querySelector('ul.ep-list-v2');
|
|
if (!episodeList) return null;
|
|
const episodes = [];
|
|
episodeList.querySelectorAll('li.ep-row-v2').forEach((li, index) => {
|
|
const link = li.querySelector('a.ep-row-v2-link');
|
|
if (link) {
|
|
const href = link.getAttribute('href');
|
|
const absoluteUrl = href.startsWith('http') ? href : new URL(href, window.location.href).href;
|
|
const epNoEl = li.querySelector('.ep-row-v2-no');
|
|
const epTitleEl = li.querySelector('.ep-row-v2-title strong');
|
|
episodes.push({
|
|
index,
|
|
number: epNoEl ? epNoEl.textContent.trim() : String(index + 1),
|
|
title: epTitleEl ? epTitleEl.textContent.trim() : String(index + 1),
|
|
url: absoluteUrl
|
|
});
|
|
}
|
|
});
|
|
return episodes.length > 0 ? episodes : null;
|
|
};
|
|
|
|
let downloadFrame = null;
|
|
|
|
// iframe에서 에피소드 처리
|
|
const processEpisodeInFrame = async (episodes, index, seriesName) => {
|
|
if (index >= episodes.length) {
|
|
log('모든 에피소드 다운로드 완료!');
|
|
if (downloadFrame) {
|
|
downloadFrame.remove();
|
|
downloadFrame = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const episode = episodes[index];
|
|
log(`${index + 1}/${episodes.length} ${episode.title} 로딩 중...`);
|
|
|
|
// iframe 생성 (상단 1/3)
|
|
if (!downloadFrame) {
|
|
downloadFrame = document.createElement('iframe');
|
|
downloadFrame.style.position = 'fixed';
|
|
downloadFrame.style.top = '0';
|
|
downloadFrame.style.left = '0';
|
|
downloadFrame.style.width = '100%';
|
|
downloadFrame.style.height = '33.33vh';
|
|
downloadFrame.style.border = 'none';
|
|
downloadFrame.style.zIndex = '999999';
|
|
document.body.appendChild(downloadFrame);
|
|
}
|
|
|
|
// 에피소드 로드
|
|
downloadFrame.src = episode.url;
|
|
await new Promise((resolve) => {
|
|
downloadFrame.addEventListener('load', () => resolve(), { once: true });
|
|
});
|
|
log(`${episode.title} 로드 완료, 이미지 대기 중...`);
|
|
await sleep(7000);
|
|
|
|
// iframe에서 이미지 추출
|
|
let imageUrls = [];
|
|
try {
|
|
const iframeWin = downloadFrame.contentWindow;
|
|
const iframeDoc = iframeWin.document;
|
|
imageUrls = await getImagesWithLazyLoad(iframeDoc, iframeWin);
|
|
} catch (e) {
|
|
log('iframe 접근 오류, GM_xmlhttpRequest로 대체:', e);
|
|
// GM_xmlhttpRequest로 페이지 HTML 받아서 이미지 추출 시도
|
|
// (fallback - 추후 구현)
|
|
}
|
|
|
|
if (imageUrls.length > 0) {
|
|
await downloadImagesAsZip(imageUrls, seriesName, episode.title, episode.number);
|
|
} else {
|
|
log('이미지 없음 - 스킵');
|
|
}
|
|
|
|
await sleep(2000);
|
|
processEpisodeInFrame(episodes, index + 1, seriesName);
|
|
};
|
|
|
|
const startQueue = (episodes, seriesName) => {
|
|
log(`${episodes.length}개 에피소드 다운로드 시작 (iframe)...`);
|
|
processEpisodeInFrame(episodes, 0, seriesName);
|
|
};
|
|
|
|
async function downloadAllEpisodes() {
|
|
log('목록 페이지 처리 시작');
|
|
const episodes = getEpisodesFromList();
|
|
if (!episodes) { alert('에피소드 목록을 찾을 수 없습니다.'); return; }
|
|
const seriesName = getSeriesName();
|
|
startQueue(episodes, seriesName);
|
|
}
|
|
|
|
async function downloadAfterEpisode() {
|
|
const episodes = getEpisodesFromList();
|
|
if (!episodes) { alert('에피소드 목록을 찾을 수 없습니다.'); return; }
|
|
|
|
const input = prompt('어느 에피소드부터 다운로드할까요?\n(번호 입력, 예: 50)');
|
|
if (!input) return;
|
|
|
|
const threshold = parseFloat(input.trim());
|
|
if (isNaN(threshold)) { alert('올바른 숫자를 입력하세요.'); return; }
|
|
|
|
const filtered = episodes
|
|
.filter(ep => parseFloat(ep.number) >= threshold)
|
|
.sort((a, b) => parseFloat(a.number) - parseFloat(b.number));
|
|
|
|
if (filtered.length === 0) { alert('조건에 맞는 에피소드가 없습니다.'); return; }
|
|
const seriesName = getSeriesName();
|
|
startQueue(filtered, seriesName);
|
|
}
|
|
|
|
async function downloadRangeEpisodes() {
|
|
const episodes = getEpisodesFromList();
|
|
if (!episodes) { alert('에피소드 목록을 찾을 수 없습니다.'); return; }
|
|
|
|
const input = prompt('범위를 입력하세요.\n(예: 30-50 또는 50-끝)');
|
|
if (!input) return;
|
|
|
|
const match = input.trim().match(/^(\d+(?:\.\d+)?)\s*-\s*(?:(\d+(?:\.\d+)?)|(끝|end))?$/);
|
|
if (!match) { alert('올바른 형식이 아닙니다. 예: 30-50'); return; }
|
|
|
|
const start = parseFloat(match[1]);
|
|
const end = match[2] !== undefined ? parseFloat(match[2]) : Infinity;
|
|
|
|
if (isNaN(start)) { alert('올바른 숫자를 입력하세요.'); return; }
|
|
|
|
const filtered = episodes
|
|
.filter(ep => {
|
|
const num = parseFloat(ep.number);
|
|
return num >= start && num <= end;
|
|
})
|
|
.sort((a, b) => parseFloat(a.number) - parseFloat(b.number));
|
|
|
|
if (filtered.length === 0) { alert('조건에 맞는 에피소드가 없습니다.'); return; }
|
|
const seriesName = getSeriesName();
|
|
startQueue(filtered, seriesName);
|
|
}
|
|
|
|
// 자동 큐 처리: sessionStorage에 큐가 있으면 현재 화면을 다운로드 후 다음으로 이동
|
|
const processAutoQueue = async () => {
|
|
const queue = getQueue();
|
|
if (!queue) return;
|
|
const { isEpisodePage } = getListPageInfo();
|
|
if (!isEpisodePage) return; // 목록 페이지에서는 큐 처리 안 함
|
|
|
|
const idx = getQueueIndex();
|
|
if (idx >= queue.length) { cleanupQueue(); return; }
|
|
|
|
log(`자동 다운로드: ${idx + 1}/${queue.length} - ${queue[idx].title}`);
|
|
await sleep(7000);
|
|
|
|
try {
|
|
const imageUrls = await getImagesWithLazyLoad(document, window);
|
|
if (imageUrls.length > 0) {
|
|
const seriesName = sessionStorage.getItem(SERIES_KEY) || getSeriesName();
|
|
const episodeTitle = getEpisodeTitle() || queue[idx].title;
|
|
await downloadImagesAsZip(imageUrls, seriesName, episodeTitle, queue[idx].number);
|
|
} else {
|
|
log(`이미지 없음 - 스킵`);
|
|
}
|
|
} catch (e) {
|
|
log('자동 다운로드 오류:', e);
|
|
}
|
|
|
|
const next = idx + 1;
|
|
sessionStorage.setItem(IDX_KEY, String(next));
|
|
|
|
if (next < queue.length) {
|
|
log(`${next + 1}/${queue.length}번째로 이동...`);
|
|
await sleep(2000);
|
|
window.location.href = queue[next].url;
|
|
} else {
|
|
const listUrl = sessionStorage.getItem(LIST_KEY);
|
|
cleanupQueue();
|
|
log('모든 에피소드 다운로드 완료!');
|
|
if (listUrl) {
|
|
log('목록 페이지로 복귀합니다...');
|
|
await sleep(2000);
|
|
window.location.href = listUrl;
|
|
}
|
|
}
|
|
};
|
|
|
|
registerMenu();
|
|
|
|
// 자동 다운로드 큐 확인 (목록→에피소드 이동 시)
|
|
setTimeout(processAutoQueue, 1000);
|
|
|
|
// Next.js SPA 네비게이션 감지: URL 변경 시 메뉴 재등록
|
|
const origPushState = history.pushState;
|
|
const origReplaceState = history.replaceState;
|
|
history.pushState = function () {
|
|
origPushState.apply(this, arguments);
|
|
setTimeout(registerMenu, 500);
|
|
};
|
|
history.replaceState = function () {
|
|
origReplaceState.apply(this, arguments);
|
|
setTimeout(registerMenu, 500);
|
|
};
|
|
window.addEventListener('popstate', () => setTimeout(registerMenu, 500));
|
|
|
|
log('sbxh6Downloader loaded');
|
|
};
|
|
|
|
// DOM 로딩 완료 후 main() 실행
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', main);
|
|
} else {
|
|
main();
|
|
}
|
|
})();
|