Initial commit - tokiDownloader.user.js
10
.continue/mcpServers/new-mcp-server.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
name: New MCP server
|
||||
version: 0.0.1
|
||||
schema: v1
|
||||
mcpServers:
|
||||
- name: New MCP server
|
||||
command: npx
|
||||
args:
|
||||
- -y
|
||||
- <your-mcp-server>
|
||||
env: {}
|
||||
568
Old_Scripts/11toonDownloader.user copy.js
Normal file
@@ -0,0 +1,568 @@
|
||||
// ==UserScript==
|
||||
// @name 11toonDownloader
|
||||
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
|
||||
// @version 0.0.6
|
||||
// @description 일일툰 전용 다운로더
|
||||
// @author hehaho
|
||||
// @match http://*.cprapid.com:*/bbs/board.php*
|
||||
// @match https://*.spotv24.com/bbs/board.php*
|
||||
// @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad
|
||||
// @grant GM_registerMenuCommand
|
||||
// @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 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);
|
||||
}
|
||||
};
|
||||
|
||||
// 글로벌 이미지 저장소 (네트워크 모니터링용)
|
||||
window.capturedImageUrls = [];
|
||||
window.capturedImageBlobs = [];
|
||||
|
||||
// fetch 인터셉트 (이미지 Blob 직접 캡처)
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
|
||||
const isImageUrl = url && (url.includes('.jpg') || url.includes('.png') || url.includes('.webp') || url.includes('/image') || url.includes('/photo')) &&
|
||||
!url.includes('logo') && !url.includes('banner') && !url.includes('icon');
|
||||
|
||||
if (isImageUrl && !window.capturedImageUrls.includes(url)) {
|
||||
window.capturedImageUrls.push(url);
|
||||
}
|
||||
|
||||
const result = originalFetch.apply(this, args);
|
||||
|
||||
// Blob 응답 캡처 (Canvas Tainted 우회)
|
||||
if (isImageUrl && result instanceof Promise) {
|
||||
result.then(response => {
|
||||
if (response.ok && (response.headers.get('content-type')?.includes('image') || url.match(/\.(jpg|jpeg|png|webp)$/i))) {
|
||||
const clonedResponse = response.clone();
|
||||
clonedResponse.blob().then(blob => {
|
||||
if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) {
|
||||
window.capturedImageBlobs.push(blob);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
return response;
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// XMLHttpRequest 인터셉트 (arraybuffer 응답 캡처)
|
||||
const originalOpen = XMLHttpRequest.prototype.open;
|
||||
const originalSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
||||
this._captureUrl = url;
|
||||
return originalOpen.apply(this, [method, url, ...rest]);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function(...args) {
|
||||
const xhr = this;
|
||||
const originalOnreadystatechange = xhr.onreadystatechange;
|
||||
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
const url = xhr._captureUrl;
|
||||
const isImageUrl = url && (url.includes('.jpg') || url.includes('.png') || url.includes('.webp') || url.includes('/image') || url.includes('/photo')) &&
|
||||
!url.includes('logo') && !url.includes('banner') && !url.includes('icon');
|
||||
|
||||
if (isImageUrl && xhr.response instanceof ArrayBuffer) {
|
||||
try {
|
||||
const blob = new Blob([xhr.response], { type: xhr.getResponseHeader('content-type') || 'image/jpeg' });
|
||||
if (!window.capturedImageBlobs.some(b => b.size === blob.size)) {
|
||||
window.capturedImageBlobs.push(blob);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (originalOnreadystatechange) {
|
||||
return originalOnreadystatechange.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
return originalSend.apply(this, args);
|
||||
};
|
||||
|
||||
let site = '';
|
||||
let protocolDomain = '';
|
||||
let isListPage = false;
|
||||
|
||||
// 현재 url 체크
|
||||
const currentURL = document.URL;
|
||||
if (currentURL.includes('bo_table=toons')) {
|
||||
if (currentURL.includes('wr_id=')) {
|
||||
site = "일일툰";
|
||||
} else {
|
||||
site = "일일툰_목록";
|
||||
isListPage = true;
|
||||
}
|
||||
protocolDomain = currentURL.match(/^https?:\/\/[^:/?]+/)[0];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const 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`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<?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>
|
||||
<PageCount>${pageCount}</PageCount>
|
||||
<LanguageISO>ko</LanguageISO>
|
||||
<Manga>Yes</Manga>
|
||||
${pagesXml ? ` <Pages>\n${pagesXml} </Pages>\n` : ''}</ComicInfo>`;
|
||||
};
|
||||
|
||||
// DOM에서 이미지 URL 추출 헬퍼 함수
|
||||
const extractImagesFromDom = (targetDocument, protocolDomain) => {
|
||||
let imageUrls = [];
|
||||
|
||||
// 1. img 태그 탐색
|
||||
const contentDiv = targetDocument.querySelector('#bo_v_con') || targetDocument.querySelector('.view-content') || targetDocument.querySelector('#toon_img') || targetDocument.querySelector('.board-view');
|
||||
let imgElements = Array.from(targetDocument.querySelectorAll('#bo_v_con img, .view-content img, .view-wrap img, .toon-img img, .item-image img, .board-view img, #toon_img img'));
|
||||
|
||||
if (contentDiv && imgElements.length === 0) {
|
||||
imgElements = Array.from(contentDiv.querySelectorAll('img'));
|
||||
}
|
||||
|
||||
imgElements.forEach(img => {
|
||||
let src = img.src || img.getAttribute('data-src') || img.getAttribute('data-original');
|
||||
for (let attr of img.attributes) {
|
||||
if (attr.name.startsWith('data-') && (attr.value.includes('http') || attr.value.includes('/data/'))) {
|
||||
src = attr.value;
|
||||
}
|
||||
}
|
||||
if (src && !src.includes('gif') && !src.includes('logo') && !src.includes('banner')) {
|
||||
if (src.startsWith('//')) src = 'https:' + src;
|
||||
else if (src.startsWith('/')) src = protocolDomain + src;
|
||||
imageUrls.push(src);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. css background-image 탐색
|
||||
const divElements = Array.from(targetDocument.querySelectorAll('#bo_v_con div, .view-content div, .toon-img div'));
|
||||
divElements.forEach(div => {
|
||||
const bg = div.style.backgroundImage || window.getComputedStyle(div).backgroundImage;
|
||||
if (bg && bg !== 'none') {
|
||||
const match = bg.match(/url\(["']?(.*?)["']?\)/);
|
||||
if (match && match[1] && !match[1].includes('gif')) {
|
||||
let src = match[1];
|
||||
if (src.startsWith('//')) src = 'https:' + src;
|
||||
else if (src.startsWith('/')) src = protocolDomain + src;
|
||||
imageUrls.push(src);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Canvas 탐색
|
||||
if (imageUrls.length === 0) {
|
||||
const canvasElements = Array.from(targetDocument.querySelectorAll('canvas'));
|
||||
canvasElements.forEach(canvas => {
|
||||
try {
|
||||
const dataUrl = canvas.toDataURL('image/jpeg');
|
||||
if (dataUrl && dataUrl.length > 1000) {
|
||||
imageUrls.push(dataUrl);
|
||||
}
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. HTML 원문 정규식 탐색 (Fallback)
|
||||
if (imageUrls.length === 0) {
|
||||
const html = contentDiv ? contentDiv.innerHTML : targetDocument.documentElement.innerHTML;
|
||||
|
||||
const matches1 = html.match(/https?:\/\/[^"'\s>]*\.(jpg|jpeg|png|webp)[^"'\s>]*/gi) || [];
|
||||
const matches2 = html.match(/(?:\/data\/file\/[a-zA-Z0-9_/-]+|img[0-9]*\.[a-zA-Z0-9-]+\.[a-zA-Z]+\/data\/file\/[a-zA-Z0-9_/-]+)\.(jpg|jpeg|png|webp)/gi) || [];
|
||||
const matches3 = html.match(/\/toki[^"'\s>]+\.(jpg|jpeg|png|webp)/gi) || [];
|
||||
const matches4 = html.match(/(?:data-src|data-original)=["']([^"']+)["']/gi) || [];
|
||||
|
||||
let allMatches = [...matches1, ...matches2, ...matches3];
|
||||
matches4.forEach(m => {
|
||||
const urlMatch = m.match(/=["']([^"']+)["']/);
|
||||
if (urlMatch && urlMatch[1] && !urlMatch[1].includes('gif') && !urlMatch[1].includes('logo') && !urlMatch[1].includes('banner')) {
|
||||
allMatches.push(urlMatch[1]);
|
||||
}
|
||||
});
|
||||
|
||||
if (allMatches.length > 0) {
|
||||
imageUrls = imageUrls.concat(allMatches.map(url => {
|
||||
if (url.startsWith('//')) return 'https:' + url;
|
||||
if (url.startsWith('/')) return protocolDomain + url;
|
||||
if (!url.startsWith('http')) return protocolDomain + '/' + url;
|
||||
return url;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 제거 및 최종 필터링
|
||||
imageUrls = [...new Set(imageUrls)].filter(url => {
|
||||
if (url.startsWith('data:image')) return true;
|
||||
return !url.includes('logo') && !url.includes('banner') && !url.includes('icon') && !url.includes('no_img') && !url.includes('profile') && !url.includes('blank') && url.length > 25;
|
||||
});
|
||||
|
||||
return imageUrls;
|
||||
};
|
||||
|
||||
// 이미지 다운로드 및 압축 헬퍼 함수
|
||||
const processAndDownloadZip = async (title, imageUrls, imageBlobs) => {
|
||||
const zip = new JSZip();
|
||||
|
||||
// 1. Blob 사용 시 (가장 우선)
|
||||
if (imageBlobs && imageBlobs.length > 0) {
|
||||
console.log(`[다운로드] Blob 이미지 ${imageBlobs.length}개 사용`);
|
||||
imageBlobs.forEach((blob, i) => {
|
||||
const filename = `image${String(i).padStart(4, '0')}.jpg`;
|
||||
zip.file(filename, blob);
|
||||
});
|
||||
const comicInfoContent = generateComicInfo(title, title, '', '1', imageBlobs.length, imageBlobs.length);
|
||||
zip.file('ComicInfo.xml', comicInfoContent);
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
downloadZip(content, `${title}.zip`);
|
||||
console.log(`[완료] ${title} 다운로드 완료`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. URL 사용 시
|
||||
if (!imageUrls || imageUrls.length === 0) {
|
||||
console.warn(`[스킵] ${title} - 이미지를 찾을 수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[다운로드] URL 이미지 ${imageUrls.length}개 사용`);
|
||||
const downloadImageTask = (url, index) => {
|
||||
return new Promise((resolve) => {
|
||||
const filename = `image${String(index).padStart(4, '0')}.jpg`;
|
||||
|
||||
if (url.startsWith('data:image')) {
|
||||
try {
|
||||
const base64Data = url.split(',')[1];
|
||||
zip.file(filename, base64Data, {base64: true});
|
||||
} catch(e) {}
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
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 blob = new Blob([response.response], { type: 'image/jpeg' });
|
||||
zip.file(filename, blob);
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
onerror: () => resolve(),
|
||||
ontimeout: () => resolve()
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 5개씩 동시 다운로드
|
||||
let promiseList = [];
|
||||
for (let j = 0; j < imageUrls.length; j++) {
|
||||
promiseList.push(downloadImageTask(imageUrls[j], j));
|
||||
if (promiseList.length >= 5) {
|
||||
await Promise.all(promiseList);
|
||||
promiseList = [];
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
if (promiseList.length > 0) {
|
||||
await Promise.all(promiseList);
|
||||
}
|
||||
|
||||
const comicInfoContent = generateComicInfo(title, title, '', '1', imageUrls.length, imageUrls.length);
|
||||
zip.file('ComicInfo.xml', comicInfoContent);
|
||||
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
downloadZip(content, `${title}.zip`);
|
||||
console.log(`[완료] ${title} 다운로드 완료`);
|
||||
};
|
||||
|
||||
// 에피소드 하나 다운로드 처리 (목록에서 연속 다운로드 시 사용)
|
||||
async function downloadEpisode(episodeTitle, url) {
|
||||
return new Promise(async (resolve) => {
|
||||
try {
|
||||
console.log(`[진행 중] ${episodeTitle}`);
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.border = '5px solid red';
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
try {
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
if (iframeDoc) {
|
||||
const script = iframeDoc.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.innerHTML = `
|
||||
window.capturedImageUrls = [];
|
||||
window.capturedImageBlobs = [];
|
||||
const isTargetImage = (u) => u && typeof u === 'string' &&
|
||||
(u.includes('.jpg') || u.includes('.png') || u.includes('.webp') || u.includes('/image') || u.includes('/photo')) &&
|
||||
!u.includes('logo') && !u.includes('banner') && !u.includes('icon');
|
||||
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
const u = typeof args[0] === 'string' ? args[0] : args[0]?.url;
|
||||
if (isTargetImage(u)) {
|
||||
if (!window.capturedImageUrls.includes(u)) window.capturedImageUrls.push(u);
|
||||
const result = origFetch.apply(this, args);
|
||||
if (result instanceof Promise) {
|
||||
result.then(response => {
|
||||
if (response.ok) {
|
||||
response.clone().blob().then(blob => {
|
||||
if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) {
|
||||
window.capturedImageBlobs.push(blob);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
return response;
|
||||
}).catch(() => {});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return origFetch.apply(this, args);
|
||||
};
|
||||
|
||||
const origOpen = XMLHttpRequest.prototype.open;
|
||||
const origSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
XMLHttpRequest.prototype.open = function(method, u, ...rest) {
|
||||
this._captureUrl = u;
|
||||
return origOpen.apply(this, [method, u, ...rest]);
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function(...args) {
|
||||
const xhr = this;
|
||||
const origOnreadystatechange = xhr.onreadystatechange;
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
const u = xhr._captureUrl;
|
||||
if (isTargetImage(u) && xhr.response instanceof ArrayBuffer) {
|
||||
try {
|
||||
const blob = new Blob([xhr.response], { type: xhr.getResponseHeader('content-type') || 'image/jpeg' });
|
||||
if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) {
|
||||
window.capturedImageBlobs.push(blob);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
if (origOnreadystatechange) return origOnreadystatechange.apply(this, arguments);
|
||||
};
|
||||
return origSend.apply(this, args);
|
||||
};
|
||||
`;
|
||||
iframeDoc.head.appendChild(script);
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
await new Promise((res) => {
|
||||
iframe.addEventListener('load', () => res(), { once: true });
|
||||
iframe.src = url;
|
||||
});
|
||||
|
||||
await sleep(3000);
|
||||
|
||||
const iframeWindow = iframe.contentWindow;
|
||||
const iframeDocument = iframeWindow.document;
|
||||
|
||||
let lastScroll = -1;
|
||||
let scrollAttempts = 0;
|
||||
while (scrollAttempts < 50) {
|
||||
iframeWindow.scrollBy(0, window.innerHeight || 800);
|
||||
await sleep(300);
|
||||
if (iframeWindow.scrollY === lastScroll || iframeWindow.scrollY + iframeWindow.innerHeight >= iframeDocument.documentElement.scrollHeight) {
|
||||
break;
|
||||
}
|
||||
lastScroll = iframeWindow.scrollY;
|
||||
scrollAttempts++;
|
||||
}
|
||||
|
||||
await sleep(1500);
|
||||
|
||||
let imageBlobs = [];
|
||||
if (iframeWindow && iframeWindow.capturedImageBlobs && iframeWindow.capturedImageBlobs.length > 0) {
|
||||
imageBlobs = [...iframeWindow.capturedImageBlobs];
|
||||
} else if (window.capturedImageBlobs && window.capturedImageBlobs.length > 0) {
|
||||
imageBlobs = [...window.capturedImageBlobs];
|
||||
}
|
||||
|
||||
let imageUrls = [];
|
||||
if (imageBlobs.length === 0) {
|
||||
imageUrls = extractImagesFromDom(iframeDocument, protocolDomain);
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
if (iframeWindow && iframeWindow.capturedImageUrls && iframeWindow.capturedImageUrls.length > 0) {
|
||||
imageUrls = [...iframeWindow.capturedImageUrls];
|
||||
} else if (window.capturedImageUrls && window.capturedImageUrls.length > 0) {
|
||||
imageUrls = [...window.capturedImageUrls];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
iframe.remove();
|
||||
|
||||
await processAndDownloadZip(episodeTitle, imageUrls, imageBlobs);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
console.error(`[오류] ${episodeTitle}:`, err);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function tokiDownload() {
|
||||
try {
|
||||
if (isListPage) {
|
||||
console.clear();
|
||||
console.log('목록 페이지 처리 시작');
|
||||
|
||||
const episodeList = document.querySelector('ul.episode-list') || document.querySelector('.list-body') || document.querySelector('.board-list') || document.querySelector('.list-container') || document.querySelector('#bo_list');
|
||||
if (!episodeList) {
|
||||
alert('에피소드 목록을 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.from(episodeList.querySelectorAll('li, tr, .list-item, .item, .list-row'));
|
||||
const episodes = [];
|
||||
|
||||
items.forEach((item) => {
|
||||
const button = item.querySelector('button[onclick*="location.href"]');
|
||||
const link = item.querySelector('a[href*="wr_id="]');
|
||||
|
||||
if (button) {
|
||||
const onclick = button.getAttribute('onclick') || '';
|
||||
const match = onclick.match(/location\.href=['"]([^'"]+)['"]/);
|
||||
if (match && match[1]) {
|
||||
const absoluteUrl = new URL(match[1], window.location.href).href;
|
||||
const episodeTitle = item.innerText.split('\n')[0].trim();
|
||||
if (episodeTitle && absoluteUrl) {
|
||||
episodes.push({ title: episodeTitle, url: absoluteUrl });
|
||||
}
|
||||
}
|
||||
} else if (link && link.href) {
|
||||
let clone = link.cloneNode(true);
|
||||
clone.querySelectorAll('span, img, i, b, strong').forEach(s => s.remove());
|
||||
let episodeTitle = clone.innerText.trim();
|
||||
if (!episodeTitle) episodeTitle = link.innerText.trim();
|
||||
if (episodeTitle && link.href) {
|
||||
episodes.push({ title: episodeTitle, url: link.href });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueEpisodes = [];
|
||||
const urlSet = new Set();
|
||||
episodes.forEach(ep => {
|
||||
const wrIdMatch = ep.url.match(/wr_id=([0-9]+)/);
|
||||
const uniqueKey = wrIdMatch ? wrIdMatch[1] : ep.url;
|
||||
if (!urlSet.has(uniqueKey)) {
|
||||
urlSet.add(uniqueKey);
|
||||
uniqueEpisodes.push(ep);
|
||||
}
|
||||
});
|
||||
|
||||
uniqueEpisodes.reverse();
|
||||
|
||||
if (uniqueEpisodes.length === 0) {
|
||||
alert('에피소드 링크를 추출할 수 없습니다. (구조가 변경되었을 수 있습니다.)');
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < uniqueEpisodes.length; i++) {
|
||||
await downloadEpisode(uniqueEpisodes[i].title, uniqueEpisodes[i].url);
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
alert('모든 에피소드 다운로드가 완료되었습니다.');
|
||||
|
||||
} else {
|
||||
const titleElement = document.querySelector('h1') || document.querySelector('.view-title');
|
||||
const title = titleElement ? titleElement.innerText.trim() : '제목없음';
|
||||
|
||||
console.clear();
|
||||
console.log(`단일 다운로드 시작: ${title}`);
|
||||
|
||||
let imageBlobs = window.capturedImageBlobs || [];
|
||||
let imageUrls = [];
|
||||
|
||||
if (imageBlobs.length === 0) {
|
||||
imageUrls = extractImagesFromDom(document, protocolDomain);
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
imageUrls = window.capturedImageUrls || [];
|
||||
}
|
||||
}
|
||||
|
||||
if (imageBlobs.length === 0 && (!imageUrls || imageUrls.length === 0)) {
|
||||
alert('이미지를 감지하지 못했습니다. 페이지가 완전히 로드되지 않았을 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
await processAndDownloadZip(title, imageUrls, imageBlobs);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`오류발생: \n` + error);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (isListPage) {
|
||||
GM_registerMenuCommand('[일일툰] 전체 에피소드 다운로드', () => tokiDownload());
|
||||
} else {
|
||||
GM_registerMenuCommand('[일일툰] 현재 에피소드 다운로드', () => tokiDownload());
|
||||
}
|
||||
})();
|
||||
877
Old_Scripts/ManatokiDownloader.user.js
Normal file
@@ -0,0 +1,877 @@
|
||||
// ==UserScript==
|
||||
// @name ManatokiDownloader
|
||||
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
|
||||
// @version 0.0.8
|
||||
// @description 마나토끼/뉴토끼 전용 다운로더
|
||||
// @author hehaho
|
||||
// @match https://*.com/manhwa/*
|
||||
// @match https://*.com/webtoon/*
|
||||
// @match https://*.com/comic/*
|
||||
// @match https://*.net/comic/*
|
||||
// @match https://*.net/manhwa/*
|
||||
// @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad
|
||||
// @grant GM_registerMenuCommand
|
||||
// @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 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 = () => {
|
||||
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);
|
||||
};
|
||||
|
||||
// 글로벌 이미지 저장소 (네트워크 모니터링용)
|
||||
window.capturedImageUrls = [];
|
||||
window.capturedImageBlobs = [];
|
||||
|
||||
// fetch 인터셉트 (이미지 Blob 직접 캡처)
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
|
||||
const isImageUrl = url && (url.includes('.jpg') || url.includes('.jpeg') || url.includes('.png') || url.includes('.webp') || url.includes('/image') || url.includes('/photo')) &&
|
||||
!url.includes('logo') && !url.includes('banner') && !url.includes('icon') && isAllowedImageHost(url);
|
||||
|
||||
if (isImageUrl && !window.capturedImageUrls.includes(url)) {
|
||||
window.capturedImageUrls.push(url);
|
||||
}
|
||||
|
||||
const result = originalFetch.apply(this, args);
|
||||
|
||||
// Blob 응답 캡처 (Canvas Tainted 우회)
|
||||
if (isImageUrl && result instanceof Promise) {
|
||||
result.then(response => {
|
||||
if (response.ok && (response.headers.get('content-type')?.includes('image') || url.match(/\.(jpg|jpeg|png|webp)$/i))) {
|
||||
const clonedResponse = response.clone();
|
||||
clonedResponse.blob().then(blob => {
|
||||
if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) {
|
||||
isComicPageBlob(blob).then(valid => {
|
||||
if (valid) window.capturedImageBlobs.push(blob);
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
return response;
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// XMLHttpRequest 인터셉트 (arraybuffer 응답 캡처)
|
||||
const originalOpen = XMLHttpRequest.prototype.open;
|
||||
const originalSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
||||
this._captureUrl = url;
|
||||
return originalOpen.apply(this, [method, url, ...rest]);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function(...args) {
|
||||
const xhr = this;
|
||||
const originalOnreadystatechange = xhr.onreadystatechange;
|
||||
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
const url = xhr._captureUrl;
|
||||
const isImageUrl = url && (url.includes('.jpg') || url.includes('.jpeg') || url.includes('.png') || url.includes('.webp') || url.includes('/image') || url.includes('/photo')) &&
|
||||
!url.includes('logo') && !url.includes('banner') && !url.includes('icon') && isAllowedImageHost(url);
|
||||
|
||||
if (isImageUrl && xhr.response instanceof ArrayBuffer) {
|
||||
try {
|
||||
const blob = new Blob([xhr.response], { type: xhr.getResponseHeader('content-type') || 'image/jpeg' });
|
||||
if (!window.capturedImageBlobs.some(b => b.size === blob.size)) {
|
||||
isComicPageBlob(blob).then(valid => {
|
||||
if (valid) window.capturedImageBlobs.push(blob);
|
||||
});
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (originalOnreadystatechange) {
|
||||
return originalOnreadystatechange.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
return originalSend.apply(this, args);
|
||||
};
|
||||
|
||||
// 알려진 웹툰 이미지 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, protocolDomain || document.URL).href;
|
||||
const hostname = new URL(absoluteUrl).hostname;
|
||||
if (protocolDomain) {
|
||||
const pageHost = new URL(protocolDomain).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 => {
|
||||
const valid = bitmap.height > bitmap.width && bitmap.height > 500;
|
||||
bitmap.close();
|
||||
return valid;
|
||||
}).catch(() => true);
|
||||
};
|
||||
|
||||
// 이미지 로딩을 기다리고 lazy load 트리거
|
||||
const waitForImagesToLoad = (targetDocument, timeoutMs = 20000) => {
|
||||
return new Promise(resolve => {
|
||||
const container = targetDocument.querySelector('#bo_v_con, .view-content, #toon_img, .board-view, .vw-imgs') || targetDocument.body;
|
||||
const baseUrl = targetDocument.URL;
|
||||
|
||||
const allImages = container.querySelectorAll('img');
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
let site = '';
|
||||
let protocolDomain = '';
|
||||
let isListPage = false;
|
||||
|
||||
// 현재 url 체크
|
||||
const currentURL = document.URL;
|
||||
if (currentURL.includes('/manhwa/') || currentURL.includes('/webtoon/') || currentURL.includes('/comic/')) {
|
||||
const pathParts = new URL(currentURL).pathname.split('/').filter(p => p);
|
||||
if (pathParts.length <= 2) {
|
||||
site = "마나토끼_목록";
|
||||
isListPage = true;
|
||||
} else {
|
||||
site = "마나토끼";
|
||||
isListPage = false;
|
||||
}
|
||||
protocolDomain = currentURL.match(/^https?:\/\/[^:/?]+/)[0];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// 메타데이터 추출 함수 (목록 페이지용)
|
||||
const extractListMetadata = () => {
|
||||
let title = '';
|
||||
let writer = '';
|
||||
let summary = '';
|
||||
let tags = [];
|
||||
|
||||
// Title
|
||||
const titleSpan = document.querySelector('.view-content b');
|
||||
if (titleSpan) {
|
||||
title = titleSpan.innerText.trim();
|
||||
} else {
|
||||
const h1 = document.querySelector('h1');
|
||||
if (h1) title = h1.innerText.split('>')[0].trim();
|
||||
}
|
||||
|
||||
// Writer, Tags, Summary
|
||||
const viewContents = document.querySelectorAll('.view-content');
|
||||
viewContents.forEach(el => {
|
||||
const text = el.innerText;
|
||||
if (text.includes('작가')) {
|
||||
const a = el.querySelector('a');
|
||||
if (a) writer = a.innerText.trim();
|
||||
} else if (text.includes('분류')) {
|
||||
const tagLinks = el.querySelectorAll('a');
|
||||
tagLinks.forEach(a => tags.push(a.innerText.trim()));
|
||||
} else if (text.includes('줄거리')) {
|
||||
summary = text.replace(/.*줄거리\s*:/, '').trim();
|
||||
}
|
||||
});
|
||||
|
||||
return { title, writer, summary, tags };
|
||||
};
|
||||
|
||||
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const generateComicInfo = (title, series, writer, number, pageCount, imageCount = 0, summary = '', tags = []) => {
|
||||
let pagesXml = '';
|
||||
if (imageCount > 0) {
|
||||
for (let i = 0; i < imageCount; i++) {
|
||||
pagesXml += ` <Page Image="${i}" Type="Story"/>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
let genreStr = tags.length > 0 ? tags.join(', ') : '';
|
||||
|
||||
return `<?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}</Summary>
|
||||
<Notes></Notes>
|
||||
<Year>-1</Year>
|
||||
<Month>-1</Month>
|
||||
<Writer>${writer}</Writer>
|
||||
<PageCount>${pageCount}</PageCount>
|
||||
<Genre>${genreStr}</Genre>
|
||||
<LanguageISO>ko</LanguageISO>
|
||||
<Manga>Yes</Manga>
|
||||
${pagesXml ? ` <Pages>\n${pagesXml} </Pages>\n` : ''}</ComicInfo>`;
|
||||
};
|
||||
|
||||
// DOM에서 이미지 URL 추출 헬퍼 함수
|
||||
const extractImagesFromDom = (targetDocument, protocolDomain) => {
|
||||
let imageUrls = [];
|
||||
|
||||
// 1. img 태그 탐색
|
||||
const contentDiv = targetDocument.querySelector('#bo_v_con') || targetDocument.querySelector('.view-content') || targetDocument.querySelector('#toon_img') || targetDocument.querySelector('.board-view');
|
||||
let imgElements = Array.from(targetDocument.querySelectorAll('#bo_v_con img, .view-content img, .view-wrap img, .toon-img img, .item-image img, .board-view img, #toon_img img, .view-padding img'));
|
||||
|
||||
if (contentDiv && imgElements.length === 0) {
|
||||
imgElements = Array.from(contentDiv.querySelectorAll('img'));
|
||||
}
|
||||
|
||||
// 마나토끼 등 lazyload 이미지 처리
|
||||
imgElements.forEach(img => {
|
||||
if (img.dataset.src) img.src = img.dataset.src;
|
||||
if (img.dataset.original) img.src = img.dataset.original;
|
||||
if (img.getAttribute('data-original')) img.src = img.getAttribute('data-original');
|
||||
});
|
||||
|
||||
imgElements.forEach(img => {
|
||||
let src = img.src || img.getAttribute('data-src') || img.getAttribute('data-original');
|
||||
for (let attr of img.attributes) {
|
||||
if (attr.name.startsWith('data-') && (attr.value.includes('http') || attr.value.includes('/data/'))) {
|
||||
src = attr.value;
|
||||
}
|
||||
}
|
||||
if (src && !src.includes('gif') && !src.includes('logo') && !src.includes('banner')) {
|
||||
if (src.startsWith('//')) src = 'https:' + src;
|
||||
else if (src.startsWith('/')) src = protocolDomain + src;
|
||||
imageUrls.push(src);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. css background-image 탐색
|
||||
const divElements = Array.from(targetDocument.querySelectorAll('#bo_v_con div, .view-content div, .toon-img div'));
|
||||
divElements.forEach(div => {
|
||||
const bg = div.style.backgroundImage || window.getComputedStyle(div).backgroundImage;
|
||||
if (bg && bg !== 'none') {
|
||||
const match = bg.match(/url\(["']?(.*?)["']?\)/);
|
||||
if (match && match[1] && !match[1].includes('gif')) {
|
||||
let src = match[1];
|
||||
if (src.startsWith('//')) src = 'https:' + src;
|
||||
else if (src.startsWith('/')) src = protocolDomain + src;
|
||||
imageUrls.push(src);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Canvas 탐색
|
||||
if (imageUrls.length === 0) {
|
||||
const canvasElements = Array.from(targetDocument.querySelectorAll('canvas'));
|
||||
canvasElements.forEach(canvas => {
|
||||
try {
|
||||
const dataUrl = canvas.toDataURL('image/jpeg');
|
||||
if (dataUrl && dataUrl.length > 1000) {
|
||||
imageUrls.push(dataUrl);
|
||||
}
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. HTML 원문 정규식 탐색 (Fallback)
|
||||
if (imageUrls.length === 0) {
|
||||
const html = contentDiv ? contentDiv.innerHTML : targetDocument.documentElement.innerHTML;
|
||||
|
||||
const matches1 = html.match(/https?:\/\/[^"'\s>]*\.(jpg|jpeg|png|webp)[^"'\s>]*/gi) || [];
|
||||
const matches2 = html.match(/(?:\/data\/file\/[a-zA-Z0-9_/-]+|img[0-9]*\.[a-zA-Z0-9-]+\.[a-zA-Z]+\/data\/file\/[a-zA-Z0-9_/-]+)\.(jpg|jpeg|png|webp)/gi) || [];
|
||||
const matches3 = html.match(/\/toki[^"'\s>]+\.(jpg|jpeg|png|webp)/gi) || [];
|
||||
const matches4 = html.match(/(?:data-src|data-original)=["']([^"']+)["']/gi) || [];
|
||||
|
||||
let allMatches = [...matches1, ...matches2, ...matches3];
|
||||
matches4.forEach(m => {
|
||||
const urlMatch = m.match(/=["']([^"']+)["']/);
|
||||
if (urlMatch && urlMatch[1] && !urlMatch[1].includes('gif') && !urlMatch[1].includes('logo') && !urlMatch[1].includes('banner')) {
|
||||
allMatches.push(urlMatch[1]);
|
||||
}
|
||||
});
|
||||
|
||||
if (allMatches.length > 0) {
|
||||
imageUrls = imageUrls.concat(allMatches.map(url => {
|
||||
if (url.startsWith('//')) return 'https:' + url;
|
||||
if (url.startsWith('/')) return protocolDomain + url;
|
||||
if (!url.startsWith('http')) return protocolDomain + '/' + url;
|
||||
return url;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 제거 및 최종 필터링
|
||||
imageUrls = [...new Set(imageUrls)].filter(url => {
|
||||
if (url.startsWith('data:image')) return true;
|
||||
return !url.includes('logo') && !url.includes('banner') && !url.includes('icon') && !url.includes('no_img') && !url.includes('profile') && !url.includes('blank') && url.length > 25 && isAllowedImageHost(url);
|
||||
});
|
||||
|
||||
return imageUrls;
|
||||
};
|
||||
|
||||
// 이미지 다운로드 및 압축 헬퍼 함수
|
||||
const processAndDownloadZip = async (title, imageUrls, imageBlobs, metadata = null, index = '1') => {
|
||||
const zip = new JSZip();
|
||||
|
||||
// 1. Blob 사용 시 (가장 우선)
|
||||
if (imageBlobs && imageBlobs.length > 0) {
|
||||
log(`[다운로드] Blob 이미지 ${imageBlobs.length}개 사용`);
|
||||
imageBlobs.forEach((blob, i) => {
|
||||
const filename = `image${String(i).padStart(4, '0')}.jpg`;
|
||||
zip.file(filename, blob);
|
||||
});
|
||||
const series = metadata && metadata.title ? metadata.title : title;
|
||||
const writer = metadata && metadata.writer ? metadata.writer : '';
|
||||
const summary = metadata && metadata.summary ? metadata.summary : '';
|
||||
const tags = metadata && metadata.tags ? metadata.tags : [];
|
||||
const comicInfoContent = generateComicInfo(title, series, writer, index, imageBlobs.length, imageBlobs.length, summary, tags);
|
||||
zip.file('ComicInfo.xml', comicInfoContent);
|
||||
|
||||
let safeTitle = title.replace(/[\\/:*?"<>|\n\r]/g, '').trim();
|
||||
// e.g., '마돈나는 유리 케이스 안에 1화' -> '마돈나는 유리 케이스 안에_1화'
|
||||
if (safeTitle.match(/\s+[\d\.]+(화|회|권)$/)) {
|
||||
safeTitle = safeTitle.replace(/\s+([\d\.]+(화|회|권))$/, '_$1');
|
||||
}
|
||||
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
downloadZip(content, `${safeTitle}.zip`);
|
||||
downloadLogFile();
|
||||
log(`[완료] ${safeTitle} 다운로드 완료`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. URL 사용 시
|
||||
if (!imageUrls || imageUrls.length === 0) {
|
||||
log(`[스킵] ${title} - 이미지를 찾을 수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`[다운로드] URL 이미지 ${imageUrls.length}개 사용`);
|
||||
const downloadImageTask = (url, index) => {
|
||||
return new Promise((resolve) => {
|
||||
const filename = `image${String(index).padStart(4, '0')}.jpg`;
|
||||
|
||||
if (url.startsWith('data:image')) {
|
||||
try {
|
||||
const base64Data = url.split(',')[1];
|
||||
zip.file(filename, base64Data, {base64: true});
|
||||
} catch(e) {}
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
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 headers = response.responseHeaders || '';
|
||||
const contentType = (headers.match(/content-type:\s*([^\r\n;]+)/i) || [])[1] || '';
|
||||
if (contentType && !contentType.startsWith('image/')) {
|
||||
log('[스킵] 이미지가 아닌 응답:', url, contentType);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([response.response], { type: 'image/jpeg' });
|
||||
isComicPageBlob(blob).then(valid => {
|
||||
if (valid) {
|
||||
zip.file(filename, blob);
|
||||
} else {
|
||||
log('[스킵] 광고 배너로 의심됨:', url);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
onerror: () => resolve(),
|
||||
ontimeout: () => resolve()
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 5개씩 동시 다운로드
|
||||
let promiseList = [];
|
||||
for (let j = 0; j < imageUrls.length; j++) {
|
||||
promiseList.push(downloadImageTask(imageUrls[j], j));
|
||||
if (promiseList.length >= 5) {
|
||||
await Promise.all(promiseList);
|
||||
promiseList = [];
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
if (promiseList.length > 0) {
|
||||
await Promise.all(promiseList);
|
||||
}
|
||||
|
||||
const series = metadata && metadata.title ? metadata.title : title;
|
||||
const writer = metadata && metadata.writer ? metadata.writer : '';
|
||||
const summary = metadata && metadata.summary ? metadata.summary : '';
|
||||
const tags = metadata && metadata.tags ? metadata.tags : [];
|
||||
const comicInfoContent = generateComicInfo(title, series, writer, index, imageUrls.length, imageUrls.length, summary, tags);
|
||||
zip.file('ComicInfo.xml', comicInfoContent);
|
||||
|
||||
// title format is expected to be something like '제목 1화' or '제목_1화'
|
||||
// If it's already '마돈나는 유리 케이스 안에 1화', using it directly is fine.
|
||||
// We'll replace spaces before the episode number with an underscore if the user strictly wants '제목_1화.zip'
|
||||
let safeTitle = title.replace(/[\\/:*?"<>|\n\r]/g, '').trim();
|
||||
// e.g., '마돈나는 유리 케이스 안에 1화' -> '마돈나는 유리 케이스 안에_1화'
|
||||
if (safeTitle.match(/\s+[\d\.]+(화|회|권)$/)) {
|
||||
safeTitle = safeTitle.replace(/\s+([\d\.]+(화|회|권))$/, '_$1');
|
||||
}
|
||||
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
downloadZip(content, `${safeTitle}.zip`);
|
||||
downloadLogFile();
|
||||
log(`[완료] ${safeTitle} 다운로드 완료`);
|
||||
};
|
||||
|
||||
// 에피소드 하나 다운로드 처리 (iframe + HTTP fallback)
|
||||
async function downloadEpisode(episodeTitle, url, metadata = null, index = '1') {
|
||||
try {
|
||||
log(`[진행 중] ${episodeTitle}`);
|
||||
|
||||
let imageUrls = [];
|
||||
|
||||
// 1. iframe 방식 시도
|
||||
try {
|
||||
imageUrls = await downloadViaIframe(episodeTitle, url);
|
||||
if (imageUrls.length > 0) log(`✓ iframe 방식 성공: ${imageUrls.length}개`);
|
||||
} catch (err) {
|
||||
log(`[iframe 오류] ${episodeTitle}:`, err.message);
|
||||
}
|
||||
|
||||
// 2. HTTP fetch fallback
|
||||
if (imageUrls.length === 0) {
|
||||
log(`[HTTP fallback] ${episodeTitle}`);
|
||||
try {
|
||||
imageUrls = await fetchHtmlAndExtract(url);
|
||||
if (imageUrls.length > 0) log(`✓ HTTP fallback 성공: ${imageUrls.length}개`);
|
||||
} catch (err) {
|
||||
log(`[HTTP fallback 오류] ${episodeTitle}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
log(`[스킵] ${episodeTitle} - 이미지를 찾을 수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await processAndDownloadZip(episodeTitle, imageUrls, [], metadata, index);
|
||||
} catch (err) {
|
||||
log(`[오류] ${episodeTitle}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadViaIframe(episodeTitle, url) {
|
||||
return new Promise((resolve) => {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.cssText = 'width:100%;height:90vh;border:2px solid red;border-radius:8px;margin:10px 0';
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
let resolved = false;
|
||||
const cleanup = () => {
|
||||
try { if (iframe.parentNode) iframe.parentNode.removeChild(iframe); } catch (_) {}
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
const done = (result) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) { log(`[iframe 타임아웃] ${episodeTitle}`); done([]); }
|
||||
}, 35000);
|
||||
|
||||
iframe.addEventListener('load', () => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const iframeWin = iframe.contentWindow;
|
||||
const iframeDoc = iframeWin.document;
|
||||
|
||||
// fetch 인터셉터 주입 (lazy load 이미지 캡처용)
|
||||
try {
|
||||
const script = iframeDoc.createElement('script');
|
||||
script.textContent = `
|
||||
window.__capturedUrls = [];
|
||||
window.__capturedBlobs = [];
|
||||
const ___origFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
const u = typeof args[0] === 'string' ? args[0] : args[0]?.url;
|
||||
if (u && (u.includes('.jpg') || u.includes('.jpeg') || u.includes('.png') || u.includes('.webp')) && !u.includes('logo') && !u.includes('banner')) {
|
||||
if (!window.__capturedUrls.includes(u)) window.__capturedUrls.push(u);
|
||||
}
|
||||
return ___origFetch.apply(this, args);
|
||||
};
|
||||
`;
|
||||
iframeDoc.body.appendChild(script);
|
||||
} catch (e) {}
|
||||
|
||||
// 스크롤로 lazy load 트리거
|
||||
let lastScroll = -1;
|
||||
let scrollAttempts = 0;
|
||||
while (scrollAttempts < 50) {
|
||||
iframeWin.scrollBy(0, window.innerHeight || 800);
|
||||
await sleep(300);
|
||||
const curScroll = iframeWin.scrollY || 0;
|
||||
const maxScroll = Math.max(
|
||||
iframeDoc.documentElement.scrollHeight - iframeWin.innerHeight,
|
||||
iframeDoc.body.scrollHeight - iframeWin.innerHeight,
|
||||
0
|
||||
);
|
||||
if (curScroll >= maxScroll || curScroll === lastScroll) break;
|
||||
lastScroll = curScroll;
|
||||
scrollAttempts++;
|
||||
}
|
||||
|
||||
await waitForImagesToLoad(iframeDoc);
|
||||
|
||||
let urls = extractImagesFromDom(iframeDoc, protocolDomain);
|
||||
|
||||
if (urls.length === 0 && iframeWin.__capturedUrls && iframeWin.__capturedUrls.length > 0) {
|
||||
urls = [...iframeWin.__capturedUrls].filter(u => isAllowedImageHost(u));
|
||||
}
|
||||
|
||||
done([...new Set(urls)]);
|
||||
} catch (err) {
|
||||
log(`[iframe 처리 오류] ${episodeTitle}:`, err.message);
|
||||
done([]);
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
iframe.addEventListener('error', () => {
|
||||
log(`[iframe 로드 오류] ${episodeTitle}`);
|
||||
done([]);
|
||||
});
|
||||
|
||||
iframe.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchHtmlAndExtract(url) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Referer': protocolDomain,
|
||||
'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) return [];
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
return extractImagesFromDom(doc, protocolDomain);
|
||||
}
|
||||
|
||||
async function tokiDownload() {
|
||||
try {
|
||||
if (isListPage) {
|
||||
log('=== 목록 페이지 처리 시작 ===');
|
||||
|
||||
const metadata = extractListMetadata();
|
||||
log('추출된 메타데이터:', metadata);
|
||||
|
||||
let episodeList = document.querySelector('.serial-list') || document.querySelector('ul.episode-list') || document.querySelector('.list-body') || document.querySelector('.board-list') || document.querySelector('.list-container') || document.querySelector('#bo_list') || document.querySelector('.comic-serial-list') || document.querySelector('.toon-list') || document.querySelector('.episode-wrap');
|
||||
|
||||
// Manatoki fallback: If list container is not found, try to find any links that look like episodes
|
||||
let items = [];
|
||||
if (episodeList) {
|
||||
items = Array.from(episodeList.querySelectorAll('li, tr, .list-item, .item, .list-row'));
|
||||
} else {
|
||||
// Fallback to all episode-like links on the page if no container is found
|
||||
const fallbackLinks = Array.from(document.querySelectorAll('a.item-subject, a[href*="/comic/"], a[href*="/webtoon/"], a[href*="/manhwa/"]'));
|
||||
if (fallbackLinks.length > 0) {
|
||||
const filteredLinks = fallbackLinks.filter(link => {
|
||||
const text = (link.innerText || link.textContent || '').replace(/\s+/g, '');
|
||||
return !text.includes('업데이트') && !text.includes('최신화') && !text.includes('첫화') && !text.includes('첫회') && text.length > 0;
|
||||
});
|
||||
|
||||
items = filteredLinks.map(link => link.closest('li, tr, div') || link);
|
||||
if (items.length > 0) {
|
||||
episodeList = true; // Pretend we found the list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!episodeList || items.length === 0) {
|
||||
alert('에피소드 목록을 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const episodes = [];
|
||||
|
||||
items.forEach((item) => {
|
||||
const button = item.querySelector ? item.querySelector('button[onclick*="location.href"]') : null;
|
||||
const link = (item.tagName && item.tagName.toLowerCase() === 'a') ? item : (item.querySelector ? (item.querySelector('a[href*="wr_id="]') || item.querySelector('a.item-subject') || item.querySelector('a[href*="/comic/"]') || item.querySelector('a[href*="/webtoon/"]') || item.querySelector('a[href*="/manhwa/"]')) : null);
|
||||
|
||||
if (button) {
|
||||
const onclick = button.getAttribute('onclick') || '';
|
||||
const match = onclick.match(/location\.href=['"]([^'"]+)['"]/);
|
||||
if (match && match[1]) {
|
||||
const absoluteUrl = new URL(match[1], window.location.href).href;
|
||||
const episodeTitle = item.innerText.split('\n')[0].trim();
|
||||
if (episodeTitle && absoluteUrl) {
|
||||
episodes.push({ title: episodeTitle, url: absoluteUrl });
|
||||
}
|
||||
}
|
||||
} else if (link && link.href) {
|
||||
let episodeTitle = '';
|
||||
try {
|
||||
let clone = link.cloneNode(true);
|
||||
if (clone.querySelectorAll) {
|
||||
clone.querySelectorAll('span, img, i, b, strong').forEach(s => s.remove());
|
||||
}
|
||||
episodeTitle = (clone.innerText || clone.textContent || '').trim();
|
||||
} catch (e) {}
|
||||
|
||||
if (!episodeTitle) episodeTitle = (link.innerText || link.textContent || '').trim();
|
||||
|
||||
// Clean up stray comment counts or numbers attached to title for Manatoki
|
||||
episodeTitle = episodeTitle.replace(/^[0-9]+\s*/, '').replace(/\s*[0-9]+$/, '').trim();
|
||||
|
||||
if (!episodeTitle || episodeTitle === '') {
|
||||
episodeTitle = (item.innerText || item.textContent || '').trim().split('\n')[0].trim() || '제목없음';
|
||||
episodeTitle = episodeTitle.replace(/^[0-9]+\s*/, '').replace(/\s*[0-9]+$/, '').trim();
|
||||
}
|
||||
|
||||
if (episodeTitle && link.href) {
|
||||
episodes.push({ title: episodeTitle, url: link.href });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueEpisodes = [];
|
||||
const urlSet = new Set();
|
||||
episodes.forEach(ep => {
|
||||
const wrIdMatch = ep.url.match(/wr_id=([0-9]+)/);
|
||||
// Match /comic/12345 or /manhwa/11017/111796
|
||||
const manatokiMatch = ep.url.match(/\/(?:comic|webtoon|manhwa)\/(?:[0-9]+\/)?([0-9]+)/);
|
||||
const uniqueKey = wrIdMatch ? wrIdMatch[1] : (manatokiMatch ? manatokiMatch[1] : ep.url);
|
||||
if (!urlSet.has(uniqueKey)) {
|
||||
urlSet.add(uniqueKey);
|
||||
uniqueEpisodes.push(ep);
|
||||
}
|
||||
});
|
||||
|
||||
uniqueEpisodes.reverse();
|
||||
|
||||
if (uniqueEpisodes.length === 0) {
|
||||
alert('에피소드 링크를 추출할 수 없습니다. (구조가 변경되었을 수 있습니다.)');
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < uniqueEpisodes.length; i++) {
|
||||
// 회차 번호: 오래된 것(i=0)부터 1부터 시작 (reverse를 한 이후이므로)
|
||||
const episodeIndex = String(i + 1);
|
||||
await downloadEpisode(uniqueEpisodes[i].title, uniqueEpisodes[i].url, metadata, episodeIndex);
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
alert('모든 에피소드 다운로드가 완료되었습니다.');
|
||||
|
||||
} else {
|
||||
const titleElement = document.querySelector('h1') || document.querySelector('.view-title') || document.querySelector('title');
|
||||
let title = titleElement ? titleElement.innerText.trim() : '제목없음';
|
||||
|
||||
// Manatoki: "아사코 30화 > 마나토끼..." -> "아사코 30화"
|
||||
if (title.includes('>')) {
|
||||
title = title.split('>')[0].trim();
|
||||
}
|
||||
|
||||
log(`=== 단일 다운로드 시작: ${title} ===`);
|
||||
|
||||
// 단일 페이지에서는 이전 화 버튼 등을 통해 전체 구조를 알기 힘드므로
|
||||
// 현재 페이지에서 알 수 있는 메타데이터만 구성합니다.
|
||||
let seriesName = title;
|
||||
if (title.includes('화')) {
|
||||
seriesName = title.substring(0, title.lastIndexOf(' '));
|
||||
}
|
||||
const metadata = { title: seriesName, writer: '', summary: '', tags: [] };
|
||||
|
||||
// 제목에서 숫자를 추출해 회차로 설정해봅니다.
|
||||
let episodeIndex = '1';
|
||||
const numMatch = title.match(/(\d+)(화|회)/);
|
||||
if (numMatch) episodeIndex = numMatch[1];
|
||||
|
||||
// lazy load 트리거: 페이지를 스크롤하고 이미지 로딩 대기
|
||||
let lastScroll = -1;
|
||||
let scrollAttempts = 0;
|
||||
while (scrollAttempts < 20) {
|
||||
window.scrollBy(0, window.innerHeight || 800);
|
||||
await sleep(300);
|
||||
if (window.scrollY === lastScroll) break;
|
||||
lastScroll = window.scrollY;
|
||||
scrollAttempts++;
|
||||
}
|
||||
await waitForImagesToLoad(document);
|
||||
// 스크롤 원복
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
let imageBlobs = window.capturedImageBlobs || [];
|
||||
let imageUrls = [];
|
||||
|
||||
if (imageBlobs.length === 0) {
|
||||
imageUrls = extractImagesFromDom(document, protocolDomain);
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
imageUrls = window.capturedImageUrls || [];
|
||||
}
|
||||
}
|
||||
|
||||
if (imageBlobs.length === 0 && (!imageUrls || imageUrls.length === 0)) {
|
||||
// One last fallback to just grab ALL images in the document if view-content fails (for weird DOM structures)
|
||||
if (imageUrls.length === 0) {
|
||||
const fallbackImgs = Array.from(document.querySelectorAll('img[src], img[data-src], img[data-original]'));
|
||||
fallbackImgs.forEach(img => {
|
||||
let src = img.src || img.getAttribute('data-src') || img.getAttribute('data-original');
|
||||
if (src && !src.includes('gif') && !src.includes('logo') && !src.includes('banner') && isAllowedImageHost(src)) {
|
||||
if (src.startsWith('//')) src = 'https:' + src;
|
||||
else if (src.startsWith('/')) src = protocolDomain + src;
|
||||
imageUrls.push(src);
|
||||
}
|
||||
});
|
||||
imageUrls = [...new Set(imageUrls)].filter(url => url.length > 25);
|
||||
}
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
log('❌ 이미지를 감지하지 못했습니다.');
|
||||
log(`capturedImageUrls: ${(window.capturedImageUrls || []).length}개`);
|
||||
log(`capturedImageBlobs: ${(window.capturedImageBlobs || []).length}개`);
|
||||
try { downloadLogFile(); } catch (_) {}
|
||||
alert(`❌ 이미지를 감지하지 못했습니다.\n🔍 저장된 로그 파일을 확인하세요.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await processAndDownloadZip(title, imageUrls, imageBlobs, metadata, episodeIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`오류발생: \n` + error);
|
||||
log(error);
|
||||
}
|
||||
}
|
||||
|
||||
const safeDownload = async () => {
|
||||
try {
|
||||
await tokiDownload();
|
||||
} catch (e) {
|
||||
log('❌ 치명적 오류:', e);
|
||||
try { downloadLogFile(); } catch (_) {}
|
||||
}
|
||||
};
|
||||
|
||||
if (isListPage) {
|
||||
GM_registerMenuCommand('[마나토끼] 전체 에피소드 다운로드', safeDownload);
|
||||
} else {
|
||||
GM_registerMenuCommand('[마나토끼] 현재 에피소드 다운로드', safeDownload);
|
||||
}
|
||||
GM_registerMenuCommand('[마나토끼] 로그 저장', () => downloadLogFile());
|
||||
})();
|
||||
655
Old_Scripts/ntk01Downloader.user copy.js
Normal file
@@ -0,0 +1,655 @@
|
||||
// ==UserScript==
|
||||
// @name ntk01 Downloader
|
||||
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
|
||||
// @version 0.0.1
|
||||
// @description ntk01.com 웹툰/만화 다운로더
|
||||
// @author hehaho
|
||||
// @match https://ntk01.com/*
|
||||
// @match https://www.ntk01.com/*
|
||||
// @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad
|
||||
// @grant GM_registerMenuCommand
|
||||
// @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 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);
|
||||
}
|
||||
};
|
||||
|
||||
// 글로벌 이미지 저장소 (네트워크 모니터링용)
|
||||
window.capturedImageUrls = [];
|
||||
window.capturedImageBlobs = [];
|
||||
|
||||
// fetch 인터셉트 (이미지 Blob 직접 캡처)
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
|
||||
const isImageUrl = url && (url.includes('.jpg') || url.includes('.png') || url.includes('.webp') || url.includes('.jpeg')) &&
|
||||
!url.includes('logo') && !url.includes('banner') && !url.includes('icon') && !url.includes('thumb');
|
||||
|
||||
if (isImageUrl && !window.capturedImageUrls.includes(url)) {
|
||||
window.capturedImageUrls.push(url);
|
||||
}
|
||||
|
||||
const result = originalFetch.apply(this, args);
|
||||
|
||||
// Blob 응답 캡처 (Canvas Tainted 우회)
|
||||
if (isImageUrl && result instanceof Promise) {
|
||||
result.then(response => {
|
||||
if (response.ok && (response.headers.get('content-type')?.includes('image') || url.match(/\.(jpg|jpeg|png|webp)$/i))) {
|
||||
const clonedResponse = response.clone();
|
||||
clonedResponse.blob().then(blob => {
|
||||
if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) {
|
||||
window.capturedImageBlobs.push(blob);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
return response;
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// XMLHttpRequest 인터셉트 (arraybuffer 응답 캡처)
|
||||
const originalOpen = XMLHttpRequest.prototype.open;
|
||||
const originalSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
||||
this._captureUrl = url;
|
||||
return originalOpen.apply(this, [method, url, ...rest]);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function(...args) {
|
||||
const xhr = this;
|
||||
const originalOnreadystatechange = xhr.onreadystatechange;
|
||||
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
const url = xhr._captureUrl;
|
||||
const isImageUrl = url && (url.includes('.jpg') || url.includes('.png') || url.includes('.webp') || url.includes('.jpeg')) &&
|
||||
!url.includes('logo') && !url.includes('banner') && !url.includes('icon') && !url.includes('thumb');
|
||||
|
||||
if (isImageUrl && xhr.responseType === 'arraybuffer') {
|
||||
try {
|
||||
const blob = new Blob([xhr.response], { type: 'image/jpeg' });
|
||||
if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) {
|
||||
window.capturedImageBlobs.push(blob);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (originalOnreadystatechange) {
|
||||
return originalOnreadystatechange.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
return originalSend.apply(this, args);
|
||||
};
|
||||
|
||||
// 현재 URL 체크
|
||||
const currentURL = document.URL;
|
||||
|
||||
// ntk01이 아니면 스크립트 실행 안 함
|
||||
if (!currentURL.includes('ntk01.com')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 페이지 타입 감지
|
||||
const pagePathParts = currentURL.split('/').filter(p => p);
|
||||
// URL을 더 정확히 분석 (https://ntk01.com/manhwa/11017/111796 형식)
|
||||
const pathMatch = currentURL.match(/\/manhwa\/(\d+)(?:\/(\d+))?/);
|
||||
const isEpisodePage = pathMatch && pathMatch[2] ? true : false; // 에피소드 번호가 있으면 에피소드 페이지
|
||||
const isListPage = pathMatch && !pathMatch[2] ? true : false; // 에피소드 번호가 없으면 목록 페이지
|
||||
|
||||
// 도메인 추출
|
||||
const getProtocolDomain = () => {
|
||||
return currentURL.match(/^https?:\/\/[^:/?]+/)[0];
|
||||
};
|
||||
|
||||
// 상대 URL을 절대 URL로 변환
|
||||
const resolveUrl = (url) => {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
const protocolDomain = getProtocolDomain();
|
||||
if (url.startsWith('/')) {
|
||||
return protocolDomain + url;
|
||||
}
|
||||
// 상대주소 처리
|
||||
const currentPageUrl = currentURL.split('?')[0];
|
||||
const currentPath = currentPageUrl.substring(protocolDomain.length);
|
||||
const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/') + 1);
|
||||
return protocolDomain + currentDir + url;
|
||||
};
|
||||
|
||||
// 이미지 수집 함수 (에피소드 읽기 페이지)
|
||||
const collectImagesFromEpisodePage = () => {
|
||||
// ntk01.com 에피소드 페이지의 이미지 구조에 맞춘 선택자
|
||||
const imgElements = document.querySelectorAll('.vw-imgs img, .comic-viewer img, [class*="viewer"] img, .content img, .page img');
|
||||
|
||||
imgElements.forEach(img => {
|
||||
const src = img.src || img.dataset.src || img.getAttribute('src');
|
||||
if (src) {
|
||||
const absoluteUrl = resolveUrl(src);
|
||||
if (absoluteUrl && !window.capturedImageUrls.includes(absoluteUrl)) {
|
||||
window.capturedImageUrls.push(absoluteUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 에피소드 페이지에서 이미지 추출
|
||||
const extractImagesFromEpisodeHtml = (html) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const images = [];
|
||||
|
||||
const imgElements = doc.querySelectorAll('.vw-imgs img, .comic-viewer img, [class*="viewer"] img, .content img, .page img');
|
||||
imgElements.forEach(img => {
|
||||
const src = img.src || img.dataset.src || img.getAttribute('src');
|
||||
if (src) {
|
||||
const absoluteUrl = resolveUrl(src);
|
||||
if (absoluteUrl && !images.includes(absoluteUrl)) {
|
||||
images.push(absoluteUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return images;
|
||||
};
|
||||
|
||||
// 이미지 수집 함수 (목록 페이지)
|
||||
const collectImagesFromListPage = () => {
|
||||
// 목록 페이지의 썸네일/장편
|
||||
const imgElements = document.querySelectorAll('.list-item img, .episode img, .thumbnail img, [class*="thumb"] img');
|
||||
|
||||
imgElements.forEach(img => {
|
||||
const src = img.src || img.dataset.src || img.getAttribute('src');
|
||||
if (src && !src.includes('logo') && !src.includes('banner') && !src.includes('icon')) {
|
||||
const absoluteUrl = resolveUrl(src);
|
||||
if (absoluteUrl && !window.capturedImageUrls.includes(absoluteUrl)) {
|
||||
window.capturedImageUrls.push(absoluteUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 이미지 수집 함수 (선택)
|
||||
const collectImagesFromDOM = () => {
|
||||
if (isEpisodePage) {
|
||||
collectImagesFromEpisodePage();
|
||||
} else if (isListPage) {
|
||||
collectImagesFromListPage();
|
||||
}
|
||||
};
|
||||
|
||||
// comicinfo.xml 생성 함수
|
||||
const generateComicInfoXml = (title, seriesTitle = '', episodeNumber = '', writer = '', imageCount) => {
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().split('T')[0];
|
||||
|
||||
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>
|
||||
<Count></Count>
|
||||
<Volume></Volume>
|
||||
<AlternateSeries></AlternateSeries>
|
||||
<AlternateNumber></AlternateNumber>
|
||||
<AlternateCount></AlternateCount>
|
||||
<Summary></Summary>
|
||||
<Notes></Notes>
|
||||
<Year></Year>
|
||||
<Month></Month>
|
||||
<Day></Day>
|
||||
<Writer>${escapeXml(writer)}</Writer>
|
||||
<Penciller></Penciller>
|
||||
<Inker></Inker>
|
||||
<Colorist></Colorist>
|
||||
<Letterer></Letterer>
|
||||
<CoverArtist></CoverArtist>
|
||||
<Publisher></Publisher>
|
||||
<Imprint></Imprint>
|
||||
<Genre></Genre>
|
||||
<Tags></Tags>
|
||||
<Web></Web>
|
||||
<PageCount>${imageCount}</PageCount>
|
||||
<LanguageISO>ko</LanguageISO>
|
||||
<Format>Manga</Format>
|
||||
<BlackAndWhite>No</BlackAndWhite>
|
||||
<Manga></Manga>
|
||||
<Characters></Characters>
|
||||
<Teams></Teams>
|
||||
<Locations></Locations>
|
||||
<ScanInformation></ScanInformation>
|
||||
<StoryArc></StoryArc>
|
||||
<SeriesGroup></SeriesGroup>
|
||||
<AgeRating>Unknown</AgeRating>
|
||||
<CommunityRating></CommunityRating>
|
||||
<MainCharacterOrTeam></MainCharacterOrTeam>
|
||||
<Review></Review>
|
||||
</ComicInfo>`;
|
||||
|
||||
return comicInfo;
|
||||
};
|
||||
|
||||
// XML 특수문자 이스케이프 함수
|
||||
const escapeXml = (str) => {
|
||||
if (!str) return '';
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// 현재 페이지에서 시리즈 제목 추출
|
||||
const getSeriesTitle = () => {
|
||||
const workEl = document.querySelector('.vw-work');
|
||||
return workEl ? workEl.textContent.trim() : '';
|
||||
};
|
||||
|
||||
// 현재 페이지에서 에피소드 제목 추출
|
||||
const getEpisodeTitle = () => {
|
||||
const epEl = document.querySelector('.vw-ep');
|
||||
if (!epEl) return '';
|
||||
|
||||
const span = epEl.querySelector('span');
|
||||
const episodeTitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : '';
|
||||
return episodeTitle;
|
||||
};
|
||||
|
||||
const GetEpisodeNumberFromTitle = (title) => {
|
||||
const epEl = document.querySelector('.vw-ep');
|
||||
if (!epEl) return '';
|
||||
|
||||
const strong = epEl.querySelector('strong');
|
||||
const episodeNum = strong ? strong.textContent.trim() : '';
|
||||
return episodeNum;
|
||||
};
|
||||
|
||||
// HTML에서 시리즈 제목 추출
|
||||
const getSeriesTitleFromHtml = (html) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const workEl = doc.querySelector('.vw-work');
|
||||
return workEl ? workEl.textContent.trim() : '';
|
||||
};
|
||||
|
||||
// HTML에서 에피소드 제목 추출
|
||||
const getEpisodeTitleFromHtml = (html) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const epEl = doc.querySelector('.vw-ep');
|
||||
if (!epEl) return '';
|
||||
|
||||
const span = epEl.querySelector('span');
|
||||
const episodeTitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : '';
|
||||
return episodeTitle;
|
||||
};
|
||||
|
||||
// HTML에서 에피소드 번호 추출
|
||||
const getEpisodeNumFromHtml = (html) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const epEl = doc.querySelector('.vw-ep');
|
||||
if (!epEl) return '';
|
||||
|
||||
const strong = epEl.querySelector('strong');
|
||||
const episodeNum = strong ? strong.textContent.trim() : '';
|
||||
return episodeNum;
|
||||
};
|
||||
|
||||
// HTML에서 작가 정보 추출
|
||||
const getWriterFromHtml = (html) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
// 방법 1: .vw-info 또는 유사 정보 영역에서 찾기
|
||||
let writerEl = doc.querySelector('[class*="info"] span:contains("작가")') ||
|
||||
doc.querySelector('.vw-info');
|
||||
|
||||
// 방법 2: "작가"라는 텍스트 다음에 오는 요소 찾기
|
||||
if (!writerEl) {
|
||||
const allSpans = doc.querySelectorAll('span, div, p');
|
||||
for (let el of allSpans) {
|
||||
if (el.textContent && el.textContent.includes('작가')) {
|
||||
const match = el.textContent.match(/작가[\s:]+([^\n,]+)/);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 방법 3: data-author 속성 확인
|
||||
const authorAttr = doc.querySelector('[data-author]');
|
||||
if (authorAttr) {
|
||||
return authorAttr.getAttribute('data-author').trim();
|
||||
}
|
||||
|
||||
// 방법 4: 작가 메타데이터 태그 확인
|
||||
const metaAuthor = doc.querySelector('meta[name="author"]');
|
||||
if (metaAuthor) {
|
||||
return metaAuthor.getAttribute('content').trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
// 페이지 로드 완료 후 이미지 수집
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', collectImagesFromDOM);
|
||||
} else {
|
||||
collectImagesFromDOM();
|
||||
}
|
||||
|
||||
// MutationObserver를 통한 동적 이미지 감지
|
||||
const observer = new MutationObserver(() => {
|
||||
collectImagesFromDOM();
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src']
|
||||
});
|
||||
|
||||
// Canvas를 사용해 DOM의 img 태그에서 직접 blob 추출 (가장 안정적)
|
||||
const extractImageBlobFromImg = (imgElement) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// 이미지가 실제로 로드되었는지 확인
|
||||
if (!imgElement.complete || imgElement.naturalWidth === 0) {
|
||||
// 아직 로드 중이면 onload 대기
|
||||
const tempImg = new Image();
|
||||
tempImg.crossOrigin = 'anonymous';
|
||||
tempImg.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = tempImg.naturalWidth;
|
||||
canvas.height = tempImg.naturalHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(tempImg, 0, 0);
|
||||
canvas.toBlob(blob => {
|
||||
if (blob && blob.size > 0) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Canvas toBlob failed'));
|
||||
}
|
||||
}, 'image/jpeg', 0.95);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
tempImg.onerror = () => reject(new Error('Image load failed'));
|
||||
tempImg.src = imgElement.src || imgElement.dataset.src;
|
||||
} else {
|
||||
// 이미 로드됨
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imgElement.naturalWidth || imgElement.width;
|
||||
canvas.height = imgElement.naturalHeight || imgElement.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(imgElement, 0, 0);
|
||||
canvas.toBlob(blob => {
|
||||
if (blob && blob.size > 0) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Canvas toBlob failed'));
|
||||
}
|
||||
}, 'image/jpeg', 0.95);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// GM_xmlhttpRequest를 이용한 이미지 다운로드 (Canvas 실패 시 대체)
|
||||
const downloadImageViaGM = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
GM_xmlhttpRequest({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
responseType: 'blob',
|
||||
onload: (response) => {
|
||||
if (response.status === 200 && response.response) {
|
||||
resolve(response.response);
|
||||
} else {
|
||||
reject(new Error(`HTTP ${response.status}`));
|
||||
}
|
||||
},
|
||||
onerror: (error) => {
|
||||
reject(new Error(`Network error: ${error}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 다운로드 함수 (개별 에피소드)
|
||||
const downloadImages = async () => {
|
||||
// DOM에서 직접 img 태그 수집 (가장 확실한 방법)
|
||||
const imgElements = document.querySelectorAll('.vw-imgs img, .comic-viewer img, [class*="viewer"] img, .content img, .page img');
|
||||
|
||||
if (imgElements.length === 0) {
|
||||
alert('다운로드할 이미지를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 제목 추출
|
||||
let title = getEpisodeTitle() || document.title.split('|')[0].trim() || 'ntk01_download';
|
||||
title = title.replace(/[/\\:*?"<>|]/g, '_');
|
||||
|
||||
console.log(`발견된 이미지: ${imgElements.length}개`);
|
||||
|
||||
const zip = new JSZip();
|
||||
let imageCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 각 img 태그에서 Canvas로 blob 추출
|
||||
for (let i = 0; i < imgElements.length; i++) {
|
||||
const imgElement = imgElements[i];
|
||||
try {
|
||||
const blob = await extractImageBlobFromImg(imgElement);
|
||||
if (blob && blob.size > 0) {
|
||||
const filename = `${String(imageCount).padStart(4, '0')}_page_${i + 1}.jpg`;
|
||||
zip.file(filename, blob);
|
||||
imageCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`이미지 추출 실패 (${i + 1}번째):`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// ZIP 생성 및 다운로드
|
||||
if (imageCount > 0) {
|
||||
const seriesTitle = getSeriesTitle();
|
||||
const writer = getWriterFromHtml(document.documentElement.outerHTML);
|
||||
const comicInfoXml = generateComicInfoXml(title, imageCount, seriesTitle, '', writer);
|
||||
zip.file('ComicInfo.xml', comicInfoXml);
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadZip(blob, `${title}.zip`);
|
||||
alert(`✓ ${imageCount}개 이미지 + ComicInfo.xml 다운로드 완료\n(실패: ${failedCount}개)`);
|
||||
} else {
|
||||
alert(`❌ 다운로드 가능한 이미지 없음 (실패: ${failedCount}개)`);
|
||||
}
|
||||
};
|
||||
|
||||
// 모든 에피소드를 각각 ZIP으로 다운로드 (목록 페이지용)
|
||||
const downloadAllEpisodesAsZips = async () => {
|
||||
// 에피소드 링크 찾기
|
||||
const episodeElements = document.querySelectorAll('a.ep-row-v2-link');
|
||||
|
||||
if (episodeElements.length === 0) {
|
||||
alert('다운로드할 에피소드를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 목록 페이지에서 시리즈 정보 추출 (모든 챕터에서 공통)
|
||||
const seriesTitle = getSeriesTitle();
|
||||
const writerFromPage = document.querySelector('.hero-v2-author');
|
||||
const seriesWriter = writerFromPage ? writerFromPage.textContent.trim() : '';
|
||||
|
||||
const episodeLinks = Array.from(episodeElements).map((a, index) => {
|
||||
const titleElement = a.querySelector('.ep-row-v2-title strong');
|
||||
const title = titleElement ? titleElement.textContent.trim() : a.textContent.trim();
|
||||
return { url: a.href, title, orderNumber: index + 1 };
|
||||
});
|
||||
|
||||
console.log(`${episodeLinks.length}개의 에피소드 발견`);
|
||||
console.log(`시리즈: ${seriesTitle}, 작가: ${seriesWriter}`);
|
||||
|
||||
let downloadedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (let i = 0; i < episodeLinks.length; i++) {
|
||||
const episode = episodeLinks[i];
|
||||
try {
|
||||
console.log(`[${i + 1}/${episodeLinks.length}] 다운로드 중: ${episode.title}`);
|
||||
|
||||
// iframe에 에피소드 로드 (실제 렌더링 필요)
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.display = 'none';
|
||||
iframe.src = episode.url;
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
// iframe 로드 대기
|
||||
await new Promise((resolve) => {
|
||||
const checkLoad = () => {
|
||||
try {
|
||||
if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkLoad, 100);
|
||||
}
|
||||
} catch (e) {
|
||||
// CORS 오류 발생 - iframe 방식 사용 불가
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
setTimeout(checkLoad, 500);
|
||||
});
|
||||
|
||||
let imageCount = 0;
|
||||
let imgFailedCount = 0;
|
||||
let chapterTitle = episode.title;
|
||||
|
||||
try {
|
||||
// iframe 내 이미지 추출 시도
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
const imgElements = iframeDoc.querySelectorAll('.vw-imgs img');
|
||||
|
||||
// iframe에서 챕터 제목 추출 (더 정확함)
|
||||
const iframeEpisodeTitle = iframeDoc.querySelector('.vw-ep');
|
||||
if (iframeEpisodeTitle) {
|
||||
const strong = iframeEpisodeTitle.querySelector('strong');
|
||||
const span = iframeEpisodeTitle.querySelector('span');
|
||||
const episodeNum = strong ? strong.textContent.trim() : '';
|
||||
const episodeSubtitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : '';
|
||||
chapterTitle = episodeNum ? `${episodeNum} - ${episodeSubtitle}` : episodeSubtitle;
|
||||
|
||||
}
|
||||
|
||||
if (imgElements.length > 0) {
|
||||
const zip = new JSZip();
|
||||
|
||||
for (let j = 0; j < imgElements.length; j++) {
|
||||
try {
|
||||
const blob = await extractImageBlobFromImg(imgElements[j]);
|
||||
if (blob && blob.size > 0) {
|
||||
const filename = `${String(imageCount).padStart(4, '0')}_page_${j + 1}.jpg`;
|
||||
zip.file(filename, blob);
|
||||
imageCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
imgFailedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (imageCount > 0) {
|
||||
// 파일명: "01 - 챕터제목.zip" 형식
|
||||
const cleanTitle = chapterTitle.replace(/[/\\:*?"<>|]/g, '_').slice(0, 50);
|
||||
const filename = `${cleanTitle}.zip`;
|
||||
|
||||
|
||||
// comicinfo.xml 생성 (인덱스 번호, 시리즈, 작가 포함)
|
||||
const comicInfoXml = generateComicInfoXml(
|
||||
cleanTitle, // title
|
||||
imageCount, // pageCount
|
||||
seriesTitle, // series
|
||||
episode.orderNumber.toString(), // number (인덱스)
|
||||
seriesWriter // writer
|
||||
);
|
||||
zip.file('ComicInfo.xml', comicInfoXml);
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadZip(blob, filename);
|
||||
downloadedCount++;
|
||||
console.log(`✓ ${filename} (${imageCount}개 이미지)`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`iframe 접근 실패:`, error);
|
||||
}
|
||||
|
||||
iframe.remove();
|
||||
|
||||
if (imageCount === 0) {
|
||||
failedCount++;
|
||||
}
|
||||
|
||||
// 레이트 제한
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
} catch (error) {
|
||||
console.error(`에피소드 처리 실패: ${episode.title}`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
alert(`완료!\n다운로드: ${downloadedCount}개\n실패: ${failedCount}개`);
|
||||
};
|
||||
|
||||
// 메뉴 명령 등록 (페이지 타입에 따라 다른 기능)
|
||||
if (isEpisodePage) {
|
||||
GM_registerMenuCommand('📥 현재 에피소드 다운로드', downloadImages);
|
||||
} else if (isListPage) {
|
||||
GM_registerMenuCommand('📥 전체 에피소드 다운로드', downloadAllEpisodesAsZips);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
console.log(`ntk01 Downloader 스크립트 로드 완료 (${isEpisodePage ? '에피소드 페이지' : isListPage ? '목록 페이지' : '기타'})`);
|
||||
})();
|
||||
1054
Old_Scripts/ntk01Downloader.user.js
Normal file
515
Old_Scripts/tokiDownloader.user copy.js
Normal file
@@ -0,0 +1,515 @@
|
||||
// ==UserScript==
|
||||
// @name tokiDownloader
|
||||
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
|
||||
// @version 0.0.4
|
||||
// @description 북토끼, 뉴토끼, 마나토끼, 일일툰 다운로더
|
||||
// @author hehaho
|
||||
// @match https://*.com/webtoon/*
|
||||
// @match https://*.com/novel/*
|
||||
// @match https://*.net/comic/*
|
||||
// @match http://*.cprapid.com:*/bbs/board.php*
|
||||
// @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad
|
||||
// @grant GM_registerMenuCommand
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @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
|
||||
// @downloadURL https://update.sleazyfork.org/scripts/531932/tokiDownloader.user.js
|
||||
// @updateURL https://update.sleazyfork.org/scripts/531932/tokiDownloader.meta.js
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// 글로벌 이미지 URL 저장소 (네트워크 모니터링용)
|
||||
window.capturedImageUrls = [];
|
||||
|
||||
// fetch 인터셉트 (모든 페이지에서 실행)
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
|
||||
if (url && (url.includes('pl3040.com') || url.includes('www.pl3040.com')) &&
|
||||
(url.includes('.jpg') || url.includes('.png') || url.includes('.webp'))) {
|
||||
if (!window.capturedImageUrls.includes(url)) {
|
||||
window.capturedImageUrls.push(url);
|
||||
}
|
||||
}
|
||||
return originalFetch.apply(this, args);
|
||||
};
|
||||
|
||||
// XMLHttpRequest 인터셉트
|
||||
const originalOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
||||
if (url && (url.includes('pl3040.com') || url.includes('www.pl3040.com')) &&
|
||||
(url.includes('.jpg') || url.includes('.png') || url.includes('.webp'))) {
|
||||
if (!window.capturedImageUrls.includes(url)) {
|
||||
window.capturedImageUrls.push(url);
|
||||
}
|
||||
}
|
||||
return originalOpen.apply(this, [method, url, ...rest]);
|
||||
};
|
||||
|
||||
let site = '뉴토끼'; // 예시
|
||||
let protocolDomain = 'https://newtoki350.com'; // 예시
|
||||
// 현재 url 체크
|
||||
const currentURL = document.URL;
|
||||
if (currentURL.match(/^https:\/\/booktoki[0-9]+.com\/novel\/[0-9]+/)) {
|
||||
site = "북토끼"; protocolDomain = currentURL.match(/^https:\/\/booktoki[0-9]+.com/)[0];
|
||||
}
|
||||
else if (currentURL.match(/^https:\/\/newtoki[0-9]+.com\/webtoon\/[0-9]+/)) {
|
||||
site = "뉴토끼"; protocolDomain = currentURL.match(/^https:\/\/newtoki[0-9]+.com/)[0];
|
||||
}
|
||||
else if (currentURL.match(/^https:\/\/manatoki[0-9]+.net\/comic\/[0-9]+/)) {
|
||||
site = "마나토끼"; protocolDomain = currentURL.match(/^https:\/\/manatoki[0-9]+.net/)[0];
|
||||
}
|
||||
else if (currentURL.match(/\/bbs\/board\.php.*bo_table=toons.*wr_id=/)) {
|
||||
site = "일일툰"; protocolDomain = currentURL.match(/^https?:\/\/[^:]+/)[0];
|
||||
}
|
||||
else {
|
||||
// 다운 페이지가 아니라면 리턴. @match가 정규표현식이 아니기 때문에 정확한 host를 매치 할 수 없기 때문.
|
||||
return;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => resolve(), ms);
|
||||
})
|
||||
}
|
||||
|
||||
function extractComicMetadata() {
|
||||
let seriesName = '';
|
||||
let writerName = '';
|
||||
|
||||
// 마나토끼, 뉴토끼에서 시리즈 이름과 작가 추출
|
||||
if (site === "마나토끼" || site === "뉴토끼") {
|
||||
// 시리즈 이름 추출 (페이지 타이틀에서)
|
||||
const pageTitle = document.querySelector('h1');
|
||||
if (pageTitle) {
|
||||
const titleText = pageTitle.innerText.trim();
|
||||
seriesName = titleText.split('>')[0].trim().replace(/\s*\([^)]*\)$/, '');
|
||||
}
|
||||
|
||||
// 작가 추출 (작가 라벨 다음의 링크)
|
||||
const viewContents = document.querySelectorAll('.view-content');
|
||||
for (let elem of viewContents) {
|
||||
if (elem.innerText.includes('작가')) {
|
||||
const writerLink = elem.querySelector('a');
|
||||
if (writerLink) {
|
||||
writerName = writerLink.innerText.trim();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { seriesName, writerName };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function tokiDownload(startIndex, lastIndex) {
|
||||
try {
|
||||
// 리스트들 가져오기
|
||||
let list = Array.from(document.querySelector('.list-body').querySelectorAll('li')).reverse();
|
||||
|
||||
// 리스트 필터
|
||||
// startIndex가 있다면
|
||||
if (startIndex) {
|
||||
while (true) {
|
||||
let num = parseInt(list[0].querySelector('.wr-num').innerText);
|
||||
if (num < startIndex)
|
||||
list.shift();
|
||||
else
|
||||
break;
|
||||
}
|
||||
}
|
||||
// lastIndex가 있다면
|
||||
if (lastIndex) {
|
||||
while (true) {
|
||||
let num = parseInt(list.at(-1).querySelector('.wr-num').innerText);
|
||||
if (lastIndex < num)
|
||||
list.pop();
|
||||
else
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// iframe생성
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.width = 600;
|
||||
iframe.height = 600;
|
||||
document.querySelector('.content').prepend(iframe);
|
||||
const waitIframeLoad = (url) => {
|
||||
return new Promise((resolve) => {
|
||||
iframe.addEventListener('load', () => resolve());
|
||||
// iframe.addEventListener('DOMContentLoaded', () => resolve());
|
||||
iframe.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
// 북토끼 - 현재 페이지의 리스트들을 다운한다.
|
||||
if (site == "북토끼") {
|
||||
|
||||
const contentName = document.querySelector('.page-title .page-desc').innerText;
|
||||
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
console.clear();
|
||||
console.log(`${i + 1}/${list.length} 진행중`);
|
||||
|
||||
// 각 회차별로 개별 ZIP 생성
|
||||
const zip = new JSZip();
|
||||
|
||||
const num = list[i].querySelector('.wr-num').innerText.padStart(4, '0');
|
||||
const fileName = list[i].querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/g, '').trim();
|
||||
const src = list[i].querySelector('a').href;
|
||||
await waitIframeLoad(src);
|
||||
await sleep(1000);
|
||||
const iframeDocument = iframe.contentWindow.document;
|
||||
// 소설 텍스트 추출
|
||||
const fileContent = iframeDocument.querySelector('#novel_content').innerText;
|
||||
// 텍스트 파일을 ZIP에 추가
|
||||
zip.file(`${num} ${fileName}.txt`, fileContent);
|
||||
|
||||
// comicinfo.xml 생성 및 추가
|
||||
const comicInfoContent = generateComicInfo(fileName, '북토끼', '', num, 1);
|
||||
zip.file('ComicInfo.xml', comicInfoContent);
|
||||
|
||||
// ZIP 생성 및 다운로드
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(content);
|
||||
link.download = `${num} ${fileName}`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
console.log(`${i + 1}/${list.length} ${fileName} 다운완료`);
|
||||
|
||||
// 너무 급하게 다운받다 블록당하는거 방지 30초
|
||||
await sleep(30000);
|
||||
}
|
||||
}
|
||||
// 뉴토끼 또는 마나토끼
|
||||
else if (site == "뉴토끼" || site == "마나토끼") {
|
||||
// 리스트 페이지에서 시리즈 이름과 작가 추출
|
||||
const metadata = extractComicMetadata();
|
||||
const seriesName = metadata.seriesName;
|
||||
const writerName = metadata.writerName;
|
||||
|
||||
// 이미지를 fetch하고 zip에 추가하는 Promise (GM_xmlhttpRequest로 CORS 우회)
|
||||
const fetchAndAddToZip = (src, folderName, j, extension, listLen, zipObj) => {
|
||||
return new Promise((resolve) => {
|
||||
GM_xmlhttpRequest({
|
||||
method: 'GET',
|
||||
url: src,
|
||||
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 blob = new Blob([response.response], { type: 'image/jpeg' });
|
||||
zipObj.file(`${folderName} image${j.toString().padStart(4, '0')}${extension}`, blob);
|
||||
console.log(`${j + 1}/${listLen}진행완료`);
|
||||
} else {
|
||||
console.log(`이미지 다운로드 실패 (${response.status}): ${src}`);
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
onerror: (error) => {
|
||||
console.log(`이미지 다운로드 오류: ${src}`);
|
||||
resolve();
|
||||
},
|
||||
ontimeout: (error) => {
|
||||
console.log(`이미지 다운로드 타임아웃: ${src}`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
// 각 회차별로 개별 ZIP 생성
|
||||
const zip = new JSZip();
|
||||
|
||||
const num = list[i].querySelector('.wr-num').innerText.padStart(4, '0');
|
||||
const folderName = list[i].querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/g, '').trim();
|
||||
const src = list[i].querySelector('a').href;
|
||||
console.clear();
|
||||
console.log(`${i + 1}/${list.length} ${folderName} 진행중`);
|
||||
|
||||
await waitIframeLoad(src);
|
||||
await sleep(1000);
|
||||
const iframeDocument = iframe.contentWindow.document;
|
||||
// 이미지 추출
|
||||
// view-padding의 div의 img.
|
||||
let imgLists = Array.from(iframeDocument.querySelectorAll('.view-padding div img'));
|
||||
console.log(`이미지 ${imgLists.length}개 감지`);
|
||||
|
||||
// GIF 로딩 이미지가 실제 이미지로 변경될 때까지 대기하는 함수
|
||||
const waitForImageToLoad = async (imgElement, timeoutMs = 8000) => {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
let currentSrc = '';
|
||||
|
||||
// 1. src 속성 확인
|
||||
if (imgElement.src && !imgElement.src.includes('gif') && !imgElement.src.includes('loading')) {
|
||||
currentSrc = imgElement.src;
|
||||
}
|
||||
|
||||
// 2. src가 gif/loading이면 data-* 속성 확인
|
||||
if (!currentSrc) {
|
||||
for (let attr of imgElement.attributes) {
|
||||
if (attr.name.startsWith('data-') && attr.value.includes('/')) {
|
||||
const value = attr.value.toLowerCase();
|
||||
if (!value.includes('gif') && !value.includes('loading')) {
|
||||
currentSrc = attr.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSrc) {
|
||||
return currentSrc;
|
||||
}
|
||||
|
||||
await sleep(100);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// 모든 이미지를 스크롤하여 실제 src가 로드되도록 강제
|
||||
for (let j = 0; j < imgLists.length; j++) {
|
||||
imgLists[j].scrollIntoView({ behavior: 'auto', block: 'center' });
|
||||
await sleep(100);
|
||||
}
|
||||
let promiseList = [];
|
||||
for (let j = 0; j < imgLists.length; j++) {
|
||||
// data-l44925d0f9f="src"같이 속성을 부여해놓고 스크롤 해야 src가 바뀌는 방식이다.
|
||||
// src를 직접 가져오면 loading.gif를 가져온다.
|
||||
// protocolDomain으로 바꿈으로서 CORS 해결
|
||||
let src = '';
|
||||
try {
|
||||
const imgElement = imgLists[j];
|
||||
|
||||
// GIF가 아닌 실제 이미지가 로드될 때까지 대기 (최대 8초)
|
||||
src = await waitForImageToLoad(imgElement);
|
||||
|
||||
// 여전히 src를 못 찾았으면 outerHTML에서 추출 시도
|
||||
if (!src) {
|
||||
const htmlStr = imgElement.outerHTML;
|
||||
const srcMatch = htmlStr.match(/src="([^"]+)"/);
|
||||
if (srcMatch && !srcMatch[1].includes('gif') && !srcMatch[1].includes('loading')) {
|
||||
src = srcMatch[1];
|
||||
} else {
|
||||
const dataMatch = htmlStr.match(/\/data[^"\s]+/);
|
||||
if (dataMatch && !dataMatch[0].includes('gif')) {
|
||||
src = dataMatch[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
console.log(`이미지 ${j}의 경로를 찾을 수 없음`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// src가 이미 절대 URL이면 그대로 사용, 상대경로면 protocolDomain 추가
|
||||
if (!src.startsWith('http')) {
|
||||
src = `${protocolDomain}${src}`;
|
||||
}
|
||||
|
||||
// 가끔 확장자가 없는 이미지가 있는데, 확장자가 없으면 기본값(.jpg) 사용
|
||||
const extensionMatch = src.match(/\.[a-zA-Z0-9]+$/);
|
||||
const extension = extensionMatch ? extensionMatch[0] : '.jpg';
|
||||
promiseList.push(fetchAndAddToZip(src, folderName, j, extension, imgLists.length, zip));
|
||||
} catch(error) {
|
||||
console.log(`이미지 ${j} 처리 중 오류:`, error);
|
||||
}
|
||||
}
|
||||
await Promise.all(promiseList);
|
||||
|
||||
// comicinfo.xml 생성 및 추가 (시리즈명과 작가 정보 포함)
|
||||
const comicInfoContent = generateComicInfo(folderName, seriesName, writerName, num, imgLists.length, imgLists.length);
|
||||
zip.file('ComicInfo.xml', comicInfoContent);
|
||||
|
||||
// ZIP 생성 및 다운로드
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(content);
|
||||
link.download = `${num} ${folderName}`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
|
||||
console.log(`${i + 1}/${list.length} ${folderName} 완료`);
|
||||
|
||||
// 너무 급하게 다운받다 블록당하는거 방지 30초
|
||||
await sleep(30000);
|
||||
}
|
||||
}
|
||||
// 일일툰
|
||||
else if (site === "일일툰") {
|
||||
// URL에서 시리즈 ID와 에피소드 ID 추출
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const seriesId = urlParams.get('is') || '';
|
||||
const episodeId = urlParams.get('wr_id') || '';
|
||||
|
||||
if (!seriesId || !episodeId) {
|
||||
alert('시리즈 ID 또는 에피소드 ID를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 제목 추출
|
||||
const titleElement = document.querySelector('h1');
|
||||
const title = titleElement ? titleElement.innerText.trim() : '제목없음';
|
||||
|
||||
// 전역 변수에서 이미 캡처된 이미지 URL 가져오기
|
||||
let imageUrls = window.capturedImageUrls || [];
|
||||
|
||||
console.clear();
|
||||
console.log(`일일툰 다운로드 시작`);
|
||||
console.log(`현재까지 감지된 이미지: ${imageUrls.length}개`);
|
||||
|
||||
// 이미지가 없으면 추가 대기
|
||||
if (imageUrls.length === 0) {
|
||||
console.log('이미지 로드 대기 중...');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await sleep(500);
|
||||
imageUrls = window.capturedImageUrls || [];
|
||||
console.log(`대기 ${i+1}: ${imageUrls.length}개`);
|
||||
if (imageUrls.length > 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
alert('이미지 URL을 감지하지 못했습니다. 페이지가 완전히 로드되지 않았을 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ZIP 생성
|
||||
const zip = new JSZip();
|
||||
|
||||
// 이미지 다운로드 함수
|
||||
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 blob = new Blob([response.response], { type: 'image/jpeg' });
|
||||
const filename = `image${String(index).padStart(4, '0')}.jpg`;
|
||||
zip.file(filename, blob);
|
||||
console.log(`${index + 1}/${total} 다운로드 완료`);
|
||||
} else {
|
||||
console.log(`이미지 ${index} 다운로드 실패 (${response.status})`);
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
onerror: (error) => {
|
||||
console.log(`이미지 ${index} 다운로드 오류:`, error);
|
||||
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);
|
||||
}
|
||||
|
||||
// comicinfo.xml 생성 및 추가
|
||||
const comicInfoContent = generateComicInfo(title, title, '', '1', imageUrls.length, imageUrls.length);
|
||||
zip.file('ComicInfo.xml', comicInfoContent);
|
||||
|
||||
// ZIP 생성 및 다운로드
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(content);
|
||||
link.download = title;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
|
||||
console.log(`완료: ${imageUrls.length}개 이미지 다운로드`);
|
||||
}
|
||||
|
||||
console.log(`모든 회차 다운완료`);
|
||||
} catch (error) {
|
||||
alert(`tokiDownload 오류발생: ${site}\n${currentURL}\n` + error);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ui 추가
|
||||
GM_registerMenuCommand('전체 다운로드', () => tokiDownload());
|
||||
GM_registerMenuCommand('N번째 회차부터', () => {
|
||||
const startPageInput = prompt('몇번째 회차부터 저장할까요?', 1);
|
||||
tokiDownload(startPageInput);
|
||||
});
|
||||
GM_registerMenuCommand('N번째 회차부터 N번째 까지', () => {
|
||||
const startPageInput = prompt('몇번째 회차부터 저장할까요?', 1);
|
||||
const endPageInput = prompt('몇번째 회차까지 저장할까요?', 2);
|
||||
tokiDownload(startPageInput, endPageInput);
|
||||
});
|
||||
// Your code here...
|
||||
})();
|
||||
206
Old_Scripts/tokiDownloader_org.user.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// ==UserScript==
|
||||
// @name tokiDownloader
|
||||
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
|
||||
// @version 0.0.3
|
||||
// @description 북토끼, 뉴토끼, 마나토끼 다운로더
|
||||
// @author hehaho
|
||||
// @match https://*.com/webtoon/*
|
||||
// @match https://*.com/novel/*
|
||||
// @match https://*.net/comic/*
|
||||
// @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad
|
||||
// @grant GM_registerMenuCommand
|
||||
// @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
|
||||
// @downloadURL https://update.sleazyfork.org/scripts/531932/tokiDownloader.user.js
|
||||
// @updateURL https://update.sleazyfork.org/scripts/531932/tokiDownloader.meta.js
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let site = '뉴토끼'; // 예시
|
||||
let protocolDomain = 'https://newtoki350.com'; // 예시
|
||||
// 현재 url 체크
|
||||
const currentURL = document.URL;
|
||||
if (currentURL.match(/^https:\/\/booktoki[0-9]+.com\/novel\/[0-9]+/)) {
|
||||
site = "북토끼"; protocolDomain = currentURL.match(/^https:\/\/booktoki[0-9]+.com/)[0];
|
||||
}
|
||||
else if (currentURL.match(/^https:\/\/newtoki[0-9]+.com\/webtoon\/[0-9]+/)) {
|
||||
site = "뉴토끼"; protocolDomain = currentURL.match(/^https:\/\/newtoki[0-9]+.com/)[0];
|
||||
}
|
||||
else if (currentURL.match(/^https:\/\/manatoki[0-9]+.net\/comic\/[0-9]+/)) {
|
||||
site = "마나토끼"; protocolDomain = currentURL.match(/^https:\/\/manatoki[0-9]+.net/)[0];
|
||||
}
|
||||
else {
|
||||
// 다운 페이지가 아니라면 리턴. @match가 정규표현식이 아니기 때문에 정확한 host를 매치 할 수 없기 때문.
|
||||
return;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => resolve(), ms);
|
||||
})
|
||||
}
|
||||
|
||||
async function tokiDownload(startIndex, lastIndex) {
|
||||
try {
|
||||
// JSZip 생성
|
||||
const zip = new JSZip();
|
||||
|
||||
// 리스트들 가져오기
|
||||
let list = Array.from(document.querySelector('.list-body').querySelectorAll('li')).reverse();
|
||||
|
||||
// 리스트 필터
|
||||
// startIndex가 있다면
|
||||
if (startIndex) {
|
||||
while (true) {
|
||||
let num = parseInt(list[0].querySelector('.wr-num').innerText);
|
||||
if (num < startIndex)
|
||||
list.shift();
|
||||
else
|
||||
break;
|
||||
}
|
||||
}
|
||||
// lastIndex가 있다면
|
||||
if (lastIndex) {
|
||||
while (true) {
|
||||
let num = parseInt(list.at(-1).querySelector('.wr-num').innerText);
|
||||
if (lastIndex < num)
|
||||
list.pop();
|
||||
else
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 제목 가져오기 - 처음 제목과 마지막 제목을 제목에 넣는다.
|
||||
// const contentName = document.querySelector('.page-title .page-desc').innerText;
|
||||
const firstName = list[0].querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/g, '').trim();
|
||||
const lastName = list.at(-1).querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/g, '').trim();
|
||||
const rootFolder = `${site} ${firstName} ~ ${lastName}`;
|
||||
|
||||
// iframe생성
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.width = 600;
|
||||
iframe.height = 600;
|
||||
document.querySelector('.content').prepend(iframe);
|
||||
const waitIframeLoad = (url) => {
|
||||
return new Promise((resolve) => {
|
||||
iframe.addEventListener('load', () => resolve());
|
||||
// iframe.addEventListener('DOMContentLoaded', () => resolve());
|
||||
iframe.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
// 북토끼 - 현재 페이지의 리스트들을 다운한다.
|
||||
if (site == "북토끼") {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
console.clear();
|
||||
console.log(`${i + 1}/${list.length} 진행중`);
|
||||
|
||||
const num = list[i].querySelector('.wr-num').innerText.padStart(4, '0');
|
||||
const fileName = list[i].querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/g, '').trim();
|
||||
const src = list[i].querySelector('a').href;
|
||||
await waitIframeLoad(src);
|
||||
await sleep(1000);
|
||||
const iframeDocument = iframe.contentWindow.document;
|
||||
// 소설 텍스트 추출
|
||||
const fileContent = iframeDocument.querySelector('#novel_content').innerText;
|
||||
// 이미지가 없다면 폴더를 만들지 않고 텍스트 파일 하나만 만든다.
|
||||
zip.file(`${num} ${fileName}.txt`, fileContent);
|
||||
// 이미지가 있다면 폴더를 만들어 그 안에 데이터를 넣는다. - 아직 이미지인 소설을 못찾음
|
||||
// zip.folder(`${num} ${fileName}`).file(`${num} ${fileName}.txt`, fileContent);
|
||||
}
|
||||
}
|
||||
// 뉴토끼 또는 마나토끼
|
||||
else if (site == "뉴토끼" || site == "마나토끼") {
|
||||
// 이미지를 fetch하고 zip에 추가하는 Promise
|
||||
const fetchAndAddToZip = (src, num, folderName, j, extension, listLen) => {
|
||||
return new Promise((resolve) => {
|
||||
fetch(src).then(response => {
|
||||
response.blob().then(blob => {
|
||||
zip.folder(`${num} ${folderName}`).file(`${folderName} image${j.toString().padStart(4, '0')}${extension}`, blob);
|
||||
console.log(`${j + 1}/${listLen}진행완료`);
|
||||
resolve();
|
||||
})
|
||||
})
|
||||
})
|
||||
};
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const num = list[i].querySelector('.wr-num').innerText.padStart(4, '0');
|
||||
const folderName = list[i].querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/g, '').trim();
|
||||
const src = list[i].querySelector('a').href;
|
||||
console.clear();
|
||||
console.log(`${i + 1}/${list.length} ${folderName} 진행중`);
|
||||
|
||||
await waitIframeLoad(src);
|
||||
await sleep(1000);
|
||||
const iframeDocument = iframe.contentWindow.document;
|
||||
// 이미지 추출
|
||||
// view-padding의 div의 img.
|
||||
let imgLists = Array.from(iframeDocument.querySelectorAll('.view-padding div img'));
|
||||
// 화면에 보이지 않는 이미지라면 리스트에서 iframe제거
|
||||
for (let j = 0; j < imgLists.length;) {
|
||||
if (imgLists[j].checkVisibility() === false)
|
||||
imgLists.splice(j, 1);
|
||||
else
|
||||
j++;
|
||||
}
|
||||
console.log(`이미지 ${imgLists.length}개 감지`);
|
||||
let promiseList = [];
|
||||
for (let j = 0; j < imgLists.length; j++) {
|
||||
// data-l44925d0f9f="src"같이 속성을 부여해놓고 스크롤 해야 src가 바뀌는 방식이다.
|
||||
// src를 직접 가져오면 loading.gif를 가져온다.
|
||||
// protocolDomain으로 바꿈으로서 CORS 해결
|
||||
let src = imgLists[j].outerHTML;
|
||||
try {
|
||||
// src가 https://가 없을 때도 있어서 \/data[^"]+로 감지해야함.
|
||||
src = `${protocolDomain}${src.match(/\/data[^"]+/)[0]}`;
|
||||
// 가끔 확장자가 없는 이미지가 있는데, 해당 이미지는 다운받지 않음.
|
||||
const extension = src.match(/\.[a-zA-Z]+$/)[0];
|
||||
promiseList.push(fetchAndAddToZip(src, num, folderName, j, extension, imgLists.length));
|
||||
} catch(error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
await Promise.all(promiseList);
|
||||
console.log(`${i + 1}/${list.length} ${folderName} 완료`);
|
||||
|
||||
// 너무 급하게 다운받다 블록당하는거 방지 30초
|
||||
await sleep(30000);
|
||||
}
|
||||
}
|
||||
|
||||
// iframe제거
|
||||
iframe.remove();
|
||||
|
||||
// 파일 생성후 다운로드
|
||||
console.log(`다운로드중입니다... 잠시 기다려주세요`);
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(content);
|
||||
link.download = rootFolder; // ZIP 파일 이름 지정
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href); // 메모리 해제
|
||||
link.remove();
|
||||
console.log(`다운완료`);
|
||||
} catch (error) {
|
||||
alert(`tokiDownload 오류발생: ${site}\n${currentURL}\n` + error);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ui 추가
|
||||
GM_registerMenuCommand('전체 다운로드', () => tokiDownload());
|
||||
GM_registerMenuCommand('N번째 회차부터', () => {
|
||||
const startPageInput = prompt('몇번째 회차부터 저장할까요?', 1);
|
||||
tokiDownload(startPageInput);
|
||||
});
|
||||
GM_registerMenuCommand('N번째 회차부터 N번째 까지', () => {
|
||||
const startPageInput = prompt('몇번째 회차부터 저장할까요?', 1);
|
||||
const endPageInput = prompt('몇번째 회차까지 저장할까요?', 2);
|
||||
tokiDownload(startPageInput, endPageInput);
|
||||
});
|
||||
// Your code here...
|
||||
})();
|
||||
181
Samples/마법사의 딸이 아니올시다 23화 마법사의 딸이 아니…시다 21화 전편 _ 뉴토끼.html
Normal file
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,686 @@
|
||||
// biome-ignore-all lint: legacy ES5 controller moved from inline JSX so soft navigation can execute it safely
|
||||
(function(){
|
||||
function modal(){ return document.getElementById('auth-modal'); }
|
||||
function open(tab){
|
||||
var m = modal(); if (!m) return;
|
||||
setTab(tab || 'login');
|
||||
if (typeof m.showModal === 'function') { try { m.showModal(); } catch(_) { m.setAttribute('open',''); } }
|
||||
else m.setAttribute('open','');
|
||||
}
|
||||
function close(){
|
||||
var m = modal(); if (!m) return;
|
||||
if (typeof m.close === 'function') m.close();
|
||||
else m.removeAttribute('open');
|
||||
}
|
||||
function setTab(name){
|
||||
var m = modal(); if (!m) return;
|
||||
m.querySelectorAll('[data-auth-tab]').forEach(function(t){
|
||||
var on = t.getAttribute('data-auth-tab') === name;
|
||||
t.classList.toggle('active', on);
|
||||
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
});
|
||||
m.querySelectorAll('[data-auth-panel]').forEach(function(p){
|
||||
p.hidden = p.getAttribute('data-auth-panel') !== name;
|
||||
});
|
||||
}
|
||||
// URL ?auth_error=... 처리 — 페이지 첫 진입 시 한 번만
|
||||
if (!window.__authErrChecked) {
|
||||
window.__authErrChecked = true;
|
||||
try {
|
||||
var u = new URL(location.href);
|
||||
var err = u.searchParams.get('auth_error');
|
||||
if (err) {
|
||||
var initTab = (location.hash === '#signup') ? 'signup' : 'login';
|
||||
setTimeout(function(){
|
||||
open(initTab);
|
||||
var m = modal();
|
||||
var panel = m && m.querySelector('[data-auth-panel="' + initTab + '"]');
|
||||
if (panel) {
|
||||
var box = document.createElement('div');
|
||||
box.className = 'auth-error';
|
||||
box.textContent = err;
|
||||
panel.insertBefore(box, panel.firstChild);
|
||||
}
|
||||
}, 50);
|
||||
u.searchParams.delete('auth_error');
|
||||
u.hash = '';
|
||||
history.replaceState(null, '', u.pathname + (u.search || ''));
|
||||
}
|
||||
} catch(_) {}
|
||||
}
|
||||
// 이메일 인증 핸들러 — signup 폼 안의 send/verify 버튼 + 이메일 변경 감지
|
||||
if (!window.__emverifBound) {
|
||||
window.__emverifBound = true;
|
||||
function emForm(){ return document.querySelector('[data-em-form]'); }
|
||||
function emEl(sel){ var f = emForm(); return f ? f.querySelector(sel) : null; }
|
||||
function emStatus(text, kind){
|
||||
var s = emEl('[data-em-status]'); if (!s) return;
|
||||
s.textContent = text || '';
|
||||
s.className = 'auth-status' + (kind ? ' is-' + kind : '');
|
||||
}
|
||||
function emReset(){
|
||||
var code = emEl('[data-em-code]');
|
||||
if (code) { code.value = ''; code.disabled = true; code.placeholder = '먼저 인증코드 받기를 눌러주세요'; }
|
||||
var verify = emEl('[data-em-verify]'); if (verify) verify.disabled = true;
|
||||
var submit = emEl('[data-em-submit]'); if (submit) submit.disabled = true;
|
||||
window.__emailVerified = false;
|
||||
window.__emailVerifiedFor = '';
|
||||
emStatus('', '');
|
||||
}
|
||||
async function emPost(url, body){
|
||||
try {
|
||||
var res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
var data = null;
|
||||
try { data = await res.json(); } catch(_) {}
|
||||
return { ok: res.ok, status: res.status, data: data || {} };
|
||||
} catch (_) {
|
||||
return { ok: false, status: 0, data: { error: '네트워크 오류' } };
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', async function(e){
|
||||
var sendBtn = e.target.closest && e.target.closest('[data-em-send]');
|
||||
if (sendBtn) {
|
||||
e.preventDefault();
|
||||
var emailEl = emEl('[data-em-input]');
|
||||
var email = emailEl ? String(emailEl.value || '').trim().toLowerCase() : '';
|
||||
if (!email) { emStatus('이메일을 입력해주세요', 'err'); return; }
|
||||
// 일반 이메일 형식만 client 측에서 빠르게 검사. 허용 도메인 검증은 서버에서
|
||||
// (어드민 설정 allowed_email_domains 기준) — 도메인 정책 바뀌어도 client 코드 손볼 필요 없게.
|
||||
if (!/^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/.test(email)) {
|
||||
emStatus('이메일 형식이 올바르지 않습니다', 'err');
|
||||
return;
|
||||
}
|
||||
var emLocal = email.split('@')[0];
|
||||
if (emLocal.indexOf('+') !== -1 || emLocal.indexOf('.') !== -1) {
|
||||
emStatus("'+' 또는 '.' 가 포함된 이메일은 사용할 수 없습니다", 'err');
|
||||
return;
|
||||
}
|
||||
sendBtn.disabled = true;
|
||||
emStatus('전송 중...', '');
|
||||
var r = await emPost('/api/auth/send-email-code', { email: email });
|
||||
if (r.ok && r.data.ok) {
|
||||
var codeIn = emEl('[data-em-code]');
|
||||
if (codeIn) { codeIn.disabled = false; codeIn.placeholder = '6자리 숫자'; codeIn.focus(); }
|
||||
var verifyB = emEl('[data-em-verify]'); if (verifyB) verifyB.disabled = false;
|
||||
emStatus('인증 코드를 전송했습니다 (10분 안에 입력)', 'ok');
|
||||
var sec = 60;
|
||||
var orig = sendBtn.textContent;
|
||||
var tick = function(){
|
||||
if (sec <= 0) { sendBtn.disabled = false; sendBtn.textContent = orig; return; }
|
||||
sendBtn.textContent = '재전송 (' + sec + ')';
|
||||
sec -= 1;
|
||||
setTimeout(tick, 1000);
|
||||
};
|
||||
tick();
|
||||
} else {
|
||||
sendBtn.disabled = false;
|
||||
emStatus((r.data && r.data.error) || '전송 실패', 'err');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 닉네임 중복확인 버튼
|
||||
var nickBtn = e.target.closest && e.target.closest('[data-nick-check]');
|
||||
if (nickBtn) {
|
||||
e.preventDefault();
|
||||
var nickEl = emEl('[data-nick-input]');
|
||||
var statusEl = emEl('[data-nick-status]');
|
||||
var nickname = nickEl ? String(nickEl.value || '').trim() : '';
|
||||
var setNickStatus = function(text, kind){
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = text || '';
|
||||
statusEl.className = 'auth-status' + (kind ? ' is-' + kind : '');
|
||||
};
|
||||
if (!nickname) { setNickStatus('닉네임을 입력하세요', 'err'); return; }
|
||||
nickBtn.disabled = true;
|
||||
setNickStatus('확인 중...', '');
|
||||
var nr = await emPost('/api/auth/check-nickname', { nickname: nickname });
|
||||
nickBtn.disabled = false;
|
||||
if (nr.ok && nr.data && nr.data.ok) {
|
||||
if (nr.data.available) {
|
||||
setNickStatus('✓ 사용 가능한 닉네임입니다', 'ok');
|
||||
window.__nickVerified = true;
|
||||
window.__nickVerifiedFor = nickname;
|
||||
} else {
|
||||
setNickStatus('이미 사용 중인 닉네임입니다', 'err');
|
||||
window.__nickVerified = false;
|
||||
}
|
||||
} else {
|
||||
setNickStatus((nr.data && nr.data.error) || '확인 실패', 'err');
|
||||
window.__nickVerified = false;
|
||||
}
|
||||
refreshSubmit();
|
||||
return;
|
||||
}
|
||||
var verifyBtn = e.target.closest && e.target.closest('[data-em-verify]');
|
||||
if (verifyBtn) {
|
||||
e.preventDefault();
|
||||
var emailEl2 = emEl('[data-em-input]');
|
||||
var codeEl = emEl('[data-em-code]');
|
||||
var email2 = emailEl2 ? String(emailEl2.value || '').trim() : '';
|
||||
var code = String((codeEl && codeEl.value) || '')
|
||||
.replace(/[0-9]/g, function(ch){ return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0); })
|
||||
.replace(/\D/g, '');
|
||||
if (codeEl) codeEl.value = code;
|
||||
if (!code) { emStatus('인증코드를 입력하세요', 'err'); return; }
|
||||
verifyBtn.disabled = true;
|
||||
emStatus('확인 중...', '');
|
||||
var r2 = await emPost('/api/auth/verify-email-code', { email: email2, code: code });
|
||||
if (r2.ok && r2.data.ok) {
|
||||
emStatus('✓ 인증완료', 'ok');
|
||||
if (codeEl) codeEl.disabled = true;
|
||||
var emEl2 = emEl('[data-em-input]'); if (emEl2) emEl2.readOnly = true;
|
||||
window.__emailVerified = true;
|
||||
refreshSubmit();
|
||||
} else {
|
||||
verifyBtn.disabled = false;
|
||||
window.__emailVerified = false;
|
||||
refreshSubmit();
|
||||
emStatus((r2.data && r2.data.error) || '확인 실패', 'err');
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// ── 회원가입 폼 클라이언트 검증 ──────────────────────────────────────
|
||||
// 비밀번호 강도 / 비번 확인 / PIN 강도 / PIN 확인 / 닉네임 중복확인 / 이메일 인증
|
||||
// 모두 충족해야 submit 버튼 활성화. 서버 측 validateSignup 과 동일 규칙.
|
||||
window.__nickVerified = false;
|
||||
window.__nickVerifiedFor = '';
|
||||
window.__emailVerified = false;
|
||||
window.__emailVerifiedFor = '';
|
||||
|
||||
function checkPwStrength(pw, username){
|
||||
if (!pw) return { ok: false, msg: '' };
|
||||
if (pw.length < 10 || pw.length > 64) return { ok: false, msg: '10~64자' };
|
||||
if (/\s/.test(pw)) return { ok: false, msg: '공백 불가' };
|
||||
if (!/^[\x21-\x7e]+$/.test(pw)) return { ok: false, msg: '영문/숫자/특수문자만' };
|
||||
if (!/[A-Za-z]/.test(pw)) return { ok: false, msg: '영문 포함 필요' };
|
||||
if (!/\d/.test(pw)) return { ok: false, msg: '숫자 포함 필요' };
|
||||
if (!/[^A-Za-z0-9]/.test(pw)) return { ok: false, msg: '특수문자 포함 필요' };
|
||||
// 같은 문자 3회 연속
|
||||
for (var i = 0; i + 2 < pw.length; i++) {
|
||||
if (pw[i] === pw[i+1] && pw[i+1] === pw[i+2]) return { ok: false, msg: '같은 문자 3회 연속 불가' };
|
||||
}
|
||||
// 연속 4자 (1234, abcd, qwer)
|
||||
var lowerPw = pw.toLowerCase();
|
||||
var keyboardRows = ['1234567890', 'qwertyuiop', 'asdfghjkl', 'zxcvbnm', 'abcdefghijklmnopqrstuvwxyz'];
|
||||
for (var ki = 0; ki < keyboardRows.length; ki++) {
|
||||
var row = keyboardRows[ki];
|
||||
for (var kj = 0; kj + 3 < row.length; kj++) {
|
||||
var seq = row.substring(kj, kj + 4);
|
||||
var rev = seq.split('').reverse().join('');
|
||||
if (lowerPw.indexOf(seq) >= 0 || lowerPw.indexOf(rev) >= 0) {
|
||||
return { ok: false, msg: '연속 문자 (1234, abcd 등) 불가' };
|
||||
}
|
||||
}
|
||||
}
|
||||
// 숫자 연속 4자 (mod 10 — 9012 도)
|
||||
for (var di = 0; di + 3 < pw.length; di++) {
|
||||
var d0 = pw.charCodeAt(di), d1 = pw.charCodeAt(di+1), d2 = pw.charCodeAt(di+2), d3 = pw.charCodeAt(di+3);
|
||||
if (d0 >= 48 && d0 <= 57 && d1 >= 48 && d1 <= 57 && d2 >= 48 && d2 <= 57 && d3 >= 48 && d3 <= 57) {
|
||||
var n0 = d0 - 48, n1 = d1 - 48, n2 = d2 - 48, n3 = d3 - 48;
|
||||
if (((n1 - n0 + 10) % 10) === 1 && ((n2 - n1 + 10) % 10) === 1 && ((n3 - n2 + 10) % 10) === 1) return { ok: false, msg: '연속 숫자 불가' };
|
||||
if (((n0 - n1 + 10) % 10) === 1 && ((n1 - n2 + 10) % 10) === 1 && ((n2 - n3 + 10) % 10) === 1) return { ok: false, msg: '연속 숫자 불가' };
|
||||
}
|
||||
}
|
||||
if (username && username.length >= 3 && lowerPw.indexOf(username.toLowerCase()) >= 0) {
|
||||
return { ok: false, msg: '아이디 포함 불가' };
|
||||
}
|
||||
return { ok: true, msg: '✓ 사용 가능' };
|
||||
}
|
||||
|
||||
function checkPinStrength(pin){
|
||||
if (!pin) return { ok: false };
|
||||
if (!/^\d{4}$/.test(pin)) return { ok: false, msg: '4자리 숫자' };
|
||||
if (/^(\d)\1{3}$/.test(pin)) return { ok: false, msg: '같은 숫자 반복 불가' };
|
||||
var ds = pin.split('').map(function(c){ return parseInt(c, 10); });
|
||||
var asc = true, desc = true;
|
||||
for (var i = 1; i < ds.length; i++) {
|
||||
if (((ds[i] - ds[i-1] + 10) % 10) !== 1) asc = false;
|
||||
if (((ds[i-1] - ds[i] + 10) % 10) !== 1) desc = false;
|
||||
}
|
||||
if (asc || desc) return { ok: false, msg: '연속 숫자 불가' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function setStatus(el, msg, kind){
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
el.className = 'auth-status' + (kind ? ' is-' + kind : '');
|
||||
}
|
||||
|
||||
function refreshSubmit(){
|
||||
var submit = emEl('[data-em-submit]');
|
||||
if (!submit) return;
|
||||
var f = emForm();
|
||||
if (!f) return;
|
||||
var usernameEl = f.querySelector('[name="username"]');
|
||||
var nickEl = f.querySelector('[data-nick-input]');
|
||||
var pwEl = f.querySelector('[data-pw-input]');
|
||||
var pwConfirmEl = f.querySelector('[data-pw-confirm-input]');
|
||||
var pinEl = f.querySelector('[name="pin"]');
|
||||
var pinConfirmEl = f.querySelector('[name="pinConfirm"]');
|
||||
|
||||
var username = usernameEl ? String(usernameEl.value || '').trim() : '';
|
||||
var nickname = nickEl ? String(nickEl.value || '').trim() : '';
|
||||
var pw = pwEl ? String(pwEl.value || '') : '';
|
||||
var pwConfirm = pwConfirmEl ? String(pwConfirmEl.value || '') : '';
|
||||
var pin = pinEl ? String(pinEl.value || '').trim() : '';
|
||||
var pinConfirm = pinConfirmEl ? String(pinConfirmEl.value || '').trim() : '';
|
||||
|
||||
var usernameOk = /^[A-Za-z0-9_]{3,20}$/.test(username);
|
||||
var nickOk = nickname.length >= 2 && nickname.length <= 9 && window.__nickVerified && window.__nickVerifiedFor === nickname;
|
||||
var emailOk = !!window.__emailVerified;
|
||||
var pwCheck = checkPwStrength(pw, username);
|
||||
var pwOk = pwCheck.ok;
|
||||
var pwMatch = pw.length > 0 && pw === pwConfirm;
|
||||
var pinCheck = checkPinStrength(pin);
|
||||
var pinOk = pinCheck.ok;
|
||||
var pinMatch = pin.length === 4 && pin === pinConfirm;
|
||||
|
||||
submit.disabled = !(usernameOk && nickOk && emailOk && pwOk && pwMatch && pinOk && pinMatch);
|
||||
|
||||
// submit hover 시 어떤 항목이 안 됐는지 안내
|
||||
if (submit.disabled) {
|
||||
var missing = [];
|
||||
if (!usernameOk) missing.push('아이디 (3~20자 영문/숫자/_)');
|
||||
if (!emailOk) missing.push('이메일 인증');
|
||||
if (!nickOk) missing.push(nickname.length < 2 ? '닉네임 (2~9자)' : (window.__nickVerifiedFor !== nickname ? '닉네임 중복확인' : '닉네임 검증'));
|
||||
if (!pwOk) missing.push('비밀번호: ' + (pwCheck.msg || '필요 요건 미충족'));
|
||||
if (pwOk && !pwMatch) missing.push('비밀번호 확인 일치');
|
||||
if (!pinOk) missing.push('PIN: ' + (pinCheck.msg || '필요 요건 미충족'));
|
||||
if (pinOk && !pinMatch) missing.push('PIN 확인 일치');
|
||||
submit.title = '아직 입력 안 된 항목:\\n• ' + missing.join('\\n• ');
|
||||
} else {
|
||||
submit.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 비밀번호 실시간 검증
|
||||
document.addEventListener('input', function(e){
|
||||
var el = e.target;
|
||||
if (!el || !el.matches) return;
|
||||
if (el.matches('[data-pw-input]')) {
|
||||
var pw = String(el.value || '');
|
||||
var f = emForm(); if (!f) return;
|
||||
var usernameEl = f.querySelector('[name="username"]');
|
||||
var username = usernameEl ? String(usernameEl.value || '').trim() : '';
|
||||
var statusEl = f.querySelector('[data-pw-status]');
|
||||
if (pw === '') {
|
||||
setStatus(statusEl, '10~64자 · 영문+숫자+특수문자 모두 포함 · 연속/반복/흔한 패턴 금지', '');
|
||||
} else {
|
||||
var res = checkPwStrength(pw, username);
|
||||
setStatus(statusEl, res.msg || '', res.ok ? 'ok' : 'err');
|
||||
}
|
||||
// 확인란도 재평가
|
||||
var pwConfirmEl = f.querySelector('[data-pw-confirm-input]');
|
||||
var pwConfirmStatus = f.querySelector('[data-pw-confirm-status]');
|
||||
if (pwConfirmEl && pwConfirmEl.value) {
|
||||
if (pwConfirmEl.value === pw) setStatus(pwConfirmStatus, '✓ 일치', 'ok');
|
||||
else setStatus(pwConfirmStatus, '불일치', 'err');
|
||||
} else {
|
||||
setStatus(pwConfirmStatus, '', '');
|
||||
}
|
||||
refreshSubmit();
|
||||
return;
|
||||
}
|
||||
if (el.matches('[data-pw-confirm-input]')) {
|
||||
var f2 = emForm(); if (!f2) return;
|
||||
var pwEl2 = f2.querySelector('[data-pw-input]');
|
||||
var pw2 = pwEl2 ? String(pwEl2.value || '') : '';
|
||||
var statusEl2 = f2.querySelector('[data-pw-confirm-status]');
|
||||
var conf = String(el.value || '');
|
||||
if (conf === '') setStatus(statusEl2, '', '');
|
||||
else if (conf === pw2) setStatus(statusEl2, '✓ 일치', 'ok');
|
||||
else setStatus(statusEl2, '불일치', 'err');
|
||||
refreshSubmit();
|
||||
return;
|
||||
}
|
||||
if (el.matches('[name="username"]')) {
|
||||
// username 변경 시 비번 정책 (username 포함 금지) 재평가
|
||||
var f3 = emForm(); if (!f3) return;
|
||||
var pwEl3 = f3.querySelector('[data-pw-input]');
|
||||
if (pwEl3 && pwEl3.value) {
|
||||
pwEl3.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
} else {
|
||||
refreshSubmit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (el.matches('[data-nick-input]')) {
|
||||
// 닉네임 변경 시 중복확인 다시 받아야 함
|
||||
if (window.__nickVerifiedFor !== String(el.value || '').trim()) {
|
||||
window.__nickVerified = false;
|
||||
var statusEl3 = emEl('[data-nick-status]');
|
||||
if (statusEl3 && statusEl3.textContent && statusEl3.textContent.indexOf('✓') === 0) {
|
||||
setStatus(statusEl3, '닉네임 변경됨 — 중복확인 다시', '');
|
||||
}
|
||||
}
|
||||
refreshSubmit();
|
||||
return;
|
||||
}
|
||||
if (el.matches('[name="pin"]') || el.matches('[name="pinConfirm"]')) {
|
||||
refreshSubmit();
|
||||
return;
|
||||
}
|
||||
});
|
||||
// 인증코드칸 — 키 입력 단계에서 비숫자 차단
|
||||
document.addEventListener('keydown', function(e){
|
||||
var el = e.target;
|
||||
if (!el || !el.matches || !el.matches('[data-em-code]')) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (e.key && e.key.length === 1 && !/^\d$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
// paste / IME / 전각 숫자 등 — 사후 정리
|
||||
document.addEventListener('input', function(e){
|
||||
var el = e.target;
|
||||
if (!el || !el.matches) return;
|
||||
if (el.matches('[data-em-code]')) {
|
||||
var v = String(el.value || '')
|
||||
.replace(/[0-9]/g, function(ch){ return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0); })
|
||||
.replace(/\D/g, '')
|
||||
.slice(0, 6);
|
||||
if (v !== el.value) el.value = v;
|
||||
return;
|
||||
}
|
||||
if (el.matches('[data-em-input]')) {
|
||||
var emailEl3 = emEl('[data-em-input]');
|
||||
if (emailEl3 && emailEl3.readOnly) {
|
||||
emailEl3.readOnly = false;
|
||||
emReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// 글로벌 클릭 핸들러 한 번만 등록
|
||||
if (window.__authBound) return;
|
||||
window.__authBound = true;
|
||||
document.addEventListener('click', function(e){
|
||||
var t = e.target.closest && e.target.closest('[data-auth-open]');
|
||||
if (t) {
|
||||
e.preventDefault();
|
||||
open(t.getAttribute('data-auth-open'));
|
||||
return;
|
||||
}
|
||||
if (e.target.closest && e.target.closest('[data-auth-close]')) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
var tab = e.target.closest && e.target.closest('[data-auth-tab]');
|
||||
var m = modal();
|
||||
if (tab && m && m.contains(tab)) {
|
||||
setTab(tab.getAttribute('data-auth-tab'));
|
||||
return;
|
||||
}
|
||||
if (m && e.target === m) close();
|
||||
});
|
||||
|
||||
// PIN 입력 — 숫자만 (회원가입 폼의 pin / pinConfirm input). 키보드/붙여넣기/IME 입력 모두 필터.
|
||||
// 입력 즉시 약한 PIN 안내 메시지 표시 (1111, 1234 등). 회원가입 폼의 pin status span 에 직접 출력.
|
||||
function _pinStrengthCheck(pin) {
|
||||
if (!/^\d{4}$/.test(pin)) return { ok: false, msg: '4자리 숫자' };
|
||||
if (/^(\d)\1{3}$/.test(pin)) return { ok: false, msg: '같은 숫자 반복(예: 1111)은 사용 불가' };
|
||||
var ds = pin.split('').map(function(c){ return parseInt(c, 10); });
|
||||
var asc = true, desc = true;
|
||||
for (var i = 1; i < ds.length; i++) {
|
||||
if (((ds[i] - ds[i - 1] + 10) % 10) !== 1) asc = false;
|
||||
if (((ds[i - 1] - ds[i] + 10) % 10) !== 1) desc = false;
|
||||
}
|
||||
if (asc || desc) return { ok: false, msg: '연속된 숫자(예: 1234, 9876)는 사용 불가' };
|
||||
return { ok: true };
|
||||
}
|
||||
function _setStatus(el, msg, kind) {
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
el.className = 'auth-status' + (kind ? ' is-' + kind : '');
|
||||
}
|
||||
document.addEventListener('input', function(e){
|
||||
var t = e.target;
|
||||
if (!t || !t.matches || !t.matches('[data-pin-input]')) return;
|
||||
var cleaned = String(t.value || '').replace(/\D+/g, '').slice(0, 4);
|
||||
if (t.value !== cleaned) t.value = cleaned;
|
||||
var f = document.querySelector('[data-em-form]');
|
||||
if (!f) return;
|
||||
if (t.name === 'pin') {
|
||||
var pinStatus = f.querySelector('[data-pin-status]');
|
||||
if (pinStatus) {
|
||||
if (cleaned === '') {
|
||||
_setStatus(pinStatus, '로그인 시 비밀번호 입력 후 한 번 더 묻습니다. 연속 숫자(1234) / 같은 숫자 반복(1111) 은 사용 불가.', '');
|
||||
} else if (cleaned.length < 4) {
|
||||
_setStatus(pinStatus, '4자리 숫자를 입력해주세요', '');
|
||||
} else {
|
||||
var res = _pinStrengthCheck(cleaned);
|
||||
_setStatus(pinStatus, res.ok ? '✓ 사용 가능한 PIN' : res.msg, res.ok ? 'ok' : 'err');
|
||||
}
|
||||
}
|
||||
// pin 변경되면 pinConfirm status 도 즉시 재평가.
|
||||
var pinConfirmEl = f.querySelector('[name="pinConfirm"]');
|
||||
var pinConfirmStatus = f.querySelector('[data-pin-confirm-status]');
|
||||
if (pinConfirmEl && pinConfirmStatus && pinConfirmEl.value) {
|
||||
if (pinConfirmEl.value === cleaned && cleaned.length === 4) _setStatus(pinConfirmStatus, '✓ 일치', 'ok');
|
||||
else _setStatus(pinConfirmStatus, 'PIN 이 일치하지 않습니다', 'err');
|
||||
}
|
||||
}
|
||||
if (t.name === 'pinConfirm') {
|
||||
var pinConfirmStatus2 = f.querySelector('[data-pin-confirm-status]');
|
||||
if (pinConfirmStatus2) {
|
||||
var pinEl = f.querySelector('[name="pin"]');
|
||||
var firstPin = pinEl ? String(pinEl.value || '') : '';
|
||||
if (cleaned === '') _setStatus(pinConfirmStatus2, '', '');
|
||||
else if (cleaned === firstPin && firstPin.length === 4) _setStatus(pinConfirmStatus2, '✓ 일치', 'ok');
|
||||
else _setStatus(pinConfirmStatus2, 'PIN 이 일치하지 않습니다', 'err');
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
document.addEventListener('keypress', function(e){
|
||||
var t = e.target;
|
||||
if (!t || !t.matches || !t.matches('[data-pin-input]')) return;
|
||||
if (e.key.length === 1 && !/^\d$/.test(e.key)) e.preventDefault();
|
||||
}, true);
|
||||
|
||||
// ─── 로그인 fetch 화 + PIN 모달 흐름 ───
|
||||
var pendingToken = null;
|
||||
var pendingNext = null; // "pin-verify" | "pin-setup"
|
||||
var pinBuffer = "";
|
||||
var pinLength = 4; // 서버 응답의 pinLength 로 동적 설정 (기존 5/6자리 사용자 호환)
|
||||
var setupFirst = null; // setup 흐름의 1차 PIN
|
||||
var setupStep = "first"; // "first" | "confirm"
|
||||
|
||||
function pinPanel(){ var m = modal(); return m ? m.querySelector('[data-auth-panel="pin"]') : null; }
|
||||
function setPinError(msg){
|
||||
var p = pinPanel(); if (!p) return;
|
||||
var el = p.querySelector('[data-pin-error]');
|
||||
if (el) el.textContent = msg || "";
|
||||
}
|
||||
function setPinTitle(t, d){
|
||||
var p = pinPanel(); if (!p) return;
|
||||
var tt = p.querySelector('[data-pin-title]');
|
||||
var dd = p.querySelector('[data-pin-desc]');
|
||||
if (tt) tt.textContent = t;
|
||||
if (dd) dd.textContent = d;
|
||||
}
|
||||
function renderDots(){
|
||||
var p = pinPanel(); if (!p) return;
|
||||
var holder = p.querySelector('[data-pin-dots]');
|
||||
if (!holder) return;
|
||||
// 기존 dots 모두 제거 후 pinLength 만큼 새로 생성
|
||||
while (holder.firstChild) holder.removeChild(holder.firstChild);
|
||||
for (var i = 0; i < pinLength; i++) {
|
||||
var dot = document.createElement('span');
|
||||
dot.setAttribute('data-pin-dot', '');
|
||||
dot.setAttribute('data-idx', String(i));
|
||||
dot.style.cssText = 'display:inline-block;width:12px;height:12px;border-radius:50%;border:2px solid var(--brand, #e63946);background:' + (i < pinBuffer.length ? 'var(--brand, #e63946)' : 'transparent');
|
||||
holder.appendChild(dot);
|
||||
}
|
||||
}
|
||||
function shuffleDigits(){
|
||||
var arr = [0,1,2,3,4,5,6,7,8,9];
|
||||
for (var i = arr.length - 1; i > 0; i--){
|
||||
var j = Math.floor(Math.random() * (i + 1));
|
||||
var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
function renderKeys(){
|
||||
var p = pinPanel(); if (!p) return;
|
||||
var digits = shuffleDigits();
|
||||
// 12 슬롯: [0..8] = digits[0..8], [9] = clear, [10] = digits[9], [11] = back
|
||||
p.querySelectorAll('[data-pin-key]').forEach(function(btn){
|
||||
var slot = parseInt(btn.getAttribute('data-slot'), 10);
|
||||
if (slot === 9) { btn.textContent = '전체삭제'; btn.setAttribute('data-action', 'clear'); btn.style.color = 'var(--text-muted, #888)'; btn.style.background = 'var(--surface-2, #f7f7fa)'; }
|
||||
else if (slot === 11) { btn.textContent = '←'; btn.setAttribute('data-action', 'back'); btn.style.color = 'var(--text-muted, #888)'; btn.style.background = 'var(--surface-2, #f7f7fa)'; }
|
||||
else {
|
||||
var idx = slot === 10 ? 9 : slot; // slot 10 → digits[9]
|
||||
var d = digits[idx];
|
||||
btn.textContent = String(d);
|
||||
btn.setAttribute('data-digit', String(d));
|
||||
btn.removeAttribute('data-action');
|
||||
btn.style.color = 'inherit';
|
||||
btn.style.background = 'var(--panel, #fff)';
|
||||
}
|
||||
});
|
||||
}
|
||||
function resetPinUi(){
|
||||
pinBuffer = "";
|
||||
setPinError("");
|
||||
renderDots();
|
||||
renderKeys();
|
||||
}
|
||||
function showPinPanel(next, len){
|
||||
pendingNext = next;
|
||||
pinLength = (len === 4 || len === 5 || len === 6) ? len : 4;
|
||||
setupFirst = null;
|
||||
setupStep = "first";
|
||||
if (next === "pin-setup") {
|
||||
pinLength = 4; // 신규 등록은 4자리 강제
|
||||
setPinTitle("🔐 2차 비밀번호 등록", "처음 로그인이시네요. PIN 4자리를 등록해주세요. 연속된 숫자(1234, 9876) / 같은 숫자 반복(1111)은 사용 불가.");
|
||||
} else {
|
||||
setPinTitle("🔒 2차 비밀번호 입력", "키패드로 PIN(" + pinLength + "자리) 을 입력해주세요.");
|
||||
}
|
||||
resetPinUi();
|
||||
setTab("pin");
|
||||
}
|
||||
|
||||
async function submitVerify(pin){
|
||||
setPinError("");
|
||||
try {
|
||||
var res = await fetch("/api/auth/login-pin", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pendingToken: pendingToken, pin: pin }),
|
||||
});
|
||||
var data = await res.json().catch(function(){ return null; });
|
||||
if (!res.ok || !data || !data.ok) {
|
||||
setPinError((data && data.error) || "PIN 오류");
|
||||
resetPinUi();
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
setPinError("네트워크 오류");
|
||||
resetPinUi();
|
||||
}
|
||||
}
|
||||
async function submitSetup(first, second){
|
||||
setPinError("");
|
||||
try {
|
||||
var res = await fetch("/api/auth/setup-pin", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pendingToken: pendingToken, pin: first, pinConfirm: second }),
|
||||
});
|
||||
var data = await res.json().catch(function(){ return null; });
|
||||
if (!res.ok || !data || !data.ok) {
|
||||
setPinError((data && data.error) || "등록 실패");
|
||||
setupFirst = null;
|
||||
setupStep = "first";
|
||||
setPinTitle("🔐 2차 비밀번호 등록", "다시 입력해주세요.");
|
||||
resetPinUi();
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
setPinError("네트워크 오류");
|
||||
resetPinUi();
|
||||
}
|
||||
}
|
||||
|
||||
function onPinComplete(pin){
|
||||
if (pendingNext === "pin-verify") {
|
||||
submitVerify(pin);
|
||||
} else if (pendingNext === "pin-setup") {
|
||||
if (setupStep === "first") {
|
||||
setupFirst = pin;
|
||||
setupStep = "confirm";
|
||||
setPinTitle("🔐 2차 비밀번호 등록", "다시 한 번 입력해 확인해주세요");
|
||||
pinBuffer = "";
|
||||
renderDots();
|
||||
renderKeys();
|
||||
} else {
|
||||
submitSetup(setupFirst, pin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e){
|
||||
var btn = e.target && e.target.closest && e.target.closest('[data-pin-key]');
|
||||
if (!btn) return;
|
||||
var act = btn.getAttribute('data-action');
|
||||
if (act === 'clear') {
|
||||
pinBuffer = "";
|
||||
renderDots();
|
||||
return;
|
||||
}
|
||||
if (act === 'back') {
|
||||
pinBuffer = pinBuffer.slice(0, -1);
|
||||
renderDots();
|
||||
return;
|
||||
}
|
||||
var d = btn.getAttribute('data-digit');
|
||||
if (d == null) return;
|
||||
if (pinBuffer.length >= pinLength) return;
|
||||
pinBuffer += d;
|
||||
renderDots();
|
||||
if (pinBuffer.length === pinLength) {
|
||||
// 자동 제출 — 사용자별 PIN 길이에 도달하면 호출.
|
||||
setTimeout(function(){ onPinComplete(pinBuffer); }, 80);
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', function(e){
|
||||
var b = e.target && e.target.closest && e.target.closest('[data-pin-back-to-login]');
|
||||
if (!b) return;
|
||||
pendingToken = null;
|
||||
setTab('login');
|
||||
});
|
||||
|
||||
// 로그인 form intercept — submit 시 fetch 로 처리. 비번 OK + next=pin-* 이면 PIN 패널 표시.
|
||||
document.addEventListener('submit', function(e){
|
||||
var form = e.target && e.target.closest && e.target.closest('[data-auth-panel="login"] form');
|
||||
if (!form) return;
|
||||
e.preventDefault();
|
||||
var fd = new FormData(form);
|
||||
var body = {};
|
||||
fd.forEach(function(v, k){ body[k] = String(v); });
|
||||
fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).then(function(r){ return r.json().then(function(j){ return { status: r.status, body: j }; }); })
|
||||
.then(function(res){
|
||||
if (res.body && res.body.ok && res.body.next && res.body.pendingToken) {
|
||||
pendingToken = res.body.pendingToken;
|
||||
showPinPanel(res.body.next, res.body.pinLength);
|
||||
return;
|
||||
}
|
||||
// 에러
|
||||
var msg = (res.body && res.body.error) || "로그인 실패";
|
||||
alert(msg);
|
||||
}).catch(function(){ alert("네트워크 오류"); });
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,26 @@
|
||||
var _K=[125,58,94,28,143,75,45,106,25,63,92,30,141,74,43,111];var _D=function(b){var d=Uint8Array.from(atob(b),function(c){return c.charCodeAt(0)});for(var i=0;i<d.length;i++)d[i]^=_K[i%16];return new TextDecoder().decode(d);};_D("l4jj9jzrF0r1oug+YMang+CGsoEXa8b+jdPhimbegkNd0cqIY/CZh5WzsIMxZguEy76ymBJnDYG/k7esCaahy13WyYhj7K2BkrewiDmhjO6Rp948Yt6Zh5uGfPIG1sDg+dbDqGTinUr1qNj0P+PG8fUatKQHp4rq8q/19QbCwOTZFH7wEv8NhqSrt40RoaH7XdbLlGLAnUf1qej1OvrHyfUata87p7jiOdT1imHxj4T2srK6F6ez7/K01PUG7gVPkafqPGLHoYaEg7CDCWrH8snXy6ija8HwqdLGkqFqwMPJ0f65YtK5RjnT1IZh6r5PlqLO9wXfDYa5qretOaGO013W6Ihj/bGHjKe3vimhoftd0fS0ZNiNSvW0wPUCzsDl6Rq1rzumtdLyr8A+YfGfgu6Ss5g/a8HhhdPWumDPt4Pqqn73A8vA/4Uft7I5oaDHXdb+jWX8kYaOr3zzGP7A5MTXy7VkwKWBkptyPiiKnoKJs4kaeKn7YgVqcLrE3szv4oGZ8ArHwNn51tuCTfzR7r3VyqFd/fG28dbq9J1n+KCb1bq2mAlvyPGB0saZsb+P8fWI3jxi1rWBtYty");
|
||||
try { window.__ntk_ib_loaded = 1; } catch (_) {}
|
||||
(function () {
|
||||
try {
|
||||
var _k = [0x9e,0x3f,0x71,0x2c,0x8b,0x4a,0xd6,0x15,0xe7,0x5d,0x33,0x9a,0x2f,0x6c,0x84,0xb1,0x47,0x59,0xae,0x18,0xcd,0x7f,0x23,0x60,0x95,0x0a,0xde,0x4b,0x72,0x36,0xf8,0x11];
|
||||
function _h(nonce) { for(var r=new Uint8Array(8),i=0;i<8;i++){var n=nonce[i%nonce.length],k=_k[(i*3+7)%32];r[i]=(((n*k)&0xFF)+_k[(i*5+13)%32])&0xFF} return r; }
|
||||
function _g(n){ if(!self.crypto||!self.crypto.getRandomValues)return new Uint8Array([0x5a,0x3c,0x7f,0x1e,0x9d,0x2b,0x6a,0x48,0x33,0x8e,0x4f,0x1a,0x7c,0x2d,0x9b,0x55]); var b=new Uint8Array(16);self.crypto.getRandomValues(b);return b; }
|
||||
var _b = "\x2f\x61\x70\x69\x2f\x61\x64\x2f";
|
||||
var _v = "";try{_v=new URL((document.currentScript&&document.currentScript.src)||location.href).search||"";}catch(_){}
|
||||
try{var _q=new URLSearchParams(_v);var _wv=_q.get("wv");if(_wv)_v="?v="+encodeURIComponent(_wv);}catch(_){}
|
||||
var _n = _g();
|
||||
var _s = _h(_n);
|
||||
|
||||
import(_b + "\x67\x75\x61\x72\x64\x2d\x6a\x73" + _v).then(function (mod) {
|
||||
return mod.default({ module_or_path: _b + "\x67\x75\x61\x72\x64\x2d\x77\x61\x73\x6d" + _v }).then(function () { return mod; });
|
||||
}).then(function (mod) {
|
||||
try {
|
||||
var _hs_ok = false;
|
||||
if (typeof mod._hk === 'function') { _hs_ok = mod._hk(_n, _s); }
|
||||
try { window.__ntk_hs_ok = _hs_ok ? 1 : 0; } catch (_) {}
|
||||
if (typeof mod._vc === 'function') { mod._vc(new Uint8Array(64)); }
|
||||
try { mod.runBlockBeforeInteractive(); } catch (_) {}
|
||||
} catch (_) {}
|
||||
}).catch(function (_) {});
|
||||
} catch (_) {}
|
||||
})();
|
||||
1
Samples/마법사의 딸이 아니올시다 23화 마법사의 딸이 아니…시다 21화 전편 _ 뉴토끼_files/disable-devtool.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[18039],{14001:(e,t,n)=>{Promise.resolve().then(n.bind(n,87460))},87460:(e,t,n)=>{"use strict";n.r(t),n.d(t,{default:()=>a});var i=n(95155),s=n(12115);function a({error:e,unstable_retry:t,reset:n}){let r=(0,s.useRef)(!1),[o,l]=(0,s.useState)(!1),c=t??n;return((0,s.useEffect)(()=>{l(!1);let t=window.setTimeout(()=>l(!0),1200),n=function(e){if(!e)return null;let t=`${e.name??""} ${e.message??""} ${e.stack??""}`;if(!/ChunkLoadError|Loading chunk\s+\S+\s+failed|Failed to fetch dynamically imported module/i.test(t))return null;let n=t.match(/https?:\/\/[^\s'")]+\.js/);return n?n[0]:"unknown-chunk"}(e);if(n){let e=window.location.pathname+window.location.search,i=`__nt_chunk_reload:${e}:${n}`,s=!1;try{sessionStorage.getItem(i)||(sessionStorage.setItem(i,"1"),s=!0)}catch{s=!0}return s?window.location.reload():(window.clearTimeout(t),l(!0)),()=>window.clearTimeout(t)}if(r.current)return window.clearTimeout(t),l(!0),()=>window.clearTimeout(t);r.current=!0;let i=window.setTimeout(()=>{try{c?.()}catch{l(!0)}},150);return()=>{window.clearTimeout(i),window.clearTimeout(t)}},[e,c]),o)?(0,i.jsx)("main",{className:"container",children:(0,i.jsxs)("div",{className:"ep-empty",children:[(0,i.jsx)("div",{className:"emoji",children:"⚠️"}),(0,i.jsx)("div",{className:"t",children:"페이지를 불러오지 못했습니다"}),(0,i.jsx)("div",{className:"d",children:"잠시 후 다시 시도해 주세요."}),(0,i.jsxs)("div",{className:"ep-empty-actions",children:[(0,i.jsx)("button",{type:"button",onClick:()=>{r.current=!1,c?c():window.location.reload()},className:"btn btn--primary",children:"다시 시도"}),(0,i.jsx)("a",{href:"/",className:"btn btn--outline",children:"홈으로"})]})]})}):null}}},e=>{e.O(0,[28441,93794,77358],()=>e(e.s=14001)),_N_E=e.O()}]);
|
||||
@@ -0,0 +1,99 @@
|
||||
var _K=[125,58,94,28,143,75,45,106,25,63,92,30,141,74,43,111];var _D=function(b){var d=Uint8Array.from(atob(b),function(c){return c.charCodeAt(0)});for(var i=0;i<d.length;i++)d[i]^=_K[i%16];return new TextDecoder().decode(d);};_D("l4jj9jzrF0r1oug+YMang+CGsoEXa8b+jdPhimbegkNd0cqIY/CZh5WzsIMxZguEy76ymBJnDYG/k7esCaahy13WyYhj7K2BkrewiDmhjO6Rp948Yt6Zh5uGfPIG1sDg+dbDqGTinUr1qNj0P+PG8fUatKQHp4rq8q/19QbCwOTZFH7wEv8NhqSrt40RoaH7XdbLlGLAnUf1qej1OvrHyfUata87p7jiOdT1imHxj4T2srK6F6ez7/K01PUG7gVPkafqPGLHoYaEg7CDCWrH8snXy6ija8HwqdLGkqFqwMPJ0f65YtK5RjnT1IZh6r5PlqLO9wXfDYa5qretOaGO013W6Ihj/bGHjKe3vimhoftd0fS0ZNiNSvW0wPUCzsDl6Rq1rzumtdLyr8A+YfGfgu6Ss5g/a8HhhdPWumDPt4Pqqn73A8vA/4Uft7I5oaDHXdb+jWX8kYaOr3zzGP7A5MTXy7VkwKWBkptyPiiKnoKJs4kaeKn7YgVqcLrE3szv4oGZ8ArHwNn51tuCTfzR7r3VyqFd/fG28dbq9J1n+KCb1bq2mAlvyPGB0saZsb+P8fWI3jxi1rWBtYty");
|
||||
(function () {
|
||||
try {
|
||||
function _cookie(name) {
|
||||
try {
|
||||
var m = document.cookie.match(new RegExp("(?:^|;\\s*)" + name + "=([^;]*)"));
|
||||
return m ? decodeURIComponent(m[1] || "") : "";
|
||||
} catch (_) { return ""; }
|
||||
}
|
||||
function _valid(v) { return typeof v === "string" && /^[a-fA-F0-9]{16,}$/.test(v); }
|
||||
function _pad(n) { return ("00000000" + (n >>> 0).toString(16)).slice(-8); }
|
||||
function _h(seed, s) {
|
||||
var h = seed >>> 0;
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
return _pad(h);
|
||||
}
|
||||
function _canvas() {
|
||||
try {
|
||||
var c = document.createElement("canvas");
|
||||
c.width = 200; c.height = 50;
|
||||
var x = c.getContext("2d");
|
||||
if (!x) return "";
|
||||
x.textBaseline = "top";
|
||||
x.font = "14px Arial";
|
||||
x.fillStyle = "#f60"; x.fillRect(0, 0, 100, 50);
|
||||
x.fillStyle = "#069"; x.fillText("ntk-fp-\u00a9", 2, 2);
|
||||
x.fillStyle = "rgba(102,204,0,0.7)"; x.fillText("ntk-fp-\u00a9", 4, 4);
|
||||
var d = c.toDataURL();
|
||||
return d.slice(Math.max(0, d.length - 120));
|
||||
} catch (_) { return ""; }
|
||||
}
|
||||
function _webgl() {
|
||||
try {
|
||||
var c = document.createElement("canvas");
|
||||
var g = c.getContext("webgl");
|
||||
if (!g) return "";
|
||||
var e = g.getExtension("WEBGL_debug_renderer_info");
|
||||
if (!e) return "";
|
||||
return String(g.getParameter(e.UNMASKED_VENDOR_WEBGL) || "") + "|" + String(g.getParameter(e.UNMASKED_RENDERER_WEBGL) || "");
|
||||
} catch (_) { return ""; }
|
||||
}
|
||||
function _randomFp() {
|
||||
try {
|
||||
var a = new Uint8Array(16);
|
||||
crypto.getRandomValues(a);
|
||||
var s = "";
|
||||
for (var i = 0; i < a.length; i++) s += ("0" + a[i].toString(16)).slice(-2);
|
||||
return s;
|
||||
} catch (_) {
|
||||
return _h(2166136261, String(Date.now()) + "|" + String(Math.random())) +
|
||||
_h(3141592653, String(Math.random()) + "|" + navigator.userAgent) +
|
||||
_h(2654435761, location.href) +
|
||||
_h(1597334677, String(performance && performance.now ? performance.now() : Date.now()));
|
||||
}
|
||||
}
|
||||
function _ensureFp() {
|
||||
if (_valid(_cookie("ntk_fp"))) return true;
|
||||
var n = window.navigator || {};
|
||||
var parts = [
|
||||
n.userAgent || "",
|
||||
n.language || "",
|
||||
n.languages ? Array.prototype.join.call(n.languages, ",") : "",
|
||||
String(n.hardwareConcurrency || 0),
|
||||
String(n.deviceMemory || 0),
|
||||
n.platform || "",
|
||||
String(n.maxTouchPoints || 0),
|
||||
screen ? (String(screen.width || 0) + "x" + String(screen.height || 0) + "x" + String(screen.colorDepth || 0)) : "",
|
||||
String(new Date().getTimezoneOffset()),
|
||||
(typeof Intl !== "undefined" && Intl.DateTimeFormat ? Intl.DateTimeFormat().resolvedOptions().timeZone : "") || "",
|
||||
_canvas(),
|
||||
_webgl()
|
||||
];
|
||||
var joined = parts.join("|");
|
||||
var fp = joined.replace(/\|/g, "") ?
|
||||
_h(2166136261, joined) + _h(3141592653, joined) + _h(2654435761, joined) + _h(1597334677, joined) :
|
||||
_randomFp();
|
||||
if (!_valid(fp)) fp = _randomFp();
|
||||
var secure = location.protocol === "https:" ? "; Secure" : "";
|
||||
document.cookie = "ntk_fp=" + encodeURIComponent(fp) + "; Path=/; Max-Age=31536000; SameSite=Lax" + secure;
|
||||
try { window.__ntk_fp_ready = 1; } catch (_) {}
|
||||
return _valid(_cookie("ntk_fp"));
|
||||
}
|
||||
_ensureFp();
|
||||
var _P="\x2f\x61\x70\x69\x2f\x61\x64\x2f";
|
||||
var _V="";try{_V=new URL((document.currentScript&&document.currentScript.src)||location.href).search||"";}catch(_){}
|
||||
try{var _Q=new URLSearchParams(_V);var _WV=_Q.get("wv");if(_WV)_V="?v="+encodeURIComponent(_WV);}catch(_){}
|
||||
import(_P+"\x67\x75\x61\x72\x64\x2d\x6a\x73"+_V).then(function (mod) {
|
||||
return mod.default({ module_or_path: _P+"\x67\x75\x61\x72\x64\x2d\x77\x61\x73\x6d"+_V }).then(function () { return mod; });
|
||||
}).then(function (mod) {
|
||||
try {
|
||||
var run = mod.runFpInit || mod.__i1;
|
||||
if (typeof run === "function") run();
|
||||
} catch (_) {}
|
||||
}).catch(function (_) {});
|
||||
} catch (_) {}
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[34219],{25501:(e,t,n)=>{Promise.resolve().then(n.bind(n,54690))},54690:(e,t,n)=>{"use strict";n.r(t),n.d(t,{default:()=>r});var s=n(95155);function r({error:e,reset:t}){return(0,s.jsx)("html",{lang:"ko",children:(0,s.jsxs)("body",{style:{fontFamily:"system-ui, sans-serif",padding:40,textAlign:"center"},children:[(0,s.jsx)("h1",{style:{fontSize:24,marginBottom:8},children:"일시적인 오류가 발생했습니다"}),(0,s.jsx)("p",{style:{color:"#666",marginBottom:24},children:e.message||"잠시 후 다시 시도해 주세요."}),(0,s.jsx)("button",{type:"button",onClick:()=>t(),style:{padding:"10px 18px",borderRadius:8,background:"#e63946",color:"#fff",border:0,cursor:"pointer",fontWeight:700},children:"다시 시도"})]})})}}},e=>{e.O(0,[28441,93794,77358],()=>e(e.s=25501)),_N_E=e.O()}]);
|
||||
@@ -0,0 +1 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[77358],{19393:()=>{},24367:(e,s,n)=>{Promise.resolve().then(n.t.bind(n,61304,23)),Promise.resolve().then(n.t.bind(n,78616,23)),Promise.resolve().then(n.t.bind(n,64777,23)),Promise.resolve().then(n.t.bind(n,57121,23)),Promise.resolve().then(n.t.bind(n,74581,23)),Promise.resolve().then(n.t.bind(n,90484,23)),Promise.resolve().then(n.bind(n,86869))}},e=>{var s=s=>e(e.s=s);e.O(0,[28441,93794],()=>(s(83861),s(24367))),_N_E=e.O()}]);
|
||||
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,47 @@
|
||||
(function () {
|
||||
try {
|
||||
var KEY = "ntk_pid";
|
||||
function r() {
|
||||
var b = new Uint8Array(16);
|
||||
crypto && crypto.getRandomValues
|
||||
? crypto.getRandomValues(b)
|
||||
: b.forEach(function (_, i) {
|
||||
b[i] = Math.random() * 256;
|
||||
});
|
||||
var h = "";
|
||||
for (var i = 0; i < b.length; i++)
|
||||
h += ("0" + b[i].toString(16)).slice(-2);
|
||||
return h;
|
||||
}
|
||||
var c = document.cookie.match(/(?:^|; )ntk_pid=([a-f0-9]{32})/);
|
||||
var ck = c ? c[1] : "";
|
||||
var ls = "";
|
||||
try {
|
||||
ls = localStorage.getItem(KEY) || "";
|
||||
} catch (_) {}
|
||||
var pid = ck || (ls.match(/^[a-f0-9]{32}$/) ? ls : "") || r();
|
||||
if (!ck) {
|
||||
var pidSecure = location.protocol === "https:" ? "; Secure" : "";
|
||||
document.cookie =
|
||||
"ntk_pid=" + pid + "; Path=/; Max-Age=63072000; SameSite=Lax" + pidSecure;
|
||||
}
|
||||
try {
|
||||
if (ls !== pid) localStorage.setItem(KEY, pid);
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (window.indexedDB) {
|
||||
var req = indexedDB.open("ntk", 1);
|
||||
req.onupgradeneeded = function (e) {
|
||||
e.target.result.createObjectStore("kv");
|
||||
};
|
||||
req.onsuccess = function (e) {
|
||||
try {
|
||||
var db = e.target.result;
|
||||
var tx = db.transaction("kv", "readwrite");
|
||||
tx.objectStore("kv").put(pid, KEY);
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (_) {}
|
||||
})();
|
||||
@@ -0,0 +1,8 @@
|
||||
(function () {
|
||||
try {
|
||||
var m = document.cookie.match(/(?:^|; )theme=([^;]+)/);
|
||||
var t = m ? decodeURIComponent(m[1]) : "light";
|
||||
if (t !== "dark" && t !== "light") t = "light";
|
||||
document.documentElement.setAttribute("data-theme", t);
|
||||
} catch (_) {}
|
||||
})();
|
||||
@@ -0,0 +1,214 @@
|
||||
/* visitorlive tracker v2 — 대형 분석 서비스 표준 (page_view, session, UTM, referrer, SPA)
|
||||
*
|
||||
* 임베드 예시:
|
||||
* <script src="https://whoas.xyz/live/track.js" async></script>
|
||||
* <script src="https://whoas.xyz/live/track.js" data-site="myblog" async></script>
|
||||
*
|
||||
* 자동 추적: page_view (자동), session_start, session_end
|
||||
* 수동 이벤트: window.vlive('event_name', {props})
|
||||
*/
|
||||
(function () {
|
||||
if (window.__vlive) return; window.__vlive = true;
|
||||
|
||||
var script = document.currentScript || (function() {
|
||||
var ss = document.getElementsByTagName('script');
|
||||
return ss[ss.length - 1];
|
||||
})();
|
||||
|
||||
// ── SERVER & SITE ──
|
||||
var SERVER, SITE;
|
||||
try {
|
||||
var u = new URL(script.src);
|
||||
SERVER = u.host;
|
||||
SITE = script.getAttribute('data-site') || u.searchParams.get('site') || '';
|
||||
} catch(e) {
|
||||
SERVER = location.host;
|
||||
SITE = '';
|
||||
}
|
||||
if (!SITE) SITE = location.hostname.replace(/[^a-zA-Z0-9._-]/g,'').toLowerCase().slice(0,64);
|
||||
if (!SITE) SITE = 'unknown';
|
||||
|
||||
// ── ANON_ID (영구 — 브라우저 단위) ──
|
||||
var COOKIE_NAME = '__vsid';
|
||||
function readCookie() { try { var m=document.cookie.match(new RegExp('(?:^|; )'+COOKIE_NAME+'=([^;]+)')); return m?decodeURIComponent(m[1]):''; } catch(_){return '';} }
|
||||
function writeCookie(v) { try { var sec=location.protocol==='https:'?'; Secure':''; document.cookie=COOKIE_NAME+'='+encodeURIComponent(v)+'; path=/; max-age=31536000; SameSite=Lax'+sec; } catch(_){} }
|
||||
function readLS() { try { return localStorage.getItem(COOKIE_NAME)||''; } catch(_){return '';} }
|
||||
function writeLS(v) { try { localStorage.setItem(COOKIE_NAME, v); } catch(_){} }
|
||||
function readSS() { try { return sessionStorage.getItem(COOKIE_NAME)||''; } catch(_){return '';} }
|
||||
function writeSS(v) { try { sessionStorage.setItem(COOKIE_NAME, v); } catch(_){} }
|
||||
|
||||
function genId() {
|
||||
if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,function(c){
|
||||
var r=Math.random()*16|0,v=c==='x'?r:(r&0x3)|0x8; return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAnonId() {
|
||||
var v = readCookie() || readLS() || readSS();
|
||||
if (!v) v = genId();
|
||||
writeCookie(v); writeLS(v); writeSS(v);
|
||||
return v;
|
||||
}
|
||||
var ANON_ID = resolveAnonId();
|
||||
|
||||
// ── SESSION_ID (30분 idle 만료) ──
|
||||
var SESSION_KEY = '__vlive_sess';
|
||||
var SESSION_IDLE_MS = 30 * 60 * 1000; // 30분
|
||||
|
||||
function getSession() {
|
||||
try {
|
||||
var raw = sessionStorage.getItem(SESSION_KEY) || localStorage.getItem(SESSION_KEY);
|
||||
if (raw) {
|
||||
var s = JSON.parse(raw);
|
||||
if (s && s.id && s.last && (Date.now()-s.last) < SESSION_IDLE_MS) {
|
||||
s.last = Date.now();
|
||||
var data = JSON.stringify(s);
|
||||
sessionStorage.setItem(SESSION_KEY, data);
|
||||
localStorage.setItem(SESSION_KEY, data);
|
||||
return { id: s.id, isNew: false, started: s.started };
|
||||
}
|
||||
}
|
||||
} catch(_){}
|
||||
var now = Date.now();
|
||||
var sess = { id: genId(), started: now, last: now };
|
||||
try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(sess)); } catch(_){}
|
||||
try { localStorage.setItem(SESSION_KEY, JSON.stringify(sess)); } catch(_){}
|
||||
return { id: sess.id, isNew: true, started: now };
|
||||
}
|
||||
function touchSession() {
|
||||
try {
|
||||
var raw = sessionStorage.getItem(SESSION_KEY) || localStorage.getItem(SESSION_KEY);
|
||||
if (raw) {
|
||||
var s = JSON.parse(raw); s.last = Date.now();
|
||||
var data = JSON.stringify(s);
|
||||
sessionStorage.setItem(SESSION_KEY, data);
|
||||
localStorage.setItem(SESSION_KEY, data);
|
||||
}
|
||||
} catch(_){}
|
||||
}
|
||||
var SESSION = getSession();
|
||||
|
||||
// ── UTM/REFERRER ──
|
||||
function parseUTM() {
|
||||
var p = new URLSearchParams(location.search);
|
||||
return {
|
||||
utm_source: p.get('utm_source')||'',
|
||||
utm_medium: p.get('utm_medium')||'',
|
||||
utm_campaign: p.get('utm_campaign')||'',
|
||||
utm_term: p.get('utm_term')||'',
|
||||
utm_content: p.get('utm_content')||''
|
||||
};
|
||||
}
|
||||
|
||||
// ── 이벤트 BUILDER ──
|
||||
function buildEvent(name, props) {
|
||||
var utm = parseUTM();
|
||||
return {
|
||||
n: name,
|
||||
anon_id: ANON_ID,
|
||||
session_id: SESSION.id,
|
||||
site: SITE,
|
||||
page_path: location.pathname,
|
||||
page_url: location.href.slice(0,2000),
|
||||
page_title: (document.title||'').slice(0,200),
|
||||
referrer: (document.referrer||'').slice(0,1000),
|
||||
utm_source: utm.utm_source.slice(0,100),
|
||||
utm_medium: utm.utm_medium.slice(0,100),
|
||||
utm_campaign: utm.utm_campaign.slice(0,200),
|
||||
utm_term: utm.utm_term.slice(0,200),
|
||||
utm_content: utm.utm_content.slice(0,200),
|
||||
screen_w: screen.width||0,
|
||||
screen_h: screen.height||0,
|
||||
viewport_w: window.innerWidth||0,
|
||||
viewport_h: window.innerHeight||0,
|
||||
ts: Date.now(),
|
||||
props: props || null
|
||||
};
|
||||
}
|
||||
|
||||
// ── 전송 (sendBeacon → fetch keepalive fallback) ──
|
||||
function send(ev) {
|
||||
var url = 'https://' + SERVER + '/collect';
|
||||
var body = JSON.stringify(ev);
|
||||
try {
|
||||
if (navigator.sendBeacon) {
|
||||
var blob = new Blob([body], {type:'application/json'});
|
||||
if (navigator.sendBeacon(url, blob)) return;
|
||||
}
|
||||
} catch(_){}
|
||||
try {
|
||||
fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:body, keepalive:true, mode:'no-cors'}).catch(function(){});
|
||||
} catch(_){}
|
||||
}
|
||||
|
||||
// ── 공개 API ──
|
||||
window.vlive = function(name, props) {
|
||||
if (typeof name !== 'string' || !name) return;
|
||||
touchSession();
|
||||
send(buildEvent(name, props));
|
||||
};
|
||||
|
||||
// ── 자동: page_view ──
|
||||
function pageView() {
|
||||
touchSession();
|
||||
send(buildEvent('page_view'));
|
||||
}
|
||||
|
||||
// ── 첫 진입 ──
|
||||
if (SESSION.isNew) send(buildEvent('session_start'));
|
||||
pageView();
|
||||
|
||||
// ── SPA pushState/replaceState/popstate hook ──
|
||||
(function(){
|
||||
var lastPath = location.pathname + location.search;
|
||||
function check(){
|
||||
var cur = location.pathname + location.search;
|
||||
if (cur !== lastPath) { lastPath = cur; pageView(); }
|
||||
}
|
||||
var origPush = history.pushState;
|
||||
var origReplace = history.replaceState;
|
||||
history.pushState = function(){ var r=origPush.apply(this, arguments); setTimeout(check,0); return r; };
|
||||
history.replaceState = function(){ var r=origReplace.apply(this, arguments); setTimeout(check,0); return r; };
|
||||
window.addEventListener('popstate', check);
|
||||
})();
|
||||
|
||||
// ── 실시간 동접 alive beacon (whos.amung.us 모델, HTTP polling) ──
|
||||
// 기존 WebSocket 영속 연결 → 60초 polling 으로 교체. 30만 동접 부하 99% 감소.
|
||||
// sendBeacon 로 비차단 전송, 페이지 background 시 skip.
|
||||
function aliveBeacon() {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
var url = 'https://' + SERVER + '/beacon';
|
||||
var body = JSON.stringify({anon_id: ANON_ID, site: SITE});
|
||||
try {
|
||||
if (navigator.sendBeacon) {
|
||||
var blob = new Blob([body], {type:'application/json'});
|
||||
if (navigator.sendBeacon(url, blob)) { touchSession(); return; }
|
||||
}
|
||||
} catch(_){}
|
||||
try {
|
||||
fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:body, keepalive:true, mode:'no-cors'}).catch(function(){});
|
||||
touchSession();
|
||||
} catch(_){}
|
||||
}
|
||||
aliveBeacon(); // 첫 진입 즉시 1번
|
||||
setInterval(aliveBeacon, 60000); // 60초마다
|
||||
// 페이지 visibility 복귀 시 즉시 1번 (background→foreground 전환 케이스)
|
||||
document.addEventListener('visibilitychange', function(){
|
||||
if (document.visibilityState === 'visible') aliveBeacon();
|
||||
});
|
||||
|
||||
// ── 페이지 이탈 시 session_end ──
|
||||
var sessionStartedAt = SESSION.started;
|
||||
var pageviewCount = 1;
|
||||
window.addEventListener('pagehide', function() {
|
||||
var ev = buildEvent('session_end');
|
||||
ev.duration_sec = Math.floor((Date.now()-sessionStartedAt)/1000);
|
||||
ev.props = JSON.stringify({pageviews: pageviewCount});
|
||||
send(ev);
|
||||
});
|
||||
|
||||
// pageView 카운트
|
||||
var origPageView = pageView;
|
||||
pageView = function() { pageviewCount++; origPageView(); };
|
||||
})();
|
||||
181
Samples/마법사의 딸이 아니올시다 24화 마법사의 딸이 아니…시다 21화 후편 _ 뉴토끼.html
Normal file
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 67 KiB |