sbxh6Downloader v0.1.0: iframe → sessionStorage queue + page navigation
- iframe/팝업/XHR 대신 페이지 직접 이동 방식으로 전환 - sessionStorage 기반 큐로 전체 에피소드 연속 다운로드 - 동적 scrollHeight에 대응하는 lazy 로딩 트리거 - 공통 헬퍼: downloadImagesAsZip, getImagesWithLazyLoad - GM_unregisterMenuCommand로 메뉴 중복 등록/배지 누적 해결 - 안전한 로거(log)로 console 차단 사이트 대응 - Next.js SPA pushState/replaceState 감지하여 메뉴 재등록
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name sbxh6Downloader
|
// @name sbxh6Downloader
|
||||||
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
|
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
|
||||||
// @version 0.0.1
|
// @version 0.1.0
|
||||||
// @description sbxh6.com (뉴토끼) 만화 다운로더
|
// @description sbxh6.com (뉴토끼) 만화 다운로더
|
||||||
// @author hehaho
|
// @author hehaho
|
||||||
// @match https://sbxh*.com/manhwa/*
|
// @match https://sbxh*.com/manhwa/*
|
||||||
// @match https://sbxh*.com/manhwa/*/*
|
// @match https://sbxh*.com/manhwa/*/*
|
||||||
// @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad
|
// @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad
|
||||||
// @grant GM_registerMenuCommand
|
// @grant GM_registerMenuCommand
|
||||||
|
// @grant GM_unregisterMenuCommand
|
||||||
// @grant GM_xmlhttpRequest
|
// @grant GM_xmlhttpRequest
|
||||||
// @grant GM_download
|
// @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/3.7.1/jszip.min.js
|
||||||
@@ -19,6 +20,28 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
// 사이트가 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()));
|
||||||
|
} else if (isEpisodePage) {
|
||||||
|
menuCmdIds.push(GM_registerMenuCommand('현재 에피소드 다운로드', () => downloadCurrentEpisode()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const downloadZip = (blob, filename) => {
|
const downloadZip = (blob, filename) => {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
if (typeof GM_download !== "undefined") {
|
if (typeof GM_download !== "undefined") {
|
||||||
@@ -47,6 +70,23 @@
|
|||||||
return new Promise(resolve => setTimeout(resolve, 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 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);
|
||||||
|
};
|
||||||
|
|
||||||
function generateComicInfo(title, series, writer, number, pageCount, imageCount = 0) {
|
function generateComicInfo(title, series, writer, number, pageCount, imageCount = 0) {
|
||||||
let pagesXml = '';
|
let pagesXml = '';
|
||||||
if (imageCount > 0) {
|
if (imageCount > 0) {
|
||||||
@@ -87,12 +127,20 @@ ${pagesXml ? ` <Pages>\n${pagesXml} </Pages>\n` : ''}
|
|||||||
return comicInfo;
|
return comicInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEpisodeImages() {
|
function getEpisodeImages(container) {
|
||||||
const imgs = document.querySelectorAll('div.vw-imgs img, div.vw-imgs--single img, div.vw-imgs--double img');
|
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 urls = [];
|
||||||
|
const seen = new Set();
|
||||||
imgs.forEach(img => {
|
imgs.forEach(img => {
|
||||||
if (img.src && img.src.startsWith('http') && !urls.includes(img.src)) {
|
const src = img.src || img.getAttribute('src') || '';
|
||||||
urls.push(img.src);
|
if (src && src.startsWith('http') && !seen.has(src)) {
|
||||||
|
seen.add(src);
|
||||||
|
urls.push(src);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return urls;
|
return urls;
|
||||||
@@ -137,285 +185,213 @@ ${pagesXml ? ` <Pages>\n${pagesXml} </Pages>\n` : ''}
|
|||||||
return { isListPage, isEpisodePage };
|
return { isListPage, isEpisodePage };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lazy loading 트리거: data-src 복사 + loading=eager + 스크롤
|
||||||
|
const triggerLazyLoading = async () => {
|
||||||
|
const viewer = document.querySelector('.vw-imgs, [class*="vw-imgs"]') || document;
|
||||||
|
const imgs = viewer.querySelectorAll('img');
|
||||||
|
imgs.forEach(img => {
|
||||||
|
img.setAttribute('loading', 'eager');
|
||||||
|
['data-src', 'data-original', 'data-lazy-src', 'data-lazy'].forEach(attr => {
|
||||||
|
const val = img.getAttribute(attr);
|
||||||
|
if (val && !img.src) img.src = val;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// 동적 스크롤: 새 콘텐츠가 계속 로드되면 scrollHeight도 계속 증가
|
||||||
|
let prevHeight = 0;
|
||||||
|
let stuckCount = 0;
|
||||||
|
while (stuckCount < 3) {
|
||||||
|
const maxScroll = Math.max(
|
||||||
|
document.documentElement.scrollHeight,
|
||||||
|
document.body.scrollHeight, 0
|
||||||
|
);
|
||||||
|
if (maxScroll === prevHeight) {
|
||||||
|
stuckCount++;
|
||||||
|
} else {
|
||||||
|
stuckCount = 0;
|
||||||
|
}
|
||||||
|
prevHeight = maxScroll;
|
||||||
|
// 현재 위치에서 끝까지 step 단위로 스크롤
|
||||||
|
const step = Math.min(500, maxScroll);
|
||||||
|
for (let s = window.scrollY; s <= maxScroll; s += step) {
|
||||||
|
window.scrollTo(0, Math.min(s, maxScroll));
|
||||||
|
await sleep(80);
|
||||||
|
}
|
||||||
|
window.scrollTo(0, maxScroll);
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
// 끝까지 다 내려간 상태에서 여유 대기
|
||||||
|
await sleep(2000);
|
||||||
|
window.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}개)`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공통: 현재 페이지에서 이미지 획득 + lazy 로딩 트리거
|
||||||
|
const getImagesWithLazyLoad = async () => {
|
||||||
|
let urls = getEpisodeImages();
|
||||||
|
if (urls.length === 0) {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await sleep(500);
|
||||||
|
urls = getEpisodeImages();
|
||||||
|
if (urls.length > 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (urls.length <= 3) {
|
||||||
|
log(`lazy loading 트리거 (${urls.length}개 → 전체)...`);
|
||||||
|
await triggerLazyLoading();
|
||||||
|
urls = getEpisodeImages();
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
};
|
||||||
|
|
||||||
async function downloadCurrentEpisode() {
|
async function downloadCurrentEpisode() {
|
||||||
try {
|
try {
|
||||||
console.clear();
|
log('에피소드 다운로드 시작...');
|
||||||
console.log('에피소드 다운로드 시작...');
|
const imageUrls = await getImagesWithLazyLoad();
|
||||||
|
|
||||||
let imageUrls = getEpisodeImages();
|
|
||||||
if (imageUrls.length === 0) {
|
|
||||||
console.log('이미지 로드 대기 중...');
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
await sleep(500);
|
|
||||||
imageUrls = getEpisodeImages();
|
|
||||||
if (imageUrls.length > 0) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (imageUrls.length === 0) {
|
if (imageUrls.length === 0) {
|
||||||
alert('이미지를 찾을 수 없습니다. 페이지가 완전히 로드되었는지 확인해주세요.');
|
alert('이미지를 찾을 수 없습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seriesName = getSeriesName();
|
const seriesName = getSeriesName();
|
||||||
const episodeTitle = getEpisodeTitle();
|
const episodeTitle = getEpisodeTitle();
|
||||||
console.log(`시리즈: ${seriesName}`);
|
log(`시리즈: ${seriesName}, 에피소드: ${episodeTitle}, 이미지: ${imageUrls.length}개`);
|
||||||
console.log(`에피소드: ${episodeTitle}`);
|
|
||||||
console.log(`이미지: ${imageUrls.length}개 발견`);
|
|
||||||
|
|
||||||
const zip = new JSZip();
|
await downloadImagesAsZip(imageUrls, seriesName, episodeTitle, '1');
|
||||||
const protocolDomain = window.location.origin;
|
log('완료');
|
||||||
|
|
||||||
const downloadImage = (url, index, total) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
GM_xmlhttpRequest({
|
|
||||||
method: 'GET',
|
|
||||||
url: url,
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
headers: {
|
|
||||||
'Referer': protocolDomain,
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
||||||
},
|
|
||||||
onload: (response) => {
|
|
||||||
if (response.status === 200) {
|
|
||||||
const ext = url.split('.').pop().split('?')[0] || 'jpg';
|
|
||||||
const blob = new Blob([response.response], { type: 'image/' + ext });
|
|
||||||
const filename = `image${String(index).padStart(4, '0')}.${ext}`;
|
|
||||||
zip.file(filename, blob);
|
|
||||||
console.log(`${index + 1}/${total} 다운로드 완료`);
|
|
||||||
} else {
|
|
||||||
console.log(`이미지 ${index} 실패 (${response.status})`);
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
onerror: () => {
|
|
||||||
console.log(`이미지 ${index} 오류`);
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
ontimeout: () => {
|
|
||||||
console.log(`이미지 ${index} 타임아웃`);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let promiseList = [];
|
|
||||||
for (let i = 0; i < imageUrls.length; i++) {
|
|
||||||
promiseList.push(downloadImage(imageUrls[i], i, imageUrls.length));
|
|
||||||
if (promiseList.length >= 5) {
|
|
||||||
await Promise.all(promiseList);
|
|
||||||
promiseList = [];
|
|
||||||
await sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (promiseList.length > 0) {
|
|
||||||
await Promise.all(promiseList);
|
|
||||||
}
|
|
||||||
|
|
||||||
const extMatch = imageUrls[0].match(/\.(jpg|jpeg|png|webp)/i);
|
|
||||||
const ext = extMatch ? extMatch[1].toLowerCase() : 'jpg';
|
|
||||||
const extCounts = {};
|
|
||||||
imageUrls.forEach(url => {
|
|
||||||
const e = url.match(/\.(jpg|jpeg|png|webp)/i);
|
|
||||||
if (e) extCounts[e[1].toLowerCase()] = (extCounts[e[1].toLowerCase()] || 0) + 1;
|
|
||||||
});
|
|
||||||
const primaryExt = Object.keys(extCounts).sort((a, b) => extCounts[b] - extCounts[a])[0] || 'jpg';
|
|
||||||
|
|
||||||
const comicInfoContent = generateComicInfo(episodeTitle, seriesName, '', '1', imageUrls.length, imageUrls.length);
|
|
||||||
zip.file('ComicInfo.xml', comicInfoContent);
|
|
||||||
|
|
||||||
const content = await zip.generateAsync({ type: "blob" });
|
|
||||||
downloadZip(content, `${seriesName} - ${episodeTitle}.zip`);
|
|
||||||
|
|
||||||
console.log(`완료: ${imageUrls.length}개 이미지`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('다운로드 중 오류 발생:\n' + error);
|
alert('다운로드 중 오류 발생:\n' + error);
|
||||||
console.error(error);
|
log(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadAllEpisodes() {
|
async function downloadAllEpisodes() {
|
||||||
try {
|
log('목록 페이지 처리 시작');
|
||||||
console.clear();
|
|
||||||
console.log('목록 페이지 처리 시작');
|
|
||||||
|
|
||||||
const episodeList = document.querySelector('ul.ep-list-v2');
|
const episodeList = document.querySelector('ul.ep-list-v2');
|
||||||
if (!episodeList) {
|
if (!episodeList) { alert('에피소드 목록을 찾을 수 없습니다.'); return; }
|
||||||
alert('에피소드 목록을 찾을 수 없습니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = episodeList.querySelectorAll('li.ep-row-v2');
|
const episodes = [];
|
||||||
console.log(`총 ${items.length}개 에피소드 발견`);
|
episodeList.querySelectorAll('li.ep-row-v2').forEach((li, index) => {
|
||||||
|
const link = li.querySelector('a.ep-row-v2-link');
|
||||||
const episodes = [];
|
if (link) {
|
||||||
items.forEach((li, index) => {
|
const href = link.getAttribute('href');
|
||||||
const link = li.querySelector('a.ep-row-v2-link');
|
const absoluteUrl = href.startsWith('http') ? href : new URL(href, window.location.href).href;
|
||||||
if (link) {
|
const epNoEl = li.querySelector('.ep-row-v2-no');
|
||||||
const href = link.getAttribute('href');
|
const epTitleEl = li.querySelector('.ep-row-v2-title strong');
|
||||||
const absoluteUrl = href.startsWith('http') ? href : new URL(href, window.location.href).href;
|
episodes.push({
|
||||||
const epNoEl = li.querySelector('.ep-row-v2-no');
|
index,
|
||||||
const epTitleEl = li.querySelector('.ep-row-v2-title strong');
|
number: epNoEl ? epNoEl.textContent.trim() : String(index + 1),
|
||||||
const epNumber = epNoEl ? epNoEl.textContent.trim() : String(index + 1);
|
title: epTitleEl ? epTitleEl.textContent.trim() : String(index + 1),
|
||||||
const epTitle = epTitleEl ? epTitleEl.textContent.trim() : epNumber;
|
url: absoluteUrl
|
||||||
|
|
||||||
episodes.push({
|
|
||||||
index,
|
|
||||||
number: epNumber,
|
|
||||||
title: epTitle,
|
|
||||||
url: absoluteUrl
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`추출된 링크: ${episodes.length}개`);
|
|
||||||
|
|
||||||
if (episodes.length === 0) {
|
|
||||||
alert('에피소드 링크를 추출할 수 없습니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seriesName = getSeriesName();
|
|
||||||
console.log(`시리즈: ${seriesName}`);
|
|
||||||
|
|
||||||
const iframe = document.createElement('iframe');
|
|
||||||
iframe.width = 600;
|
|
||||||
iframe.height = 600;
|
|
||||||
iframe.style.position = 'fixed';
|
|
||||||
iframe.style.left = '-9999px';
|
|
||||||
iframe.style.top = '0';
|
|
||||||
document.body.appendChild(iframe);
|
|
||||||
|
|
||||||
const waitIframeLoad = (url) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
iframe.addEventListener('load', () => resolve(), { once: true });
|
|
||||||
iframe.src = url;
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const getImagesInIframe = () => {
|
|
||||||
try {
|
|
||||||
const iframeDoc = iframe.contentWindow.document;
|
|
||||||
const imgs = iframeDoc.querySelectorAll('div.vw-imgs img, div.vw-imgs--single img');
|
|
||||||
const urls = [];
|
|
||||||
imgs.forEach(img => {
|
|
||||||
if (img.src && img.src.startsWith('http') && !urls.includes(img.src)) {
|
|
||||||
urls.push(img.src);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return urls;
|
|
||||||
} catch (e) {
|
|
||||||
console.log('iframe 접근 오류:', e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < episodes.length; i++) {
|
|
||||||
const episode = episodes[i];
|
|
||||||
console.clear();
|
|
||||||
console.log(`${i + 1}/${episodes.length} ${episode.title} 처리 중...`);
|
|
||||||
|
|
||||||
await waitIframeLoad(episode.url);
|
|
||||||
console.log('페이지 로드 완료, 이미지 대기 중...');
|
|
||||||
await sleep(3000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const iframeDoc = iframe.contentWindow.document;
|
|
||||||
const viewContent = iframeDoc.querySelector('.vw-imgs, .vw-imgs--single, [class*="vw-imgs"]');
|
|
||||||
if (viewContent) {
|
|
||||||
viewContent.scrollTop = 100;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
await sleep(2000);
|
|
||||||
|
|
||||||
let imageUrls = getImagesInIframe();
|
|
||||||
|
|
||||||
if (imageUrls.length === 0) {
|
|
||||||
console.log(`이미지를 찾을 수 없음 - 스킵`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`이미지 ${imageUrls.length}개 발견`);
|
|
||||||
|
|
||||||
const zip = new JSZip();
|
|
||||||
const protocolDomain = window.location.origin;
|
|
||||||
|
|
||||||
const downloadImage = (url, index, total) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
GM_xmlhttpRequest({
|
|
||||||
method: 'GET',
|
|
||||||
url: url,
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
headers: {
|
|
||||||
'Referer': protocolDomain,
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
||||||
},
|
|
||||||
onload: (response) => {
|
|
||||||
if (response.status === 200) {
|
|
||||||
const ext = url.split('.').pop().split('?')[0] || 'jpg';
|
|
||||||
const blob = new Blob([response.response], { type: 'image/' + ext });
|
|
||||||
const filename = `image${String(index).padStart(4, '0')}.${ext}`;
|
|
||||||
zip.file(filename, blob);
|
|
||||||
console.log(` ${index + 1}/${total} 이미지 다운로드`);
|
|
||||||
} else {
|
|
||||||
console.log(` 이미지 ${index} 실패 (${response.status})`);
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
onerror: () => {
|
|
||||||
console.log(` 이미지 ${index} 오류`);
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
ontimeout: () => {
|
|
||||||
console.log(` 이미지 ${index} 타임아웃`);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let promiseList = [];
|
|
||||||
for (let j = 0; j < imageUrls.length; j++) {
|
|
||||||
promiseList.push(downloadImage(imageUrls[j], j, imageUrls.length));
|
|
||||||
if (promiseList.length >= 5) {
|
|
||||||
await Promise.all(promiseList);
|
|
||||||
promiseList = [];
|
|
||||||
await sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (promiseList.length > 0) {
|
|
||||||
await Promise.all(promiseList);
|
|
||||||
}
|
|
||||||
|
|
||||||
const comicInfoContent = generateComicInfo(episode.title, seriesName, '', String(i + 1), imageUrls.length, imageUrls.length);
|
|
||||||
zip.file('ComicInfo.xml', comicInfoContent);
|
|
||||||
|
|
||||||
const content = await zip.generateAsync({ type: "blob" });
|
|
||||||
downloadZip(content, `${seriesName} - ${episode.title}.zip`);
|
|
||||||
|
|
||||||
console.log(`${i + 1}/${episodes.length} ${episode.title} 완료`);
|
|
||||||
await sleep(3000);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
iframe.remove();
|
if (episodes.length === 0) { alert('에피소드 링크를 추출할 수 없습니다.'); return; }
|
||||||
console.log('모든 에피소드 다운로드 완료');
|
|
||||||
} catch (error) {
|
const seriesName = getSeriesName();
|
||||||
alert('목록 다운로드 중 오류:\n' + error);
|
log(`${episodes.length}개 에피소드를 큐에 저장, 첫 화로 이동합니다...`);
|
||||||
console.error(error);
|
|
||||||
|
// 큐 저장 후 첫 번째 에피소드로 이동
|
||||||
|
sessionStorage.setItem(QUEUE_KEY, JSON.stringify(episodes));
|
||||||
|
sessionStorage.setItem(SERIES_KEY, seriesName);
|
||||||
|
sessionStorage.setItem(IDX_KEY, '0');
|
||||||
|
window.location.href = episodes[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 큐 처리: 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(2000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageUrls = await getImagesWithLazyLoad();
|
||||||
|
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 { isListPage, isEpisodePage } = getListPageInfo();
|
const next = idx + 1;
|
||||||
|
sessionStorage.setItem(IDX_KEY, String(next));
|
||||||
|
|
||||||
if (isListPage) {
|
if (next < queue.length) {
|
||||||
GM_registerMenuCommand('[목록] 전체 에피소드 다운로드', () => downloadAllEpisodes());
|
log(`${next + 1}/${queue.length}번째로 이동...`);
|
||||||
console.log('sbxh6Downloader: 목록 페이지 감지됨. Tampermonkey 메뉴에서 실행하세요.');
|
await sleep(2000);
|
||||||
} else if (isEpisodePage) {
|
window.location.href = queue[next].url;
|
||||||
GM_registerMenuCommand('현재 에피소드 다운로드', () => downloadCurrentEpisode());
|
} else {
|
||||||
console.log('sbxh6Downloader: 에피소드 페이지 감지됨. Tampermonkey 메뉴에서 실행하세요.');
|
cleanupQueue();
|
||||||
} else {
|
log('모든 에피소드 다운로드 완료!');
|
||||||
console.log('sbxh6Downloader: 알 수 없는 페이지 타입');
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
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');
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user