Compare commits
4 Commits
709ad4b620
...
cd74a9d42a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd74a9d42a | ||
|
|
cc360bc5ef | ||
|
|
31f591605d | ||
|
|
beb8b793dd |
@@ -1,11 +1,11 @@
|
||||
// ==UserScript==
|
||||
// @name sbxh6Downloader
|
||||
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
|
||||
// @version 0.1.1
|
||||
// @version 0.1.3
|
||||
// @description sbxh6.com (뉴토끼) 만화 다운로더
|
||||
// @author hehaho
|
||||
// @match https://sbxh*.com/manhwa/*
|
||||
// @match https://sbxh*.com/manhwa/*/*
|
||||
// @match https://sbxh9.com/manhwa/*
|
||||
// @match https://sbxh9.com/manhwa/*/*
|
||||
// @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad
|
||||
// @grant GM_registerMenuCommand
|
||||
// @grant GM_unregisterMenuCommand
|
||||
@@ -16,18 +16,24 @@
|
||||
// @run-at document-start
|
||||
// @license MIT
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// 페이지가 setTimeout을 오버라이드해도 영향받지 않도록 원본 캡처
|
||||
const _setTimeout = window.setTimeout.bind(window);
|
||||
const sleep = (ms) => new Promise(r => _setTimeout(r, ms));
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Phase 1: IntersectionObserver 후킹 (document-start 에서 즉시 실행)
|
||||
// 백그라운드 탭에서 IO가 동작하지 않아도 observe() 즉시 콜백 실행
|
||||
// Phase 1: 문서 표시 상태 강제 + IntersectionObserver 후킹
|
||||
// ---------------------------------------------------------------
|
||||
// document.hidden 강제 false (배경 탭에서도 페이지가 활성화된 것으로 인식)
|
||||
// document/visibility 강제 활성화 (페이지 JS가 덮어써도 주기적 복원)
|
||||
const forceVisible = () => {
|
||||
try {
|
||||
Object.defineProperty(document, 'hidden', { get: () => false, configurable: true });
|
||||
Object.defineProperty(document, 'visibilityState', { get: () => 'visible', configurable: true });
|
||||
['webkitHidden', 'mozHidden', 'msHidden'].forEach(p => {
|
||||
try { Object.defineProperty(document, p, { get: () => false, configurable: true }); } catch (_) {}
|
||||
});
|
||||
} catch (_) {}
|
||||
try {
|
||||
const proto = Document.prototype;
|
||||
@@ -36,6 +42,13 @@
|
||||
Object.defineProperty(proto, 'visibilityState', { get: () => 'visible', configurable: true });
|
||||
}
|
||||
} catch (_) {}
|
||||
try { document.hasFocus = () => true; } catch (_) {}
|
||||
};
|
||||
forceVisible();
|
||||
(function refreshVisible() {
|
||||
forceVisible();
|
||||
_setTimeout(refreshVisible, 500);
|
||||
})();
|
||||
|
||||
// IntersectionObserver 후킹: observe() 즉시 콜백 실행
|
||||
const OrigIO = window.IntersectionObserver;
|
||||
@@ -45,7 +58,7 @@
|
||||
const origObserve = instance.observe.bind(instance);
|
||||
instance.observe = function (target) {
|
||||
origObserve(target);
|
||||
setTimeout(() => {
|
||||
_setTimeout(() => {
|
||||
try {
|
||||
callback([{
|
||||
target: target,
|
||||
@@ -69,10 +82,38 @@
|
||||
// Phase 2: DOM 로딩 후 나머지 로직
|
||||
// ---------------------------------------------------------------
|
||||
const main = () => {
|
||||
// 사이트가 console을 모니터링/차단하므로 자체 로거 사용
|
||||
const log = (...args) => {
|
||||
try { console.log(...args); } catch (_) {}
|
||||
// ---------------------------------------------------------------
|
||||
// 로깅 시스템 (sessionStorage 기반, 페이지 이동 시에도 유지)
|
||||
// ---------------------------------------------------------------
|
||||
const LOG_KEY2 = 'sbxh6_log';
|
||||
|
||||
const getLog = () => {
|
||||
try { return JSON.parse(sessionStorage.getItem(LOG_KEY2) || '[]'); } catch (_) { return []; }
|
||||
};
|
||||
const appendLog = (...args) => {
|
||||
const msg = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
||||
const entry = `[${new Date().toISOString().slice(11,23)}] ${msg}`;
|
||||
try { console.log(...args); } catch (_) {}
|
||||
try {
|
||||
const buf = getLog();
|
||||
buf.push(entry);
|
||||
if (buf.length > 500) buf.splice(0, buf.length - 500);
|
||||
sessionStorage.setItem(LOG_KEY2, JSON.stringify(buf));
|
||||
} catch (_) {}
|
||||
};
|
||||
const downloadLog = () => {
|
||||
const buf = getLog();
|
||||
if (buf.length === 0) { alert('로그가 없습니다.'); return; }
|
||||
const text = buf.join('\n');
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `download_log_${Date.now()}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const log = appendLog;
|
||||
|
||||
// 메뉴 명령 ID 관리 (중복 등록 방지 → 배지 숫자 증가 방지)
|
||||
let menuCmdIds = [];
|
||||
@@ -88,8 +129,10 @@
|
||||
menuCmdIds.push(GM_registerMenuCommand('[목록] 전체 에피소드 다운로드', () => downloadAllEpisodes()));
|
||||
menuCmdIds.push(GM_registerMenuCommand('[목록] n화 이후 다운로드', () => downloadAfterEpisode()));
|
||||
menuCmdIds.push(GM_registerMenuCommand('[목록] 범위 다운로드', () => downloadRangeEpisodes()));
|
||||
menuCmdIds.push(GM_registerMenuCommand('[목록] 로그 다운로드', () => downloadLog()));
|
||||
} else if (isEpisodePage) {
|
||||
menuCmdIds.push(GM_registerMenuCommand('현재 에피소드 다운로드', () => downloadCurrentEpisode()));
|
||||
menuCmdIds.push(GM_registerMenuCommand('로그 다운로드', () => downloadLog()));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,10 +160,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// 자동 연속 다운로드 큐 (sessionStorage 기반)
|
||||
const QUEUE_KEY = 'sbxh6_q';
|
||||
const IDX_KEY = 'sbxh6_i';
|
||||
@@ -180,103 +219,47 @@
|
||||
return comicInfo;
|
||||
}
|
||||
|
||||
const waitForViewerReady = async (timeout = 12000) => {
|
||||
const viewerSelectors = [
|
||||
'.vw-imgs',
|
||||
'.vw-imgs--double',
|
||||
'.vw-imgs--single',
|
||||
'.webtoon-page',
|
||||
'.manhwa-page',
|
||||
'[class*="vw-imgs"]',
|
||||
'.viewer',
|
||||
'[role="main"]'
|
||||
];
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
for (const selector of viewerSelectors) {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) return el;
|
||||
}
|
||||
const imgs = Array.from(document.querySelectorAll('img')).filter(img => img.naturalWidth >= 120 && img.naturalHeight >= 120);
|
||||
if (imgs.length > 0) return document;
|
||||
await sleep(300);
|
||||
}
|
||||
return document;
|
||||
};
|
||||
|
||||
function getEpisodeImages(container) {
|
||||
const root = container || document;
|
||||
// 만화/웹툰 뷰어 영역에서만 이미지 수집
|
||||
const viewerSelectors = [
|
||||
'.vw-imgs',
|
||||
'.vw-imgs--double',
|
||||
'.vw-imgs--single',
|
||||
'.webtoon-page',
|
||||
'.manhwa-page',
|
||||
'[class*="vw-imgs"]',
|
||||
'.viewer'
|
||||
];
|
||||
|
||||
let imgs = [];
|
||||
for (const selector of viewerSelectors) {
|
||||
try {
|
||||
const containers = root.querySelectorAll(selector);
|
||||
if (containers.length > 0) {
|
||||
containers.forEach(containerEl => {
|
||||
imgs.push(...Array.from(containerEl.querySelectorAll('img')));
|
||||
});
|
||||
if (imgs.length > 0) break;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 찾지 못했으면 전체 img에서 필터링
|
||||
if (imgs.length === 0) {
|
||||
imgs = Array.from(root.querySelectorAll('img')).filter(img => {
|
||||
const rect = img.getBoundingClientRect();
|
||||
return rect.height > 50 && rect.width > 50;
|
||||
});
|
||||
}
|
||||
|
||||
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') || img.getAttribute('data-src') || '';
|
||||
const src = img.src || img.getAttribute('src') || '';
|
||||
if (src && src.startsWith('http') && !seen.has(src)) {
|
||||
const lowerSrc = src.toLowerCase();
|
||||
if (!lowerSrc.includes('avatar') &&
|
||||
!lowerSrc.includes('logo') &&
|
||||
!lowerSrc.includes('icon') &&
|
||||
!lowerSrc.includes('banner') &&
|
||||
!lowerSrc.includes('nav') &&
|
||||
!lowerSrc.includes('header') &&
|
||||
!lowerSrc.includes('footer') &&
|
||||
!lowerSrc.includes('.svg') &&
|
||||
!lowerSrc.includes('placeholder')) {
|
||||
seen.add(src);
|
||||
urls.push(src);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (urls.length < 5) {
|
||||
log(`getEpisodeImages: img=${imgs.length} urls=${urls.length} (소수만 발견)`);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function getSeriesName() {
|
||||
let seriesName = '제목없음';
|
||||
let writer = '';
|
||||
|
||||
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();
|
||||
}
|
||||
let rawTitle = '';
|
||||
|
||||
if (metaEl) {
|
||||
rawTitle = metaEl.textContent.trim();
|
||||
} else {
|
||||
const ogTitle = document.querySelector('meta[property="og:title"]');
|
||||
if (ogTitle) {
|
||||
const content = ogTitle.getAttribute('content') || '';
|
||||
return content.split('|')[0].trim() || content;
|
||||
rawTitle = (ogTitle ? ogTitle.getAttribute('content') || '' : document.title).split('|')[0].trim();
|
||||
}
|
||||
|
||||
return document.title.split('|')[0].trim() || '제목없음';
|
||||
const parts = rawTitle.split(/\s-\s/);
|
||||
seriesName = parts[0] || rawTitle;
|
||||
if (parts.length > 1) writer = parts[1];
|
||||
|
||||
return { seriesName, writer };
|
||||
}
|
||||
|
||||
function getEpisodeTitle() {
|
||||
@@ -300,167 +283,263 @@
|
||||
return { isListPage, isEpisodePage };
|
||||
}
|
||||
|
||||
// 이미지의 data-* 속성을 src로 강제 복사
|
||||
// 모든 img의 data-* 속성을 src로 강제 복사 (알려진 속성명 우선, 이후 모든 data-* 검사)
|
||||
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 () => {
|
||||
const viewer = document.querySelector('.vw-imgs, [class*="vw-imgs"], .viewer') || document.body;
|
||||
forceLoadImages(viewer);
|
||||
|
||||
let prevHeight = 0;
|
||||
let stuckCount = 0;
|
||||
let maxAttempts = 100; // 더 많은 시도
|
||||
let attemptCount = 0;
|
||||
|
||||
while (stuckCount < 10 && attemptCount < maxAttempts) {
|
||||
attemptCount++;
|
||||
const maxScroll = Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight,
|
||||
viewer.scrollHeight,
|
||||
0
|
||||
);
|
||||
|
||||
log(`[${attemptCount}] 높이: ${maxScroll}px (이전: ${prevHeight}px)`);
|
||||
|
||||
if (maxScroll === prevHeight) {
|
||||
stuckCount++;
|
||||
log(`정체 카운트: ${stuckCount}/10`);
|
||||
} else {
|
||||
stuckCount = 0;
|
||||
log(`새 콘텐츠 감지!`);
|
||||
}
|
||||
prevHeight = maxScroll;
|
||||
|
||||
// 작은 step으로 천천히 스크롤 (모든 이미지 트리거)
|
||||
const step = Math.max(50, Math.min(100, maxScroll / 20)); // 50-100px 범위로 조정
|
||||
let currentPos = window.scrollY;
|
||||
|
||||
while (currentPos < maxScroll) {
|
||||
window.scrollTo(0, currentPos);
|
||||
await sleep(50); // 더 빠른 반응
|
||||
currentPos += step;
|
||||
}
|
||||
|
||||
// 정확히 끝까지
|
||||
window.scrollTo(0, maxScroll);
|
||||
await sleep(150);
|
||||
|
||||
// 중간중간 강제 로딩
|
||||
forceLoadImages(viewer);
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
// 최종 확인: 절대 끝까지
|
||||
const finalHeight = Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight,
|
||||
viewer.scrollHeight
|
||||
);
|
||||
log(`최종 높이: ${finalHeight}px - 끝까지 스크롤`);
|
||||
window.scrollTo(0, finalHeight);
|
||||
await sleep(250);
|
||||
forceLoadImages(viewer);
|
||||
await sleep(250);
|
||||
window.scrollTo(0, finalHeight);
|
||||
await sleep(375);
|
||||
|
||||
log('스크롤 완료');
|
||||
};
|
||||
|
||||
// 헬퍼: 시리즈명에서 작가 분리 ("시리즈 - 작가" 형식 → {series, writer})
|
||||
function extractSeriesAndWriter(fullSeriesName) {
|
||||
const parts = fullSeriesName.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
series: parts[0].trim(),
|
||||
writer: parts[1].trim()
|
||||
};
|
||||
}
|
||||
return { series: fullSeriesName, writer: '' };
|
||||
}
|
||||
|
||||
// 공통: 이미지 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);
|
||||
}
|
||||
|
||||
// ComicInfo: 시리즈와 작가 분리, Title은 "시리즈명 + 에피소드제목"으로 조합
|
||||
const { series: cleanSeries, writer } = extractSeriesAndWriter(seriesName);
|
||||
const fullTitle = `${cleanSeries} ${episodeTitle}`;
|
||||
const comicInfo = generateComicInfo(fullTitle, cleanSeries, writer, episodeNumber, imageUrls.length, imageUrls.length);
|
||||
zip.file('ComicInfo.xml', comicInfo);
|
||||
|
||||
// ZIP 파일명은 원본 그대로 유지
|
||||
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 () => {
|
||||
const viewer = await waitForViewerReady();
|
||||
let urls = getEpisodeImages(viewer);
|
||||
log(`초기 이미지 감지: ${urls.length}개`);
|
||||
|
||||
if (urls.length === 0) {
|
||||
log('이미지 없음 - 0.3초 대기 후 재시도...');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await sleep(300);
|
||||
urls = getEpisodeImages(viewer);
|
||||
if (urls.length > 0) {
|
||||
log(`${i+1}번 시도 후 ${urls.length}개 감지`);
|
||||
// 그래도 src가 없으면 모든 data-* 속성 중 http로 시작하는 값 사용
|
||||
if (!img.src || !img.src.startsWith('http')) {
|
||||
for (const attr of img.attributes) {
|
||||
if (attr.name.startsWith('data-') && attr.value && attr.value.startsWith('http')) {
|
||||
img.src = attr.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 실제 만화 이미지가 충분하지 않으면 lazy loading 트리거
|
||||
const hasLargeImages = urls.some(src => src.match(/\.(jpe?g|png|webp|gif)(\?|$)/i));
|
||||
if (urls.length <= 8 || !hasLargeImages) {
|
||||
log(`lazy loading 트리거 (${urls.length}개 → 전체)...`);
|
||||
await triggerLazyLoading();
|
||||
urls = getEpisodeImages(viewer);
|
||||
log(`스크롤 후 감지: ${urls.length}개`);
|
||||
// 강제 scroll 이벤트 디스패치
|
||||
const fireScroll = () => {
|
||||
window.dispatchEvent(new Event('scroll', { bubbles: true }));
|
||||
window.dispatchEvent(new UIEvent('scroll', { detail: 0 }));
|
||||
};
|
||||
|
||||
// 디버그: .vw-imgs 영역 HTML 구조 로그
|
||||
const debugDumpViewer = (label) => {
|
||||
try {
|
||||
const viewer = document.querySelector('.vw-imgs, [class*="vw-imgs"]');
|
||||
if (!viewer) { log(`${label}: viewer 없음`); return; }
|
||||
const children = viewer.children;
|
||||
let info = `${label}: childCount=${children.length}`;
|
||||
for (let i = 0; i < Math.min(children.length, 5); i++) {
|
||||
const c = children[i];
|
||||
info += ` | [${i}] <${c.tagName}> class="${c.className}" style="${c.getAttribute('style') || ''}"`;
|
||||
if (c.tagName === 'IMG') {
|
||||
info += ` src="${c.src || '(none)'}" data-src="${c.getAttribute('data-src') || ''}"`;
|
||||
}
|
||||
}
|
||||
log(info);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
// lazy loading 트리거: data-src 복사 + loading=eager + 스크롤
|
||||
const triggerLazyLoading = async (doc, win) => {
|
||||
const d = doc || document;
|
||||
const w = win || window;
|
||||
log(`triggerLazyLoading: scrollY=${w.scrollY}, hidden=${document.hidden}, innerH=${window.innerHeight}`);
|
||||
debugDumpViewer('triggerLazyLoading 시작');
|
||||
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
|
||||
);
|
||||
log(`triggerLazyLoading: scroll=${maxScroll}, stuck=${stuckCount}, imgs=${viewer.querySelectorAll('img').length}`);
|
||||
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));
|
||||
fireScroll();
|
||||
_setTimeout(fireScroll, 10);
|
||||
await sleep(300);
|
||||
}
|
||||
w.scrollTo(0, maxScroll);
|
||||
fireScroll();
|
||||
forceLoadImages(viewer);
|
||||
await sleep(1500);
|
||||
}
|
||||
forceLoadImages(viewer);
|
||||
await sleep(2000);
|
||||
w.scrollTo(0, 0);
|
||||
fireScroll();
|
||||
debugDumpViewer('triggerLazyLoading 완료');
|
||||
log(`triggerLazyLoading: 완료, 최종 imgs=${viewer.querySelectorAll('img').length}`);
|
||||
await sleep(1000);
|
||||
};
|
||||
|
||||
// viewer 내 이미지 슬롯 전체 개수 (img + 미로딩 placeholder div)
|
||||
const countTotalImgSlots = (root) => {
|
||||
const container = root || document;
|
||||
const viewers = container.querySelectorAll(
|
||||
'.vw-imgs, .vw-imgs--double, .vw-imgs--single, ' +
|
||||
'.webtoon-page, .manhwa-page, [class*="vw-imgs"]'
|
||||
);
|
||||
if (viewers.length === 0) return 0;
|
||||
let total = 0;
|
||||
viewers.forEach(v => {
|
||||
total += v.querySelectorAll('img').length;
|
||||
total += v.querySelectorAll(
|
||||
'div[style*="aspect-ratio"], ' +
|
||||
'div[class*="placeholder"], ' +
|
||||
'div[class*="lazy"], ' +
|
||||
'div.img-item, ' +
|
||||
'div[class*="img-wrap"]'
|
||||
).length;
|
||||
});
|
||||
return total;
|
||||
};
|
||||
|
||||
// 첫 번째 img의 URL 패턴으로 모든 이미지 URL 생성 (lazy 로딩 우회)
|
||||
const generateEpisodeUrls = () => {
|
||||
try {
|
||||
const viewer = document.querySelector('.vw-imgs, [class*="vw-imgs"]');
|
||||
if (!viewer) { log('generateUrls: viewer 없음'); return null; }
|
||||
const totalSlots = countTotalImgSlots();
|
||||
if (totalSlots === 0) { log('generateUrls: totalSlots=0'); return null; }
|
||||
|
||||
const firstImg = viewer.querySelector('img');
|
||||
if (!firstImg) { log('generateUrls: img 없음'); return null; }
|
||||
|
||||
const src = firstImg.src || firstImg.getAttribute('src') || '';
|
||||
if (!src || !src.startsWith('http')) { log('generateUrls: 유효한 src 없음'); return null; }
|
||||
|
||||
// URL 패턴: /p001.jpg → base 제거
|
||||
const baseUrl = src.replace(/\/p\d+\.[a-z]+(?:\?.*)?$/i, '/');
|
||||
const extMatch = src.match(/\.([a-z]+)(?:\?.*)?$/i);
|
||||
const ext = extMatch ? extMatch[1] : 'jpg';
|
||||
const numMatch = src.match(/p(\d+)\.[a-z]+/i);
|
||||
const numDigits = numMatch ? numMatch[1].length : 3;
|
||||
|
||||
const urls = [];
|
||||
for (let i = 1; i <= totalSlots; i++) {
|
||||
urls.push(`${baseUrl}p${String(i).padStart(numDigits, '0')}.${ext}`);
|
||||
}
|
||||
log(`generateUrls: ${baseUrl} → ${urls.length}개 생성`);
|
||||
return urls;
|
||||
} catch (e) {
|
||||
log('generateUrls 오류:', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 공통: 현재 페이지에서 이미지 획득 + lazy 로딩 트리거 + URL 생성 fallback
|
||||
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;
|
||||
}
|
||||
}
|
||||
const prevLen = urls.length;
|
||||
debugDumpViewer('getImagesWithLazyLoad 시작');
|
||||
// 우선 페이지 끝까지 fast scroll 1회
|
||||
const maxScroll = Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight, 0
|
||||
);
|
||||
const hasContent = maxScroll > window.innerHeight * 1.2;
|
||||
if (hasContent) {
|
||||
log(`getImagesWithLazyLoad: scrollable(${maxScroll}px) → fast scroll`);
|
||||
window.scrollTo(0, maxScroll);
|
||||
fireScroll();
|
||||
_setTimeout(fireScroll, 10);
|
||||
await sleep(2000);
|
||||
forceLoadImages();
|
||||
urls = getEpisodeImages();
|
||||
log(`getImagesWithLazyLoad: fast scroll 후 ${urls.length}개`);
|
||||
}
|
||||
// 부족하면 step-by-step lazy 로딩 (최대 3회)
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const totalSlots = countTotalImgSlots();
|
||||
const stillNeed = (
|
||||
(totalSlots > 0 && urls.length < totalSlots) ||
|
||||
urls.length <= 3 ||
|
||||
(prevLen > 0 && urls.length < prevLen * 0.5)
|
||||
);
|
||||
log(`getImagesWithLazyLoad: loaded=${urls.length}, totalSlots=${totalSlots}, need=${stillNeed} (attempt ${attempt + 1})`);
|
||||
if (!stillNeed) break;
|
||||
log(`getImagesWithLazyLoad: ${urls.length}개 → step scroll (attempt ${attempt + 1})`);
|
||||
await triggerLazyLoading();
|
||||
const newUrls = getEpisodeImages();
|
||||
if (newUrls.length <= urls.length) break;
|
||||
urls = newUrls;
|
||||
}
|
||||
// 모든 data-* 속성 강제 검사
|
||||
forceLoadImages();
|
||||
urls = getEpisodeImages();
|
||||
// 3개 이하면 HTML 덤프
|
||||
if (urls.length <= 3) debugDumpViewer('getImagesWithLazyLoad 최종');
|
||||
// 그래도 부족하면 URL 패턴으로 직접 생성 (fallback)
|
||||
const totalSlots = countTotalImgSlots();
|
||||
if (urls.length < totalSlots && totalSlots > 3) {
|
||||
const generated = generateEpisodeUrls();
|
||||
if (generated && generated.length > urls.length) {
|
||||
log(`getImagesWithLazyLoad: URL 생성 fallback → ${generated.length}개 (원래 ${urls.length}개)`);
|
||||
urls = generated;
|
||||
}
|
||||
}
|
||||
log(`getImagesWithLazyLoad: 최종 ${urls.length}개`);
|
||||
return urls;
|
||||
};
|
||||
|
||||
async function downloadImagesAsZip(imageUrls, seriesInfo, episodeTitle, episodeNumber) {
|
||||
const zip = new JSZip();
|
||||
const folderName = `${seriesInfo.seriesName}_${episodeNumber}`.replace(/[<>:"/\\|?*]/g, '_').slice(0, 200);
|
||||
const folder = zip.folder(folderName);
|
||||
|
||||
const fetchImage = (url) => new Promise((resolve, reject) => {
|
||||
GM_xmlhttpRequest({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
responseType: 'blob',
|
||||
headers: { 'Referer': window.location.origin + '/' },
|
||||
onload: (res) => {
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
resolve(res.response);
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.status}: ${url}`));
|
||||
}
|
||||
},
|
||||
onerror: (err) => reject(new Error(`GM_xhr 실패: ${url}`))
|
||||
});
|
||||
});
|
||||
|
||||
const total = imageUrls.length;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const url = imageUrls[i];
|
||||
const ext = url.match(/\.(jpe?g|png|webp|gif|avif)/i)?.[1] || 'jpg';
|
||||
const filename = `p${String(i + 1).padStart(3, '0')}.${ext}`;
|
||||
try {
|
||||
const blob = await fetchImage(url);
|
||||
folder.file(filename, blob, { binary: true });
|
||||
} catch (e) {
|
||||
log(`downloadImagesAsZip: ${filename} 실패 - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const comicInfoXml = generateComicInfo(
|
||||
episodeTitle,
|
||||
seriesInfo.seriesName,
|
||||
seriesInfo.writer,
|
||||
episodeNumber,
|
||||
total,
|
||||
total
|
||||
);
|
||||
folder.file('ComicInfo.xml', comicInfoXml);
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const safeName = `${seriesInfo.seriesName}_${episodeNumber}.zip`.replace(/[<>:"/\\|?*]/g, '_');
|
||||
downloadZip(zipBlob, safeName);
|
||||
log(`downloadImagesAsZip: ${safeName} 저장 완료 (${total}개 중 ${folder.files ? Object.keys(folder.files).length - 1 : '?'}개 성공)`);
|
||||
}
|
||||
|
||||
async function downloadCurrentEpisode() {
|
||||
try {
|
||||
log('에피소드 다운로드 시작...');
|
||||
@@ -471,11 +550,14 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const seriesName = getSeriesName();
|
||||
const episodeTitle = getEpisodeTitle();
|
||||
log(`시리즈: ${seriesName}, 에피소드: ${episodeTitle}, 이미지: ${imageUrls.length}개`);
|
||||
const seriesInfo = getSeriesName();
|
||||
const episodeTitleText = getEpisodeTitle();
|
||||
const episodeTitle = `${seriesInfo.seriesName} ${episodeTitleText}`;
|
||||
const episodeNumber = episodeTitleText.match(/\d+/)?.[0] || '1';
|
||||
|
||||
await downloadImagesAsZip(imageUrls, seriesName, episodeTitle, '1');
|
||||
log(`시리즈: ${seriesInfo.seriesName}, 작가: ${seriesInfo.writer}, 에피소드: ${episodeTitle}, 이미지: ${imageUrls.length}개`);
|
||||
|
||||
await downloadImagesAsZip(imageUrls, seriesInfo, episodeTitle, episodeNumber);
|
||||
log('완료');
|
||||
} catch (error) {
|
||||
alert('다운로드 중 오류 발생:\n' + error);
|
||||
@@ -510,7 +592,7 @@
|
||||
const startQueue = (episodes, seriesName) => {
|
||||
log(`${episodes.length}개 에피소드를 큐에 저장, 첫 화로 이동합니다...`);
|
||||
sessionStorage.setItem(QUEUE_KEY, JSON.stringify(episodes));
|
||||
sessionStorage.setItem(SERIES_KEY, seriesName);
|
||||
sessionStorage.setItem(SERIES_KEY, JSON.stringify(seriesName));
|
||||
sessionStorage.setItem(IDX_KEY, '0');
|
||||
sessionStorage.setItem(LIST_KEY, window.location.href);
|
||||
window.location.href = episodes[0].url;
|
||||
@@ -520,8 +602,8 @@
|
||||
log('목록 페이지 처리 시작');
|
||||
const episodes = getEpisodesFromList();
|
||||
if (!episodes) { alert('에피소드 목록을 찾을 수 없습니다.'); return; }
|
||||
const seriesName = getSeriesName();
|
||||
startQueue(episodes, seriesName);
|
||||
const seriesInfo = getSeriesName();
|
||||
startQueue(episodes, seriesInfo);
|
||||
}
|
||||
|
||||
// n화 이후 다운로드
|
||||
@@ -540,8 +622,8 @@
|
||||
.sort((a, b) => parseFloat(a.number) - parseFloat(b.number));
|
||||
|
||||
if (filtered.length === 0) { alert('조건에 맞는 에피소드가 없습니다.'); return; }
|
||||
const seriesName = getSeriesName();
|
||||
startQueue(filtered, seriesName);
|
||||
const seriesInfo = getSeriesName();
|
||||
startQueue(filtered, seriesInfo);
|
||||
}
|
||||
|
||||
// 범위 다운로드
|
||||
@@ -568,8 +650,8 @@
|
||||
.sort((a, b) => parseFloat(a.number) - parseFloat(b.number));
|
||||
|
||||
if (filtered.length === 0) { alert('조건에 맞는 에피소드가 없습니다.'); return; }
|
||||
const seriesName = getSeriesName();
|
||||
startQueue(filtered, seriesName);
|
||||
const seriesInfo = getSeriesName();
|
||||
startQueue(filtered, seriesInfo);
|
||||
}
|
||||
|
||||
// 자동 큐 처리: sessionStorage에 큐가 있으면 현재 화면을 다운로드 후 다음으로 이동
|
||||
@@ -577,20 +659,23 @@
|
||||
const queue = getQueue();
|
||||
if (!queue) return;
|
||||
const { isEpisodePage } = getListPageInfo();
|
||||
if (!isEpisodePage) return; // 목록 페이지에서는 큐 처리 안 함
|
||||
if (!isEpisodePage) return;
|
||||
|
||||
const idx = getQueueIndex();
|
||||
if (idx >= queue.length) { cleanupQueue(); return; }
|
||||
|
||||
log(`자동 다운로드: ${idx + 1}/${queue.length} - ${queue[idx].title}`);
|
||||
await sleep(7000);
|
||||
await sleep(5000);
|
||||
|
||||
try {
|
||||
const imageUrls = await getImagesWithLazyLoad();
|
||||
log(`자동 다운로드: ${idx + 1}/${queue.length} - ${queue[idx].title}, 이미지 ${imageUrls.length}개`);
|
||||
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);
|
||||
const seriesInfoStr = sessionStorage.getItem(SERIES_KEY);
|
||||
const seriesInfo = seriesInfoStr ? JSON.parse(seriesInfoStr) : getSeriesName();
|
||||
const episodeTitleText = getEpisodeTitle() || queue[idx].title;
|
||||
const fullEpisodeTitle = `${seriesInfo.seriesName} ${episodeTitleText}`;
|
||||
await downloadImagesAsZip(imageUrls, seriesInfo, fullEpisodeTitle, queue[idx].number);
|
||||
} else {
|
||||
log(`이미지 없음 - 스킵`);
|
||||
}
|
||||
@@ -620,20 +705,20 @@
|
||||
registerMenu();
|
||||
|
||||
// 자동 다운로드 큐 확인 (목록→에피소드 이동 시)
|
||||
setTimeout(processAutoQueue, 1000);
|
||||
_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);
|
||||
_setTimeout(registerMenu, 500);
|
||||
};
|
||||
history.replaceState = function () {
|
||||
origReplaceState.apply(this, arguments);
|
||||
setTimeout(registerMenu, 500);
|
||||
_setTimeout(registerMenu, 500);
|
||||
};
|
||||
window.addEventListener('popstate', () => setTimeout(registerMenu, 500));
|
||||
window.addEventListener('popstate', () => _setTimeout(registerMenu, 500));
|
||||
|
||||
log('sbxh6Downloader loaded');
|
||||
};
|
||||
@@ -645,3 +730,4 @@
|
||||
main();
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user