1055 lines
45 KiB
JavaScript
1055 lines
45 KiB
JavaScript
// ==UserScript==
|
||
// @name Ntoki Downloader
|
||
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
|
||
// @version 0.2.0
|
||
// @description 뉴토끼(ntk01.com, sbxh*.com) 웹툰/만화 다운로더 (팝업 방식, JavaScript 렌더링 필요)
|
||
// @author hehaho
|
||
// @match https://ntk01.com/*
|
||
// @match https://www.ntk01.com/*
|
||
// @match https://sbxh1.com/*
|
||
// @match https://sbxh2.com/*
|
||
// @match https://sbxh3.com/*
|
||
// @match https://sbxh4.com/*
|
||
// @match https://sbxh5.com/*
|
||
// @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-end
|
||
// @license MIT
|
||
// ==/UserScript==
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
const LOG = [];
|
||
const log = (...args) => {
|
||
const msg = args.map(a => {
|
||
if (a instanceof Error) return a.message;
|
||
if (typeof a === 'object' && a !== null) {
|
||
try { return JSON.stringify(a); } catch(e) { return String(a); }
|
||
}
|
||
return String(a);
|
||
}).join(' ');
|
||
LOG.push(`[${new Date().toISOString().slice(11,19)}] ${msg}`);
|
||
};
|
||
|
||
const currentURL = document.URL;
|
||
|
||
log(`🚀 Ntoki Downloader v0.2.0 시작됨`);
|
||
log(`📍 URL: ${currentURL}`);
|
||
log(`📍 호스트: ${new URL(currentURL).hostname}`);
|
||
|
||
// 알려진 웹툰 이미지 CDN 도메인 (화이트리스트)
|
||
const ALLOWED_IMAGE_DOMAINS = ['i.toonflix.app', 'img.ntk01.com', '11toon8.com', 'sbxh1.com', 'sbxh2.com', 'sbxh3.com'];
|
||
|
||
const isAllowedImageHost = (url) => {
|
||
try {
|
||
const absoluteUrl = url.startsWith('http://') || url.startsWith('https://')
|
||
? url
|
||
: new URL(url, currentURL).href;
|
||
const hostname = new URL(absoluteUrl).hostname;
|
||
const pageHost = new URL(currentURL).hostname;
|
||
if (hostname === pageHost || hostname.endsWith('.' + pageHost)) return true;
|
||
return ALLOWED_IMAGE_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d));
|
||
} catch(e) {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// 이미지 해상도 기반 검증 (만화 페이지는 세로>가로, 최소 500px)
|
||
const isComicPageBlob = (blob) => {
|
||
return createImageBitmap(blob).then(bitmap => {
|
||
// 배너만 필터: 가로가 세로의 2배 이상(전형적인 배너) 또는 높이 200px 미만
|
||
const valid = !(bitmap.width > bitmap.height * 2 || bitmap.height < 200);
|
||
bitmap.close();
|
||
return valid;
|
||
}).catch(() => true);
|
||
};
|
||
|
||
// 이미지 로딩을 기다리고 lazy load 트리거
|
||
const waitForImagesToLoad = (targetDocument, timeoutMs = 20000) => {
|
||
return new Promise(resolve => {
|
||
const container = targetDocument.querySelector('.vw-imgs, .vw-images, #bo_v_con, .view-content, #toon_img, .board-view') || targetDocument.body;
|
||
const allImages = container.querySelectorAll('img');
|
||
const baseUrl = targetDocument.URL;
|
||
allImages.forEach(img => {
|
||
if (img.getAttribute('loading') === 'lazy') {
|
||
img.setAttribute('loading', 'eager');
|
||
}
|
||
['data-src', 'data-original', 'data-lazy-src', 'data-lazy'].forEach(attr => {
|
||
const val = img.getAttribute(attr);
|
||
if (val && !img.src) {
|
||
try {
|
||
img.src = new URL(val, baseUrl).href;
|
||
} catch {}
|
||
}
|
||
});
|
||
});
|
||
const loading = Array.from(allImages).filter(img => !img.complete || img.naturalWidth === 0);
|
||
if (loading.length === 0) { resolve(); return; }
|
||
let remaining = loading.length;
|
||
const timeout = setTimeout(resolve, timeoutMs);
|
||
loading.forEach(img => {
|
||
const done = () => {
|
||
remaining--;
|
||
if (remaining <= 0) { clearTimeout(timeout); resolve(); }
|
||
};
|
||
if (img.complete && img.naturalWidth > 0) done();
|
||
else {
|
||
img.addEventListener('load', done, { once: true });
|
||
img.addEventListener('error', done, { once: true });
|
||
}
|
||
});
|
||
});
|
||
};
|
||
|
||
// 페이지 타입 감지
|
||
const pathMatch = currentURL.match(/\/manhwa\/([^/]+)(?:\/(\d+))?/);
|
||
const isEpisodePage = pathMatch && pathMatch[2] ? true : false;
|
||
const isListPage = pathMatch && !pathMatch[2] ? true : false;
|
||
|
||
// 파일 다운로드 헬퍼
|
||
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);
|
||
}
|
||
};
|
||
|
||
const downloadLogFile = () => { return; // 비활성화
|
||
const now = new Date();
|
||
const time = String(now.getHours()).padStart(2,'0') + String(now.getMinutes()).padStart(2,'0') + String(now.getSeconds()).padStart(2,'0');
|
||
const content = LOG.join('\n');
|
||
if (!content) return;
|
||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `Download_${time}.log`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
// XML 특수문자 이스케이프
|
||
const escapeXml = (str) => {
|
||
if (!str) return '';
|
||
return str
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
};
|
||
|
||
// comicinfo.xml 생성
|
||
const generateComicInfoXml = (title, seriesTitle = '', episodeNumber = '', writer = '', imageCount) => {
|
||
const comicInfo = `<?xml version="1.0" encoding="utf-8"?>
|
||
<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||
<Title>${escapeXml(title)}</Title>
|
||
<Series>${escapeXml(seriesTitle)}</Series>
|
||
<Number>${escapeXml(episodeNumber)}</Number>
|
||
<Writer>${escapeXml(writer)}</Writer>
|
||
<PageCount>${imageCount}</PageCount>
|
||
<LanguageISO>ko</LanguageISO>
|
||
<Format>Manga</Format>
|
||
</ComicInfo>`;
|
||
return comicInfo;
|
||
};
|
||
|
||
// JSON-LD에서 정보 추출 (말줄임표 없는 완전한 제목)
|
||
const getJsonLdData = () => {
|
||
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
|
||
for (const script of scripts) {
|
||
try {
|
||
const data = JSON.parse(script.textContent);
|
||
if (data) return data;
|
||
} catch (e) {}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// DOM에서 정보 추출
|
||
const getSeriesTitle = () => {
|
||
// JSON-LD에서 먼저 시도
|
||
const jsonLd = getJsonLdData();
|
||
if (jsonLd) {
|
||
// ComicIssue 타입인 경우 isPartOf.name 사용
|
||
if (jsonLd.isPartOf && jsonLd.isPartOf.name) {
|
||
return jsonLd.isPartOf.name;
|
||
}
|
||
// Book 타입인 경우 name 사용
|
||
if (jsonLd['@type'] === 'Book' && jsonLd.name) {
|
||
return jsonLd.name;
|
||
}
|
||
}
|
||
// DOM에서 fallback
|
||
const workEl = document.querySelector('.vw-work');
|
||
if (workEl) return workEl.textContent.trim();
|
||
const heroTitle = document.querySelector('.hero-v2-title');
|
||
if (heroTitle) return heroTitle.textContent.trim();
|
||
return '';
|
||
};
|
||
|
||
const getEpisodeTitle = () => {
|
||
// JSON-LD에서 먼저 시도 - name이 headline보다 완전한 제목임
|
||
const jsonLd = getJsonLdData();
|
||
if (jsonLd && (jsonLd['@type'] === 'ComicIssue' || jsonLd['@type'] === 'ComicStory')) {
|
||
if (jsonLd.name) return jsonLd.name;
|
||
}
|
||
// DOM에서 fallback
|
||
const epEl = document.querySelector('.vw-ep');
|
||
if (!epEl) return '';
|
||
const span = epEl.querySelector('span');
|
||
return span ? span.textContent.trim().replace(/^[\s\-]+/, '') : '';
|
||
};
|
||
|
||
const getEpisodeNumber = () => {
|
||
const epEl = document.querySelector('.vw-ep');
|
||
if (!epEl) return '';
|
||
const strong = epEl.querySelector('strong');
|
||
return strong ? strong.textContent.trim() : '';
|
||
};
|
||
|
||
// 이미지 URL 추출 공통 함수
|
||
const extractImageUrls = (container = document, baseUrl) => {
|
||
const imageUrls = [];
|
||
const seen = new Set();
|
||
const resolveUrl = (url) => {
|
||
if (!url) return '';
|
||
try {
|
||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||
if (url.startsWith('//')) return new URL(url, 'https:').href;
|
||
if (url.startsWith('/')) return new URL(url, baseUrl || document.URL).href;
|
||
return new URL(url, baseUrl || document.URL).href;
|
||
} catch { return ''; }
|
||
};
|
||
|
||
const addUrl = (url) => {
|
||
if (!url || seen.has(url)) return;
|
||
if (url.includes('logo') || url.includes('banner') || url.includes('icon') || url.includes('avatar')) return;
|
||
if (!isAllowedImageHost(url)) return;
|
||
seen.add(url);
|
||
imageUrls.push(url);
|
||
};
|
||
|
||
// 방법 1: .vw-imgs 내의 img 태그
|
||
const imgContainer = container.querySelector('.vw-imgs') || container;
|
||
const imgElements = imgContainer.querySelectorAll('img');
|
||
imgElements.forEach(img => {
|
||
const sources = [
|
||
img.src,
|
||
img.currentSrc,
|
||
img.dataset.src,
|
||
img.dataset.original,
|
||
img.dataset.lazySrc,
|
||
img.dataset.lazy,
|
||
img.getAttribute('data-src'),
|
||
img.getAttribute('data-original'),
|
||
img.getAttribute('srcset')?.split(' ')[0],
|
||
img.getAttribute('data-srcset')?.split(' ')[0],
|
||
];
|
||
sources.forEach(s => { const u = resolveUrl(s); if (u) addUrl(u); });
|
||
});
|
||
|
||
// 방법 2: preload 링크
|
||
if (imageUrls.length === 0) {
|
||
const preloadLinks = container.querySelectorAll('link[rel="preload"][as="image"]');
|
||
preloadLinks.forEach(link => {
|
||
const u = resolveUrl(link.getAttribute('href'));
|
||
if (u) addUrl(u);
|
||
});
|
||
}
|
||
|
||
// 방법 3: picture > source srcset
|
||
if (imageUrls.length === 0) {
|
||
const sources = container.querySelectorAll('picture source[srcset], source[data-srcset]');
|
||
sources.forEach(src => {
|
||
const srcset = src.getAttribute('srcset') || src.dataset.srcset;
|
||
if (srcset) {
|
||
srcset.split(',').forEach(s => {
|
||
const u = resolveUrl(s.trim().split(' ')[0]);
|
||
if (u) addUrl(u);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
return imageUrls;
|
||
};
|
||
|
||
// 현재 에피소드 이미지 다운로드
|
||
const downloadImages = async () => {
|
||
// 페이지 이미지 로딩 완료 대기
|
||
await waitForImagesToLoad(document);
|
||
const imageUrls = extractImageUrls();
|
||
|
||
if (imageUrls.length === 0) {
|
||
log('❌ 다운로드할 이미지를 찾을 수 없음');
|
||
const allImgs = document.querySelectorAll('.vw-imgs img');
|
||
log('.vw-imgs img 개수:', allImgs.length);
|
||
allImgs.forEach((img, i) => {
|
||
log(`img[${i}] src=${img.src || '(empty)'}, data-src=${img.dataset?.src || '(none)'}, complete=${img.complete}, naturalWidth=${img.naturalWidth}`);
|
||
});
|
||
const preloadLinks = document.querySelectorAll('link[rel="preload"][as="image"]');
|
||
log('preload links:', preloadLinks.length);
|
||
preloadLinks.forEach((link, i) => {
|
||
log(`link[${i}] href=${link.getAttribute('href')}`);
|
||
});
|
||
log('👉 다운로드 로그를 저장합니다.');
|
||
try { downloadLogFile(); } catch (_) {}
|
||
alert(`❌ 다운로드할 이미지를 찾을 수 없습니다.\n🔍 저장된 로그 파일(Download_xxxxxx.log)을 확인하세요.`);
|
||
return;
|
||
}
|
||
|
||
let title = getEpisodeTitle() || document.title.split('|')[0].trim() || 'webtoon_download';
|
||
title = title.replace(/[/\\:*?"<>|]/g, '_');
|
||
let episodeNum = getEpisodeNumber();
|
||
|
||
log(`발견된 이미지: ${imageUrls.length}개, 제목: ${title}`);
|
||
|
||
const zip = new JSZip();
|
||
let imageCount = 0;
|
||
let failedCount = 0;
|
||
|
||
for (let i = 0; i < imageUrls.length; i++) {
|
||
const imageUrl = imageUrls[i];
|
||
log(`다운로드 중... ${i + 1}/${imageUrls.length}`);
|
||
|
||
try {
|
||
const blob = await downloadImageBlob(imageUrl);
|
||
if (blob && blob.size > 0) {
|
||
const filename = `${String(imageCount).padStart(4, '0')}_page_${i + 1}.jpg`;
|
||
zip.file(filename, blob);
|
||
imageCount++;
|
||
log(`✓ 이미지 다운로드 완료: ${i + 1}/${imageUrls.length}`);
|
||
} else {
|
||
failedCount++;
|
||
log(`⚠ 이미지 다운로드 실패 (${i + 1}번째): 빈 응답`);
|
||
}
|
||
} catch (error) {
|
||
log(`⚠ 이미지 다운로드 실패 (${i + 1}번째):`, error);
|
||
failedCount++;
|
||
}
|
||
}
|
||
|
||
if (imageCount > 0) {
|
||
const seriesTitle = getSeriesTitle();
|
||
const comicInfoXml = generateComicInfoXml(title, seriesTitle, episodeNum, '', imageCount);
|
||
zip.file('ComicInfo.xml', comicInfoXml);
|
||
const blob = await zip.generateAsync({ type: 'blob' });
|
||
downloadZip(blob, `${title}.zip`);
|
||
downloadLogFile();
|
||
alert(`✓ ${imageCount}개 이미지 + ComicInfo.xml 다운로드 완료\n(실패: ${failedCount}개)\n\n파일명: ${title}.zip`);
|
||
} else {
|
||
alert(`❌ 다운로드 가능한 이미지 없음\n발견: ${imageUrls.length}개, 성공: ${imageCount}개, 실패: ${failedCount}개`);
|
||
}
|
||
};
|
||
|
||
// sleep 헬퍼
|
||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||
|
||
// 팝업 창으로 에피소드 페이지를 렌더링 후 이미지 URL 추출 (JavaScript 실행 + 스크롤 필요)
|
||
const downloadEpisodeImagesViaPopup = async (episodeUrl, episodeTitle) => {
|
||
return new Promise((resolve) => {
|
||
log(`🔄 팝업 열기: ${episodeTitle}`);
|
||
|
||
const popup = window.open(episodeUrl, 'episodeViewer', 'width=800,height=600,left=-9999,top=-9999');
|
||
|
||
if (!popup) {
|
||
log(`⚠️ 팝업 차단됨, HTTP 방식 fallback: ${episodeTitle}`);
|
||
fetchHtmlAndResolve(episodeUrl, episodeTitle, resolve);
|
||
return;
|
||
}
|
||
|
||
let resolved = false;
|
||
const MAX_WAIT = 60000;
|
||
|
||
const done = (result) => {
|
||
if (resolved) return;
|
||
resolved = true;
|
||
// 팝업은 닫지 않고 result에 closePopup 전달 (호출자가 다운로드 완료 후 닫음)
|
||
result.closePopup = closePopup;
|
||
resolve(result);
|
||
};
|
||
|
||
let closePopup = () => { try { popup.close(); } catch (_) {} };
|
||
|
||
// 1단계: .vw-imgs가 나타날 때까지 대기 (최대 30초)
|
||
const waitForViewer = () => {
|
||
let waited = 0;
|
||
let viewerTimer = null;
|
||
|
||
const pollTimer = setInterval(() => {
|
||
waited += 500;
|
||
|
||
try {
|
||
if (popup.closed) {
|
||
clearInterval(pollTimer);
|
||
log(`⚠️ 팝업이 일찍 닫힘: ${episodeTitle}`);
|
||
done({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
|
||
return;
|
||
}
|
||
|
||
const doc = popup.document;
|
||
const popupUrl = popup.location ? popup.location.href : '';
|
||
|
||
// about:blank면 아직 로딩 전 -> 계속 대기
|
||
if (!popupUrl || popupUrl === 'about:blank') return;
|
||
|
||
const vwImgs = doc.querySelector('.vw-imgs');
|
||
|
||
if (vwImgs) {
|
||
clearInterval(pollTimer);
|
||
if (viewerTimer) clearTimeout(viewerTimer);
|
||
log(`✅ .vw-imgs 발견, 스크롤 시작: ${episodeTitle}`);
|
||
scrollToLoadImages(doc, done);
|
||
return;
|
||
}
|
||
|
||
// 25초 이상 지나도 vw-imgs 없으면 HTTP fallback
|
||
if (waited >= 25000) {
|
||
clearInterval(pollTimer);
|
||
if (viewerTimer) clearTimeout(viewerTimer);
|
||
log(`⚠️ .vw-imgs 없음 (${waited}ms 대기): ${episodeTitle}`);
|
||
try { popup.close(); } catch (_) {}
|
||
fetchHtmlAndResolve(episodeUrl, episodeTitle, done);
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
// 아직 cross-origin 상태 (초기 로딩 중) - 무시
|
||
}
|
||
}, 500);
|
||
|
||
viewerTimer = setTimeout(() => {
|
||
clearInterval(pollTimer);
|
||
if (!resolved) {
|
||
log(`⏰ 뷰어 대기 타임아웃 (30s): ${episodeTitle}`);
|
||
try { popup.close(); } catch (_) {}
|
||
fetchHtmlAndResolve(episodeUrl, episodeTitle, done);
|
||
}
|
||
}, 30000);
|
||
};
|
||
|
||
// 2단계: 모든 이미지 eager 로딩 설정 후 천천히 스크롤
|
||
const scrollToLoadImages = (doc, callback) => {
|
||
const win = popup;
|
||
let lastScroll = -1;
|
||
let scrollCount = 0;
|
||
let goingDown = true;
|
||
const SCROLL_STEP = 700;
|
||
const SCROLL_INTERVAL = 600;
|
||
|
||
// 모든 이미지를 eager로 설정하고 data-src → src 복사
|
||
const allImgs = doc.querySelectorAll('.vw-imgs img, .vw-images img, [class*="viewer"] img, [class*="comic"] img');
|
||
let imgCount = allImgs.length;
|
||
log(`📸 .vw-imgs 내 img 태그: ${imgCount}개`);
|
||
allImgs.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) {
|
||
try { img.src = new URL(val, episodeUrl).href; } catch {}
|
||
}
|
||
});
|
||
});
|
||
|
||
const scrollTimer = setInterval(() => {
|
||
try {
|
||
if (popup.closed) {
|
||
clearInterval(scrollTimer);
|
||
callback({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
|
||
return;
|
||
}
|
||
|
||
if (goingDown) {
|
||
win.scrollBy(0, SCROLL_STEP);
|
||
} else {
|
||
win.scrollBy(0, -SCROLL_STEP * 2);
|
||
}
|
||
scrollCount++;
|
||
|
||
const curScroll = win.scrollY || win.pageYOffset || 0;
|
||
const maxScroll = Math.max(
|
||
doc.documentElement.scrollHeight - win.innerHeight,
|
||
doc.body.scrollHeight - win.innerHeight,
|
||
0
|
||
);
|
||
|
||
if (goingDown && (curScroll >= maxScroll || curScroll === lastScroll || scrollCount > 80)) {
|
||
// 바닥 도달 → 위로 스크롤 시작
|
||
goingDown = false;
|
||
lastScroll = curScroll;
|
||
log(`📜 바닥 도달, 위로 스크롤 (${scrollCount}회): ${episodeTitle}`);
|
||
} else if (!goingDown && curScroll <= 0) {
|
||
// 맨 위 도달 → 완료
|
||
clearInterval(scrollTimer);
|
||
log(`📜 왕복 스크롤 완료 (${scrollCount}회): ${episodeTitle}`);
|
||
log(`⏳ 5초간 이미지 로딩 대기...`);
|
||
setTimeout(() => {
|
||
waitForImagesToLoad(doc).then(() => {
|
||
extractFromPopup(doc, callback);
|
||
}).catch(() => {
|
||
extractFromPopup(doc, callback);
|
||
});
|
||
}, 5000);
|
||
return;
|
||
}
|
||
|
||
lastScroll = curScroll;
|
||
} catch (e) {
|
||
if (scrollCount > 160) {
|
||
clearInterval(scrollTimer);
|
||
extractFromPopup(doc, callback);
|
||
}
|
||
}
|
||
}, SCROLL_INTERVAL);
|
||
|
||
setTimeout(() => {
|
||
clearInterval(scrollTimer);
|
||
if (!resolved) {
|
||
log(`⏰ 스크롤 타임아웃: ${episodeTitle}`);
|
||
extractFromPopup(doc, callback);
|
||
}
|
||
}, 180000);
|
||
};
|
||
|
||
// 3단계: 팝업 DOM에서 이미지 URL 추출 + 누락 페이지 보강
|
||
const extractFromPopup = async (doc, callback) => {
|
||
try {
|
||
log(`🔍 팝업 DOM 분석: ${episodeTitle}`);
|
||
const vwImgs = doc.querySelector('.vw-imgs');
|
||
const imgEls = vwImgs ? vwImgs.querySelectorAll('img') : [];
|
||
log(`.vw-imgs 존재: ${!!vwImgs}, img 개수: ${imgEls.length}`);
|
||
|
||
let imageUrls = extractImageUrls(doc, episodeUrl);
|
||
log(`✓ ${imageUrls.length}개 이미지 URL 추출 (팝업)`);
|
||
|
||
// pNNN 패턴이 있으면 누락 페이지 HEAD 요청으로 찾기
|
||
if (imageUrls.some(u => /\/p\d{3}\./.test(u))) {
|
||
imageUrls = await fillMissingPages(imageUrls, episodeUrl);
|
||
}
|
||
|
||
let chapterTitle = episodeTitle;
|
||
|
||
if (imageUrls.length > 0) {
|
||
log(`호스트 목록: ${[...new Set(imageUrls.map(u => new URL(u).hostname))].join(', ')}`);
|
||
}
|
||
|
||
try {
|
||
const epEl = doc.querySelector('.vw-ep');
|
||
if (epEl) {
|
||
const strong = epEl.querySelector('strong');
|
||
const span = epEl.querySelector('span');
|
||
const episodeNum = strong ? strong.textContent.trim() : '';
|
||
const episodeSubtitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : '';
|
||
chapterTitle = episodeNum ? `${episodeNum} - ${episodeSubtitle}` : episodeSubtitle;
|
||
}
|
||
} catch (e) {}
|
||
|
||
callback({
|
||
success: imageUrls.length > 0,
|
||
imageUrls, imageCount: imageUrls.length, chapterTitle
|
||
});
|
||
} catch (error) {
|
||
log(`❌ 팝업 추출 실패: ${episodeTitle}`, error);
|
||
callback({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
|
||
}
|
||
};
|
||
|
||
// 시작
|
||
waitForViewer();
|
||
});
|
||
};
|
||
|
||
// HTTP fetch fallback (메타데이터 + 가능한 이미지)
|
||
const fetchHtmlAndResolve = async (episodeUrl, episodeTitle, resolve) => {
|
||
try {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||
|
||
const response = await fetch(episodeUrl, {
|
||
credentials: 'include',
|
||
headers: {
|
||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||
'Referer': document.URL,
|
||
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7'
|
||
}
|
||
});
|
||
clearTimeout(timeoutId);
|
||
|
||
const html = await response.text();
|
||
if (!html || html.length < 500) {
|
||
resolve({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
|
||
return;
|
||
}
|
||
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(html, 'text/html');
|
||
const imageUrls = extractImageUrls(doc, episodeUrl);
|
||
|
||
let chapterTitle = episodeTitle;
|
||
if (imageUrls.length > 0) {
|
||
log(`✓ [${episodeTitle}] ${imageUrls.length}개 이미지 URL 추출 (HTTP fallback)`);
|
||
} else {
|
||
log(`⚠️ 이미지 없음 (HTTP fallback): ${episodeTitle}`);
|
||
}
|
||
|
||
resolve({
|
||
success: imageUrls.length > 0,
|
||
imageUrls: imageUrls,
|
||
imageCount: imageUrls.length,
|
||
chapterTitle: chapterTitle
|
||
});
|
||
} catch (error) {
|
||
log(`❌ HTTP fallback 실패: ${episodeTitle}`, error);
|
||
resolve({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
|
||
}
|
||
};
|
||
|
||
// URL로부터 이미지 다운로드
|
||
const downloadImageBlob = (url) => {
|
||
return new Promise((resolve) => {
|
||
GM_xmlhttpRequest({
|
||
method: 'GET',
|
||
url: url,
|
||
responseType: 'arraybuffer',
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||
'Referer': new URL(document.URL).origin + '/'
|
||
},
|
||
onload: (response) => {
|
||
try {
|
||
const responseText = response.responseHeaders || '';
|
||
const contentType = (responseText.match(/content-type:\s*([^\r\n;]+)/i) || [])[1] || '';
|
||
if (contentType && !contentType.startsWith('image/')) {
|
||
log(`⚠️ 이미지가 아닌 응답 스킵:`, contentType);
|
||
resolve(null);
|
||
return;
|
||
}
|
||
const blob = new Blob([response.response], { type: 'image/jpeg' });
|
||
isComicPageBlob(blob).then(valid => {
|
||
if (valid) {
|
||
resolve(blob);
|
||
} else {
|
||
log(`⚠️ 광고 배너로 의심됨: ${url}`);
|
||
resolve(null);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
log(`⚠️ 이미지 Blob 변환 실패:`, error);
|
||
resolve(null);
|
||
}
|
||
},
|
||
onerror: (error) => {
|
||
log(`⚠️ 이미지 다운로드 실패: ${url}`);
|
||
resolve(null);
|
||
}
|
||
});
|
||
});
|
||
};
|
||
|
||
// 누락 페이지 URL 자동 생성 (pNNN 패턴, HEAD 불필요)
|
||
const fillMissingPages = async (imageUrls, episodeUrl) => {
|
||
const pUrls = imageUrls.filter(u => /\/p\d{3}\./.test(u));
|
||
if (pUrls.length === 0) return imageUrls;
|
||
|
||
const baseMatch = episodeUrl.match(/\/manhwa\/([^/]+)\/(\d+)/);
|
||
if (!baseMatch) return imageUrls;
|
||
|
||
let maxPage = 0;
|
||
const extMap = new Map();
|
||
for (const url of pUrls) {
|
||
const m = url.match(/\/p(\d{3})\.(jpg|png|webp)/);
|
||
if (m) {
|
||
const num = parseInt(m[1], 10);
|
||
maxPage = Math.max(maxPage, num);
|
||
extMap.set(url, m[2]);
|
||
}
|
||
}
|
||
|
||
log(`🔍 누락 페이지 확인: 최대 p${String(maxPage).padStart(3,'0')}까지 있음, ${maxPage}번 이후 확장자 패턴 분석 중...`);
|
||
|
||
// 마지막 3개 이미지의 확장자 패턴으로 다음 확장자 추론
|
||
const sortedPages = [...pUrls]
|
||
.map(u => { const m = u.match(/\/p(\d{3})\.(jpg|png|webp)/); return m ? {url: u, num: parseInt(m[1],10), ext: m[2]} : null; })
|
||
.filter(Boolean)
|
||
.sort((a,b) => a.num - b.num);
|
||
|
||
const lastFew = sortedPages.slice(-5);
|
||
log(` 마지막 ${lastFew.length}개 패턴: ${lastFew.map(p => `p${p.num}.${p.ext}`).join(', ')}`);
|
||
|
||
const results = [...imageUrls];
|
||
const seen = new Set(imageUrls);
|
||
|
||
for (let page = maxPage + 1; page <= maxPage + 10; page++) {
|
||
const pageStr = String(page).padStart(3, '0');
|
||
|
||
// 이전 페이지들의 확장자 패턴 확인
|
||
const prevExts = [];
|
||
for (let p = Math.max(1, page - 5); p < page && p <= maxPage; p++) {
|
||
const prevUrl = imageUrls.find(u => u.includes(`/p${String(p).padStart(3,'0')}.`));
|
||
if (prevUrl) {
|
||
const m = prevUrl.match(/\.(jpg|png|webp)$/);
|
||
if (m) prevExts.push(m[1]);
|
||
}
|
||
}
|
||
|
||
// 가장 자주 나온 확장자 선호
|
||
const extCount = {};
|
||
let bestExt = '.jpg';
|
||
for (const e of prevExts) { extCount[e] = (extCount[e]||0)+1; }
|
||
const entries = Object.entries(extCount);
|
||
if (entries.length > 0) {
|
||
bestExt = entries.sort((a,b) => b[1] - a[1])[0][0];
|
||
}
|
||
|
||
const url = `https://i.toonflix.app/manhwa/${baseMatch[1]}/${baseMatch[2]}/p${pageStr}.${bestExt}`;
|
||
if (!seen.has(url)) {
|
||
log(` ➕ 생성: p${pageStr}.${bestExt} (패턴 기반)`);
|
||
results.push(url);
|
||
seen.add(url);
|
||
}
|
||
}
|
||
|
||
const added = results.length - imageUrls.length;
|
||
if (added > 0) log(`✅ ${added}개 페이지 URL 추가 생성 (총 ${results.length}개)`);
|
||
else log(` 추가 생성 없음`);
|
||
return results;
|
||
};
|
||
|
||
// 에피소드 목록 페이지에서 링크 수집
|
||
const getEpisodeList = () => {
|
||
const episodeElements = document.querySelectorAll('a.ep-row-v2-link');
|
||
if (episodeElements.length === 0) return null;
|
||
|
||
const seriesTitle = getSeriesTitle();
|
||
const writerFromPage = document.querySelector('.hero-v2-author');
|
||
const seriesWriter = writerFromPage ? writerFromPage.textContent.trim() : '';
|
||
|
||
const episodeLinks = Array.from(episodeElements).map((a) => {
|
||
const titleElement = a.querySelector('.ep-row-v2-title strong');
|
||
const title = titleElement ? titleElement.textContent.trim() : a.textContent.trim();
|
||
const noElement = a.querySelector('.ep-row-v2-no');
|
||
const episodeNum = noElement ? noElement.textContent.trim() : '';
|
||
return { url: a.href, title, episodeNum };
|
||
});
|
||
|
||
episodeLinks.reverse();
|
||
return { episodeLinks, seriesTitle, seriesWriter };
|
||
};
|
||
|
||
// 에피소드 목록을 순차 다운로드 (공통 코어)
|
||
const downloadEpisodeList = async (episodeLinks, seriesTitle, seriesWriter) => {
|
||
let downloadedCount = 0;
|
||
let failedCount = 0;
|
||
const MAX_RETRIES = 2;
|
||
let failedEpisodes = [];
|
||
|
||
const downloadSingleEpisode = async (episode, retryCount = 0) => {
|
||
let result;
|
||
try {
|
||
log(`⏳ [${retryCount > 0 ? `재시도 ${retryCount}/${MAX_RETRIES}` : ''}] 다운로드 중: ${episode.title}`);
|
||
|
||
result = await downloadEpisodeImagesViaPopup(episode.url, episode.title);
|
||
|
||
if (result.success && result.imageUrls && result.imageUrls.length > 0) {
|
||
const zip = new JSZip();
|
||
let downloadedImages = 0;
|
||
|
||
for (let j = 0; j < result.imageUrls.length; j++) {
|
||
const imageUrl = result.imageUrls[j];
|
||
log(` 📥 이미지 ${j + 1}/${result.imageUrls.length} 다운로드 중...`);
|
||
|
||
const blob = await downloadImageBlob(imageUrl);
|
||
if (blob) {
|
||
const filename = `${String(j).padStart(4, '0')}_page_${j + 1}.jpg`;
|
||
zip.file(filename, blob);
|
||
downloadedImages++;
|
||
}
|
||
}
|
||
|
||
if (downloadedImages > 0) {
|
||
let chapterTitle = result.chapterTitle || episode.title;
|
||
|
||
const cleanTitle = chapterTitle.replace(/[/\\:*?"<>|]/g, '_').slice(0, 100);
|
||
|
||
let epNumber = episode.episodeNum;
|
||
if (!epNumber) {
|
||
const numMatch = episode.title.match(/^(\d+)/);
|
||
epNumber = numMatch ? numMatch[1] : '0';
|
||
}
|
||
|
||
const comicInfoXml = generateComicInfoXml(
|
||
cleanTitle,
|
||
seriesTitle,
|
||
epNumber,
|
||
seriesWriter,
|
||
downloadedImages
|
||
);
|
||
zip.file('ComicInfo.xml', comicInfoXml);
|
||
|
||
const blob = await zip.generateAsync({ type: 'blob' });
|
||
downloadZip(blob, `${cleanTitle}.zip`);
|
||
log(`✅ ${cleanTitle} (${downloadedImages}/${result.imageCount}개 이미지) 다운로드 완료`);
|
||
if (result.closePopup) result.closePopup();
|
||
return true;
|
||
}
|
||
}
|
||
if (result && result.closePopup) result.closePopup();
|
||
return false;
|
||
} catch (error) {
|
||
log(`❌ 에피소드 처리 실패: ${episode.title}`, error);
|
||
if (result && result.closePopup) result.closePopup();
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// 1차 다운로드
|
||
for (let i = 0; i < episodeLinks.length; i++) {
|
||
const episode = episodeLinks[i];
|
||
const success = await downloadSingleEpisode(episode);
|
||
if (success) {
|
||
downloadedCount++;
|
||
} else {
|
||
failedCount++;
|
||
failedEpisodes.push(episode);
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 20000));
|
||
}
|
||
|
||
// 실패한 에피소드 재시도
|
||
for (let retry = 0; retry < MAX_RETRIES && failedEpisodes.length > 0; retry++) {
|
||
log(`🔄 재시도 ${retry + 1}/${MAX_RETRIES}: ${failedEpisodes.length}개 에피소드`);
|
||
const stillFailed = [];
|
||
|
||
for (const episode of failedEpisodes) {
|
||
const success = await downloadSingleEpisode(episode, retry + 1);
|
||
if (success) {
|
||
downloadedCount++;
|
||
failedCount--;
|
||
} else {
|
||
stillFailed.push(episode);
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 30000));
|
||
}
|
||
|
||
failedEpisodes = stillFailed;
|
||
if (failedEpisodes.length === 0) break;
|
||
}
|
||
|
||
// 사용자 재시도 루프
|
||
while (failedEpisodes.length > 0) {
|
||
const result = confirm(`🎉 1차 완료!\n\n✅ 다운로드: ${downloadedCount}개\n⚠️ 실패: ${failedEpisodes.length}개\n📊 총 에피소드: ${episodeLinks.length}개\n\n실패한 에피소드를 다시 시도할까요?`);
|
||
if (!result) break;
|
||
|
||
const stillFailed = [];
|
||
for (const episode of failedEpisodes) {
|
||
log(`🔄 사용자 재시도: ${episode.title}`);
|
||
const success = await downloadSingleEpisode(episode, '재시도');
|
||
if (success) {
|
||
downloadedCount++;
|
||
failedCount--;
|
||
} else {
|
||
stillFailed.push(episode);
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 30000));
|
||
}
|
||
failedEpisodes = stillFailed;
|
||
}
|
||
|
||
log(`✅ 최종 완료: ${downloadedCount}개 성공, ${failedEpisodes.length}개 실패 (총 ${episodeLinks.length}개)`);
|
||
alert(`🎉 최종 완료!\n\n✅ 다운로드: ${downloadedCount}개\n⚠️ 실패: ${failedEpisodes.length}개\n📊 총 에피소드: ${episodeLinks.length}개`);
|
||
};
|
||
|
||
// 전체 에피소드 다운로드
|
||
const downloadAllEpisodesAsZips = async () => {
|
||
const list = getEpisodeList();
|
||
if (!list) {
|
||
log(`❌ 에피소드 링크를 찾을 수 없음 (선택자: a.ep-row-v2-link)`);
|
||
const allLinks = document.querySelectorAll('a[href*="/manhwa/"]');
|
||
log(`/manhwa/ 링크 개수: ${allLinks.length}`);
|
||
allLinks.forEach((a, i) => {
|
||
if (i < 10) log(` 링크[${i}] class="${a.className}" href="${a.href}"`);
|
||
});
|
||
alert(`❌ 다운로드할 에피소드를 찾을 수 없습니다.`);
|
||
return;
|
||
}
|
||
log(`📊 ${list.episodeLinks.length}개의 에피소드 발견 (첫화순으로 정렬)`);
|
||
log(`📚 시리즈: ${list.seriesTitle}, 작가: ${list.seriesWriter}`);
|
||
await downloadEpisodeList(list.episodeLinks, list.seriesTitle, list.seriesWriter);
|
||
};
|
||
|
||
// 에피소드 번호 추출 헬퍼 (숫자만)
|
||
const parseEpisodeNumber = (ep) => {
|
||
const digits = ep.episodeNum.replace(/[^\d]/g, '');
|
||
if (digits) return parseInt(digits, 10);
|
||
const m = ep.title.match(/(\d+)/);
|
||
return m ? parseInt(m[1], 10) : null;
|
||
};
|
||
|
||
// N화 이후 다운로드
|
||
const downloadEpisodesFromInput = async () => {
|
||
const list = getEpisodeList();
|
||
if (!list) {
|
||
alert(`❌ 에피소드 목록을 찾을 수 없습니다.`);
|
||
return;
|
||
}
|
||
const input = prompt(`다운로드할 시작 에피소드 번호를 입력하세요.\n(예: 10 → 10화부터 마지막까지)`, '');
|
||
if (input === null) return;
|
||
const startNum = parseInt(input.replace(/[^\d]/g, ''), 10);
|
||
if (isNaN(startNum) || startNum < 1) {
|
||
alert(`올바른 숫자를 입력하세요.`);
|
||
return;
|
||
}
|
||
const filtered = list.episodeLinks.filter(ep => {
|
||
const n = parseEpisodeNumber(ep);
|
||
return n !== null && n >= startNum;
|
||
});
|
||
if (filtered.length === 0) {
|
||
alert(`${startNum}화 이후의 에피소드가 없습니다.`);
|
||
return;
|
||
}
|
||
log(`📊 ${list.episodeLinks.length}개 중 ${startNum}화 이후 ${filtered.length}개 에피소드`);
|
||
log(`📚 시리즈: ${list.seriesTitle}, 작가: ${list.seriesWriter}`);
|
||
await downloadEpisodeList(filtered, list.seriesTitle, list.seriesWriter);
|
||
};
|
||
|
||
// 에피소드 범위 다운로드
|
||
const downloadEpisodesRangeInput = async () => {
|
||
const list = getEpisodeList();
|
||
if (!list) {
|
||
alert(`❌ 에피소드 목록을 찾을 수 없습니다.`);
|
||
return;
|
||
}
|
||
const startInput = prompt(`다운로드할 시작 에피소드 번호를 입력하세요.`, '');
|
||
if (startInput === null) return;
|
||
const startNum = parseInt(startInput.replace(/[^\d]/g, ''), 10);
|
||
if (isNaN(startNum) || startNum < 1) {
|
||
alert(`올바른 숫자를 입력하세요.`);
|
||
return;
|
||
}
|
||
const endInput = prompt(`다운로드할 마지막 에피소드 번호를 입력하세요.`, '');
|
||
if (endInput === null) return;
|
||
const endNum = parseInt(endInput.replace(/[^\d]/g, ''), 10);
|
||
if (isNaN(endNum) || endNum < startNum) {
|
||
alert(`마지막 번호는 ${startNum} 이상이어야 합니다.`);
|
||
return;
|
||
}
|
||
const filtered = list.episodeLinks.filter(ep => {
|
||
const n = parseEpisodeNumber(ep);
|
||
return n !== null && n >= startNum && n <= endNum;
|
||
});
|
||
if (filtered.length === 0) {
|
||
alert(`${startNum}화~${endNum}화 범위의 에피소드가 없습니다.`);
|
||
return;
|
||
}
|
||
log(`📊 ${list.episodeLinks.length}개 중 ${startNum}화~${endNum}화 범위 ${filtered.length}개 에피소드`);
|
||
log(`📚 시리즈: ${list.seriesTitle}, 작가: ${list.seriesWriter}`);
|
||
await downloadEpisodeList(filtered, list.seriesTitle, list.seriesWriter);
|
||
};
|
||
|
||
// 메뉴 명령 ID 관리 (중복 등록 방지)
|
||
let menuCmdIds = [];
|
||
|
||
// 안전한 메뉴 콜백 래퍼: 모든 에러를 LOG로 기록하고 로그 파일 다운로드
|
||
const safeCallback = (fn) => async (...args) => {
|
||
try {
|
||
await fn(...args);
|
||
} catch (e) {
|
||
log('❌ 치명적 오류:', e);
|
||
log('Stack:', e instanceof Error ? e.stack : '');
|
||
log('로그를 저장합니다.');
|
||
try { downloadLogFile(); } catch (_) {}
|
||
}
|
||
};
|
||
|
||
// 메뉴 명령 등록 함수
|
||
const registerMenuCommands = () => {
|
||
// 이전 명령 해제
|
||
menuCmdIds.forEach(id => GM_unregisterMenuCommand(id));
|
||
menuCmdIds = [];
|
||
|
||
const currentUrl = document.URL;
|
||
const hostname = new URL(currentUrl).hostname;
|
||
const pathMatch = currentUrl.match(/\/manhwa\/([^/]+)(?:\/(\d+))?/);
|
||
const isEp = pathMatch && pathMatch[2];
|
||
const isList = pathMatch && !pathMatch[2];
|
||
|
||
log(`📍 현재 URL: ${currentUrl}`);
|
||
log(`📍 호스트: ${hostname}, 목록: ${isList}, 에피소드: ${isEp}`);
|
||
|
||
if (isEp) {
|
||
menuCmdIds.push(GM_registerMenuCommand('📥 현재 에피소드 다운로드', safeCallback(downloadImages)));
|
||
log('✅ 에피소드 페이지 메뉴 등록 완료');
|
||
} else if (isList) {
|
||
menuCmdIds.push(GM_registerMenuCommand('📥 전체 에피소드 다운로드', safeCallback(downloadAllEpisodesAsZips)));
|
||
menuCmdIds.push(GM_registerMenuCommand('📥 N화 이후 다운로드', safeCallback(downloadEpisodesFromInput)));
|
||
menuCmdIds.push(GM_registerMenuCommand('📥 범위 다운로드', safeCallback(downloadEpisodesRangeInput)));
|
||
log('✅ 목록 페이지 메뉴 등록 완료 (3개)');
|
||
} else {
|
||
log('⏸️ 현재 페이지는 다운로드 대상이 아닙니다. 로그 저장 메뉴만 표시됩니다.');
|
||
}
|
||
|
||
// 항상 로그 저장 메뉴 등록
|
||
menuCmdIds.push(GM_registerMenuCommand('📋 로그 저장', () => downloadLogFile()));
|
||
};
|
||
|
||
// 초기 메뉴 등록
|
||
registerMenuCommands();
|
||
|
||
// SPA 네비게이션 감지 (Next.js)
|
||
const startNavObserver = () => {
|
||
const observer = new MutationObserver(() => {
|
||
registerMenuCommands();
|
||
});
|
||
observer.observe(document.body, { childList: true, subtree: true });
|
||
};
|
||
|
||
// 히스토리 API 변경 감지
|
||
const origPushState = history.pushState;
|
||
const origReplaceState = history.replaceState;
|
||
history.pushState = function() {
|
||
origPushState.apply(this, arguments);
|
||
setTimeout(registerMenuCommands, 300);
|
||
};
|
||
history.replaceState = function() {
|
||
origReplaceState.apply(this, arguments);
|
||
setTimeout(registerMenuCommands, 300);
|
||
};
|
||
window.addEventListener('popstate', () => setTimeout(registerMenuCommands, 300));
|
||
|
||
startNavObserver();
|
||
|
||
log(`Ntoki Downloader v0.2.0 loaded`);
|
||
log(`URL: ${document.URL}`);
|
||
log(`User-Agent: ${navigator.userAgent}`);
|
||
log(`지원 도메인: sbxh1/2/3.com, ntk01.com`);
|
||
log('팝업 방식 (JavaScript 렌더링) + 오류 시 로그 자동 저장');
|
||
const jszipCheck = typeof JSZip !== 'undefined';
|
||
log(`JSZip 라이브러리: ${jszipCheck ? '✅ 로드됨' : '❌ 로드 안 됨'}`);
|
||
if (!jszipCheck) {
|
||
log('⚠️ JSZip이 로드되지 않았습니다. CDN 차단 또는 네트워크 문제일 수 있습니다.');
|
||
}
|
||
})();
|