async function fetchNovelContent(url) { const response = await fetch(url); if (!response.ok) { console.error(`Failed to fetch content from ${url}. Status: ${response.status}`); return null; } const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Extract episode title const titleElement = doc.querySelector('.toon-title'); let episodeTitle = 'Untitled Episode'; if (titleElement) { episodeTitle = titleElement.getAttribute('title') || titleElement.textContent.split('<br>')[0].trim() || 'Untitled Episode'; } const content = doc.querySelector('#novel_content'); if (!content) { console.error(`Failed to find '#novel_content' on the page: ${url}`); return null; } let cleanedContent = cleanText(content.innerHTML); if (cleanedContent.startsWith(episodeTitle)) { cleanedContent = cleanedContent.slice(episodeTitle.length).trim(); } return { episodeTitle: episodeTitle, content: cleanedContent }; } function unescapeHTML(text) { const entities = { '<': '<', '>': '>', '&': '&', '"': '"', ''': "'", ' ': ' ', '–': '-', '—': '--', '‘': "'", '’': "'", '“': '"', '”': '"' }; Object.entries(entities).forEach(([entity, replacement]) => { const regex = new RegExp(entity, 'g'); text = text.replace(regex, replacement); }); return text; } function cleanText(text) { text = text.replace(/<div>/g, ''); text = text.replace(/<\/div>/g, ''); text = text.replace(/<p>/g, '\n'); text = text.replace(/<\/p>/g, '\n'); text = text.replace(/<br\s*[/]?>/g, '\n'); text = text.replace(/<img[^>]*>/gi, '[skipped image]'); text = text.replace(/<[^>]*>/g, ''); text = text.replace(/ {2,}/g, ' '); text = unescapeHTML(text); text = text .split('\n') .map(line => line.trim()) .filter(line => line.length > 0) .join('\n\n') .replace(/\n{3,}/g, '\n\n'); return text; } function createModal(title) { // Add animation styles to document if not already added if (!document.getElementById('novel-dl-styles')) { const style = document.createElement('style'); style.id = 'novel-dl-styles'; style.textContent = ` @keyframes fadeIn { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } } @keyframes pulse { 0% { opacity: 0.7; } 50% { opacity: 1; } 100% { opacity: 0.7; } } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `; document.head.appendChild(style); } // Create modal container const modal = document.createElement('div'); modal.id = 'downloadProgressModal'; Object.assign(modal.style, { display: 'flex', position: 'fixed', zIndex: '9999', left: '0', top: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', alignItems: 'center', justifyContent: 'center', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' }); // Create modal content const modalContent = document.createElement('div'); Object.assign(modalContent.style, { backgroundColor: '#fff', borderRadius: '12px', boxShadow: '0 4px 24px rgba(0,0,0,0.15)', width: '450px', maxWidth: '90%', padding: '0', overflow: 'hidden', animation: 'fadeIn 0.3s' }); // Create header const header = document.createElement('div'); Object.assign(header.style, { backgroundColor: '#f9f9fb', borderBottom: '1px solid #eaecef', padding: '16px 20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }); // Add title to header const headerTitle = document.createElement('h3'); headerTitle.textContent = title; Object.assign(headerTitle.style, { margin: '0', color: '#172238', fontSize: '16px', fontWeight: '600' }); header.appendChild(headerTitle); // Add close button const closeButton = document.createElement('button'); closeButton.innerHTML = '×'; Object.assign(closeButton.style, { background: 'none', border: 'none', fontSize: '22px', cursor: 'pointer', color: '#666', padding: '0 4px', lineHeight: '1' }); closeButton.onclick = () => { if (confirm('다운로드를 취소하시겠습니까?')) { document.body.removeChild(modal); } }; header.appendChild(closeButton); modalContent.appendChild(header); // Create body const body = document.createElement('div'); Object.assign(body.style, { padding: '20px' }); modalContent.appendChild(body); // Create status element const statusElement = document.createElement('div'); Object.assign(statusElement.style, { marginBottom: '16px', fontSize: '14px', color: '#444', fontWeight: '500' }); body.appendChild(statusElement); // Create progress info elements const progressInfo = document.createElement('div'); Object.assign(progressInfo.style, { display: 'flex', justifyContent: 'space-between', marginBottom: '10px', fontSize: '14px', color: '#555' }); const progressText = document.createElement('div'); progressText.textContent = '0%'; Object.assign(progressText.style, { fontWeight: '600' }); progressInfo.appendChild(progressText); const timeRemaining = document.createElement('div'); progressInfo.appendChild(timeRemaining); body.appendChild(progressInfo); // Create progress bar container const progressBarContainer = document.createElement('div'); Object.assign(progressBarContainer.style, { width: '100%', height: '8px', backgroundColor: '#eaecef', borderRadius: '8px', overflow: 'hidden' }); // Create progress bar const progressBar = document.createElement('div'); Object.assign(progressBar.style, { width: '0%', height: '100%', background: 'linear-gradient(90deg, #3a7bd5, #6fa1ff)', borderRadius: '8px', transition: 'width 0.3s ease' }); progressBarContainer.appendChild(progressBar); body.appendChild(progressBarContainer); // Create detailed progress element const detailedProgress = document.createElement('div'); Object.assign(detailedProgress.style, { marginTop: '16px', fontSize: '13px', color: '#666', textAlign: 'center' }); body.appendChild(detailedProgress); modal.appendChild(modalContent); return { modal, statusElement, progressText, timeRemaining, progressBar, detailedProgress }; } // Improved time estimation function with moving average function createProgressTracker(totalItems) { const startTime = Date.now(); const processingTimes = []; const MAX_SAMPLES = 5; // Use last 5 samples for moving average return { update: (completedItems) => { const progress = (completedItems / totalItems) * 100; const elapsed = Date.now() - startTime; // Calculate time per item and store for moving average if (completedItems > 0) { const currentTimePerItem = elapsed / completedItems; processingTimes.push(currentTimePerItem); // Keep only the most recent samples if (processingTimes.length > MAX_SAMPLES) { processingTimes.shift(); } } // Calculate moving average of processing time const avgTimePerItem = processingTimes.length > 0 ? processingTimes.reduce((sum, time) => sum + time, 0) / processingTimes.length : 0; // Calculate remaining time based on moving average const remainingItems = totalItems - completedItems; const estimatedRemainingTime = avgTimePerItem * remainingItems; return { progress: progress.toFixed(1), remaining: formatTime(estimatedRemainingTime), elapsed: formatTime(elapsed), speed: (avgTimePerItem > 0) ? (1000 / avgTimePerItem).toFixed(2) : "0.00" // Items per second }; } }; } function formatTime(ms) { if (ms < 1000) return "잠시만 기다려주세요..."; if (ms < 60000) { return `${Math.ceil(ms / 1000)}초`; } else if (ms < 3600000) { const mins = Math.floor(ms / 60000); const secs = Math.floor((ms % 60000) / 1000); return `${mins}분 ${secs}초`; } else { const hours = Math.floor(ms / 3600000); const mins = Math.floor((ms % 3600000) / 60000); return `${hours}시간 ${mins}분`; } } async function loadScript(url) { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = url; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } function sanitizeFilename(name) { return name.replace(/[/\\?%*:|"<>]/g, '_'); } async function downloadNovel(title, episodeLinks, startEpisode, endEpisode, delayMs = 5000) { // Create a modern dialog for save option selection const dialog = document.createElement('div'); Object.assign(dialog.style, { position: 'fixed', zIndex: '9999', left: '0', top: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' }); const dialogContent = document.createElement('div'); Object.assign(dialogContent.style, { backgroundColor: '#fff', borderRadius: '12px', boxShadow: '0 4px 24px rgba(0,0,0,0.15)', width: '350px', maxWidth: '90%', padding: '24px', animation: 'fadeIn 0.3s' }); const dialogTitle = document.createElement('h3'); dialogTitle.textContent = '저장 방식 선택'; Object.assign(dialogTitle.style, { margin: '0 0 20px 0', color: '#172238', fontSize: '18px', fontWeight: '600' }); dialogContent.appendChild(dialogTitle); const optionsContainer = document.createElement('div'); Object.assign(optionsContainer.style, { display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '24px' }); const createOption = (value, text, description) => { const option = document.createElement('div'); Object.assign(option.style, { padding: '14px', border: '1px solid #e4e9f0', borderRadius: '8px', cursor: 'pointer', backgroundColor: '#f9f9fb', transition: 'all 0.2s ease' }); option.innerHTML = ` <div style="font-weight: 600; color: #172238; margin-bottom: 4px;">${text}</div> <div style="font-size: 13px; color: #666;">${description}</div> `; option.onclick = () => { document.body.removeChild(dialog); processDownload(value === '1' ? false : true); }; option.onmouseover = () => { option.style.backgroundColor = '#f0f2f8'; option.style.borderColor = '#3a7bd5'; }; option.onmouseout = () => { option.style.backgroundColor = '#f9f9fb'; option.style.borderColor = '#e4e9f0'; }; return option; }; optionsContainer.appendChild(createOption('1', '한 파일로 병합', '모든 회차가 하나의 파일로 저장됩니다.')); optionsContainer.appendChild(createOption('2', '각 회차별 저장 (ZIP)', '각 회차를 개별 파일로 ZIP 압축합니다.')); dialogContent.appendChild(optionsContainer); const cancelButton = document.createElement('button'); cancelButton.textContent = '취소'; Object.assign(cancelButton.style, { width: '100%', padding: '10px', border: '1px solid #e4e9f0', borderRadius: '8px', backgroundColor: '#f9f9fb', cursor: 'pointer', fontSize: '14px', fontWeight: '500', transition: 'all 0.2s ease' }); cancelButton.onmouseover = () => { cancelButton.style.backgroundColor = '#f0f2f8'; }; cancelButton.onmouseout = () => { cancelButton.style.backgroundColor = '#f9f9fb'; }; cancelButton.onclick = () => { document.body.removeChild(dialog); }; dialogContent.appendChild(cancelButton); // Add developer contact link at bottom const contactContainer = document.createElement('div'); Object.assign(contactContainer.style, { marginTop: '16px', textAlign: 'center', fontSize: '13px' }); const contactLink = document.createElement('a'); contactLink.href = 'mailto:yeorinhieut@gmail.com'; contactLink.textContent = '개발자에게 연락하기'; Object.assign(contactLink.style, { color: '#666', textDecoration: 'none', borderBottom: '1px dotted #999' }); contactLink.onmouseover = () => { contactLink.style.color = '#3a7bd5'; contactLink.style.borderBottom = '1px dotted #3a7bd5'; }; contactLink.onmouseout = () => { contactLink.style.color = '#666'; contactLink.style.borderBottom = '1px dotted #999'; }; contactContainer.appendChild(contactLink); // Add separator const separator = document.createElement('span'); separator.textContent = ' · '; separator.style.color = '#999'; contactContainer.appendChild(separator); // Add issue reporting link const issueLink = document.createElement('a'); issueLink.href = 'https://github.com/yeorinhieut/novel-dl/issues'; issueLink.textContent = '오류 제보하기'; issueLink.target = '_blank'; // Open in new tab Object.assign(issueLink.style, { color: '#666', textDecoration: 'none', borderBottom: '1px dotted #999' }); issueLink.onmouseover = () => { issueLink.style.color = '#3a7bd5'; issueLink.style.borderBottom = '1px dotted #3a7bd5'; }; issueLink.onmouseout = () => { issueLink.style.color = '#666'; issueLink.style.borderBottom = '1px dotted #999'; }; contactContainer.appendChild(issueLink); dialogContent.appendChild(contactContainer); dialog.appendChild(dialogContent); document.body.appendChild(dialog); async function processDownload(saveAsZip) { let zip; if (saveAsZip) { try { await loadScript('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js'); zip = new JSZip(); } catch (e) { alert('ZIP 라이브러리 로드 실패!'); return; } } const startingIndex = episodeLinks.length - startEpisode; const endingIndex = episodeLinks.length - endEpisode; const totalEpisodes = startingIndex - endingIndex + 1; const { modal, statusElement, progressText, timeRemaining, progressBar, detailedProgress } = createModal(`"${title}" 다운로드 중`); document.body.appendChild(modal); // Initialize the progress tracker const progressTracker = createProgressTracker(totalEpisodes); let novelText = `${title}\n\nDownloaded with novel-dl,\nhttps://github.com/yeorinhieut/novel-dl\n\n`; let completedEpisodes = 0; let failedEpisodes = 0; let captchaCount = 0; statusElement.textContent = '다운로드를 준비하는 중...'; for (let i = startingIndex; i >= endingIndex; i--) { const episodeUrl = episodeLinks[i]; if (!episodeUrl.startsWith('https://booktoki')) { failedEpisodes++; continue; } const currentEpisode = startingIndex - i + 1; const episodeNumber = episodeLinks.length - i; statusElement.textContent = `${episodeNumber}화 다운로드 중... (${currentEpisode}/${totalEpisodes})`; let result = await fetchNovelContent(episodeUrl); if (!result) { captchaCount++; statusElement.textContent = `⚠️ CAPTCHA 감지됨! ${episodeNumber}화를 처리할 수 없습니다.`; const userConfirmed = confirm(`CAPTCHA가 발견되었습니다!\n${episodeUrl}\n\n캡챠를 해결한 후 확인을 눌러주세요.`); if (!userConfirmed) { failedEpisodes++; continue; } statusElement.textContent = `${episodeNumber}화 다시 시도 중...`; result = await fetchNovelContent(episodeUrl); if (!result) { statusElement.textContent = `❌ ${episodeNumber}화 다운로드 실패`; failedEpisodes++; continue; } } const {episodeTitle, content} = result; if (saveAsZip) { zip.file(`${sanitizeFilename(episodeTitle)}.txt`, content); } else { novelText += `${episodeTitle}\n\n${content}\n\n`; } completedEpisodes++; const stats = progressTracker.update(completedEpisodes); progressBar.style.width = `${stats.progress}%`; progressText.textContent = `${stats.progress}%`; timeRemaining.textContent = `남은 시간: ${stats.remaining}`; detailedProgress.innerHTML = ` <div style="margin-bottom: 4px; display: flex; justify-content: center; gap: 12px;"> <span>✅ 완료: ${completedEpisodes}화</span> <span>❌ 실패: ${failedEpisodes}화</span> <span>⚠️ 캡챠: ${captchaCount}회</span> </div> <div>소요 시간: ${stats.elapsed} | 처리 속도: ${stats.speed}화/초</div> `; // Add configurable delay to prevent rate limiting await new Promise(r => setTimeout(r, delayMs)); } statusElement.textContent = '✅ 다운로드 완료, 파일 생성 중...'; progressBar.style.width = '100%'; progressText.textContent = '100%'; setTimeout(() => { document.body.removeChild(modal); // Create completion dialog const completionDialog = document.createElement('div'); Object.assign(completionDialog.style, { position: 'fixed', zIndex: '9999', left: '0', top: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' }); const completionContent = document.createElement('div'); Object.assign(completionContent.style, { backgroundColor: '#fff', borderRadius: '12px', boxShadow: '0 4px 24px rgba(0,0,0,0.15)', width: '400px', maxWidth: '90%', padding: '24px', animation: 'fadeIn 0.3s', textAlign: 'center' }); // Success icon const successIcon = document.createElement('div'); successIcon.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#4CAF50" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path> <polyline points="22 4 12 14.01 9 11.01"></polyline> </svg> `; Object.assign(successIcon.style, { display: 'flex', justifyContent: 'center', marginBottom: '16px' }); completionContent.appendChild(successIcon); // Completion title const completionTitle = document.createElement('h3'); completionTitle.textContent = '다운로드가 완료되었어요!'; Object.assign(completionTitle.style, { color: '#172238', fontSize: '18px', margin: '0 0 8px 0' }); completionContent.appendChild(completionTitle); // Completion message const completionMessage = document.createElement('p'); completionMessage.textContent = `${completedEpisodes}화의 다운로드가 준비되었습니다.`; Object.assign(completionMessage.style, { color: '#666', margin: '0 0 24px 0', fontSize: '14px' }); completionContent.appendChild(completionMessage); // Download button const downloadBtn = document.createElement('button'); downloadBtn.textContent = '다운로드'; Object.assign(downloadBtn.style, { backgroundColor: '#4CAF50', color: 'white', border: 'none', padding: '12px 24px', borderRadius: '8px', fontSize: '14px', fontWeight: '500', cursor: 'pointer', marginBottom: '24px', width: '100%', transition: 'background-color 0.2s' }); downloadBtn.onmouseover = () => { downloadBtn.style.backgroundColor = '#388E3C'; }; downloadBtn.onmouseout = () => { downloadBtn.style.backgroundColor = '#4CAF50'; }; downloadBtn.onclick = () => { if (saveAsZip) { zip.generateAsync({type: 'blob'}).then(blob => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${sanitizeFilename(title)}.zip`; a.click(); // Show a success notification after clicking download showNotification(`"${title}" 다운로드 시작`, `${completedEpisodes}화가 ZIP 파일로 저장됩니다.`); document.body.removeChild(completionDialog); }); } else { const blob = new Blob([novelText], {type: 'text/plain'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${sanitizeFilename(title)}(${startEpisode}~${endEpisode}).txt`; a.click(); // Show a success notification after clicking download showNotification(`"${title}" 다운로드 시작`, `${completedEpisodes}화가 텍스트 파일로 저장됩니다.`); document.body.removeChild(completionDialog); } }; completionContent.appendChild(downloadBtn); // Developer contact link const contactLink = document.createElement('a'); contactLink.href = 'mailto:yeorinhieut@gmail.com'; contactLink.textContent = '개발자에게 연락하기'; Object.assign(contactLink.style, { display: 'inline-block', color: '#666', fontSize: '13px', textDecoration: 'none', borderBottom: '1px dotted #999' }); contactLink.onmouseover = () => { contactLink.style.color = '#3a7bd5'; contactLink.style.borderBottom = '1px dotted #3a7bd5'; }; contactLink.onmouseout = () => { contactLink.style.color = '#666'; contactLink.style.borderBottom = '1px dotted #999'; }; completionContent.appendChild(contactLink); // Add separator const separator = document.createElement('span'); separator.textContent = ' · '; separator.style.color = '#999'; completionContent.appendChild(separator); // Add issue reporting link const issueLink = document.createElement('a'); issueLink.href = 'https://github.com/yeorinhieut/novel-dl/issues'; issueLink.textContent = '오류 제보하기'; issueLink.target = '_blank'; // Open in new tab Object.assign(issueLink.style, { color: '#666', fontSize: '13px', textDecoration: 'none', borderBottom: '1px dotted #999' }); issueLink.onmouseover = () => { issueLink.style.color = '#3a7bd5'; issueLink.style.borderBottom = '1px dotted #3a7bd5'; }; issueLink.onmouseout = () => { issueLink.style.color = '#666'; issueLink.style.borderBottom = '1px dotted #999'; }; completionContent.appendChild(issueLink); completionDialog.appendChild(completionContent); document.body.appendChild(completionDialog); }, 500); } } function showNotification(title, message) { const notification = document.createElement('div'); Object.assign(notification.style, { position: 'fixed', bottom: '20px', right: '20px', backgroundColor: '#fff', borderLeft: '4px solid #4CAF50', borderRadius: '4px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', padding: '16px', zIndex: '9999', maxWidth: '320px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', animation: 'fadeIn 0.3s' }); notification.innerHTML = ` <div style="font-weight: 600; color: #172238; margin-bottom: 4px;">${title}</div> <div style="font-size: 13px; color: #666;">${message}</div> `; document.body.appendChild(notification); // Auto remove after 5 seconds setTimeout(() => { notification.style.opacity = '0'; notification.style.transition = 'opacity 0.3s'; setTimeout(() => document.body.removeChild(notification), 300); }, 5000); } function extractTitle() { const titleElement = document.evaluate('//*[@id="content_wrapper"]/div[1]/span', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; return titleElement ? titleElement.textContent.trim() : null; } function extractEpisodeLinks() { const episodeLinks = []; const links = document.querySelectorAll('.item-subject'); links.forEach(link => { const episodeLink = link.getAttribute('href'); episodeLinks.push(episodeLink); }); return episodeLinks; } async function fetchPage(url) { const response = await fetch(url); if (!response.ok) { console.error(`Failed to fetch page: ${url}. Status: ${response.status}`); return null; } const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); return doc; } async function runCrawler() { const novelPageRule = 'https://booktoki'; let currentUrl = window.location.href; // Clean URL const urlParts = currentUrl.split('?')[0]; currentUrl = urlParts; if (!currentUrl.startsWith(novelPageRule)) { alert('이 스크립트는 북토기 소설 목록 페이지에서 실행해야 합니다.'); return; } const title = extractTitle(); if (!title) { alert('소설 제목을 추출하지 못했습니다.'); return; } // Create a modern UI for input const dialog = document.createElement('div'); Object.assign(dialog.style, { position: 'fixed', zIndex: '9999', left: '0', top: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' }); const dialogContent = document.createElement('div'); Object.assign(dialogContent.style, { backgroundColor: '#fff', borderRadius: '12px', boxShadow: '0 4px 24px rgba(0,0,0,0.15)', width: '400px', maxWidth: '90%', padding: '24px', animation: 'fadeIn 0.3s' }); const dialogTitle = document.createElement('h3'); dialogTitle.textContent = `"${title}" 다운로드 설정`; Object.assign(dialogTitle.style, { margin: '0 0 20px 0', color: '#172238', fontSize: '18px', fontWeight: '600' }); dialogContent.appendChild(dialogTitle); // Function to create input groups function createInputGroup(labelText, inputType, defaultValue, placeholder, description) { const group = document.createElement('div'); Object.assign(group.style, { marginBottom: '20px' }); const label = document.createElement('label'); label.textContent = labelText; Object.assign(label.style, { display: 'block', marginBottom: '8px', fontSize: '14px', color: '#444', fontWeight: '500' }); group.appendChild(label); if (description) { const desc = document.createElement('div'); desc.textContent = description; Object.assign(desc.style, { fontSize: '13px', color: '#666', marginBottom: '8px' }); group.appendChild(desc); } const input = document.createElement('input'); input.type = inputType; input.value = defaultValue; input.placeholder = placeholder || ''; Object.assign(input.style, { width: '100%', padding: '10px', border: '1px solid #e4e9f0', borderRadius: '8px', fontSize: '14px', boxSizing: 'border-box' }); group.appendChild(input); return { group, input }; } // Pages input const pagesInput = createInputGroup( '소설 목록의 페이지 수', 'number', '1', '페이지 수 입력', '1000화가 넘지 않는 경우 1, 1000화 이상부터 2~ 입력' ); dialogContent.appendChild(pagesInput.group); pagesInput.input.min = 1; // Buttons container const buttonsContainer = document.createElement('div'); Object.assign(buttonsContainer.style, { display: 'flex', justifyContent: 'space-between', marginTop: '16px', gap: '12px' }); // Cancel button const cancelButton = document.createElement('button'); cancelButton.textContent = '취소'; Object.assign(cancelButton.style, { flex: '1', padding: '10px', border: '1px solid #e4e9f0', borderRadius: '8px', backgroundColor: '#f9f9fb', cursor: 'pointer', fontSize: '14px', fontWeight: '500', transition: 'all 0.2s ease' }); cancelButton.onmouseover = () => { cancelButton.style.backgroundColor = '#f0f2f8'; }; cancelButton.onmouseout = () => { cancelButton.style.backgroundColor = '#f9f9fb'; }; cancelButton.onclick = () => document.body.removeChild(dialog); buttonsContainer.appendChild(cancelButton); // Continue button const continueButton = document.createElement('button'); continueButton.textContent = '계속'; Object.assign(continueButton.style, { flex: '1', padding: '10px', border: 'none', borderRadius: '8px', backgroundColor: '#3a7bd5', color: 'white', cursor: 'pointer', fontSize: '14px', fontWeight: '500', transition: 'all 0.2s ease' }); continueButton.onmouseover = () => { continueButton.style.backgroundColor = '#2d62aa'; }; continueButton.onmouseout = () => { continueButton.style.backgroundColor = '#3a7bd5'; }; buttonsContainer.appendChild(continueButton); dialogContent.appendChild(buttonsContainer); dialog.appendChild(dialogContent); document.body.appendChild(dialog); // Handle continue button click continueButton.onclick = async () => { const totalPages = parseInt(pagesInput.input.value, 10); if (isNaN(totalPages) || totalPages < 1) { alert('유효한 페이지 수를 입력해주세요.'); return; } document.body.removeChild(dialog); // Show loading dialog const loadingDialog = document.createElement('div'); Object.assign(loadingDialog.style, { position: 'fixed', zIndex: '9999', left: '0', top: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' }); const loadingContent = document.createElement('div'); Object.assign(loadingContent.style, { backgroundColor: '#fff', borderRadius: '12px', boxShadow: '0 4px 24px rgba(0,0,0,0.15)', width: '300px', maxWidth: '90%', padding: '24px', textAlign: 'center' }); const loadingTitle = document.createElement('h3'); loadingTitle.textContent = '에피소드 목록 불러오는 중'; Object.assign(loadingTitle.style, { margin: '0 0 16px 0', color: '#172238', fontSize: '16px', fontWeight: '600' }); loadingContent.appendChild(loadingTitle); const loadingText = document.createElement('p'); loadingText.textContent = '잠시만 기다려주세요...'; Object.assign(loadingText.style, { margin: '0 0 20px 0', fontSize: '14px', color: '#555' }); loadingContent.appendChild(loadingText); // Create loading animation const spinnerContainer = document.createElement('div'); Object.assign(spinnerContainer.style, { display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: '16px' }); const spinner = document.createElement('div'); Object.assign(spinner.style, { width: '32px', height: '32px', border: '3px solid #f3f3f3', borderTop: '3px solid #3a7bd5', borderRadius: '50%', animation: 'spin 1s linear infinite' }); spinnerContainer.appendChild(spinner); loadingContent.appendChild(spinnerContainer); loadingDialog.appendChild(loadingContent); document.body.appendChild(loadingDialog); // Fetch all episode links with progress updates const allEpisodeLinks = []; for (let page = 1; page <= totalPages; page++) { loadingText.textContent = `페이지 ${page}/${totalPages} 불러오는 중...`; const nextPageUrl = `${currentUrl}?spage=${page}`; const nextPageDoc = await fetchPage(nextPageUrl); if (nextPageDoc) { const nextPageLinks = Array.from(nextPageDoc.querySelectorAll('.item-subject')).map(link => link.getAttribute('href')); allEpisodeLinks.push(...nextPageLinks); loadingText.textContent = `${allEpisodeLinks.length}개 에피소드 발견됨`; } // Small delay to prevent rate limiting await new Promise(r => setTimeout(r, 500)); } document.body.removeChild(loadingDialog); if (allEpisodeLinks.length === 0) { alert('에피소드 목록을 가져오지 못했습니다.'); return; } // Create episode range dialog const rangeDialog = document.createElement('div'); Object.assign(rangeDialog.style, { position: 'fixed', zIndex: '9999', left: '0', top: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' }); const rangeContent = document.createElement('div'); Object.assign(rangeContent.style, { backgroundColor: '#fff', borderRadius: '12px', boxShadow: '0 4px 24px rgba(0,0,0,0.15)', width: '400px', maxWidth: '90%', padding: '24px', animation: 'fadeIn 0.3s' }); const rangeTitle = document.createElement('h3'); rangeTitle.textContent = '다운로드 범위 설정'; Object.assign(rangeTitle.style, { margin: '0 0 16px 0', color: '#172238', fontSize: '18px', fontWeight: '600' }); rangeContent.appendChild(rangeTitle); const episodeCount = document.createElement('div'); episodeCount.innerHTML = `<span style="display: inline-block; background-color: #ebf5ff; color: #3a7bd5; padding: 4px 8px; border-radius: 4px; font-weight: 500;">전체 ${allEpisodeLinks.length}화가 발견되었습니다.</span>`; Object.assign(episodeCount.style, { margin: '0 0 20px 0', fontSize: '14px' }); rangeContent.appendChild(episodeCount); // Start episode input const startInput = createInputGroup('시작 회차', 'number', '1', '시작 회차 번호'); rangeContent.appendChild(startInput.group); startInput.input.min = 1; startInput.input.max = allEpisodeLinks.length; // End episode input const endInput = createInputGroup('종료 회차', 'number', allEpisodeLinks.length.toString(), '종료 회차 번호'); rangeContent.appendChild(endInput.group); endInput.input.min = 1; endInput.input.max = allEpisodeLinks.length; // Delay input with warning const delayInput = createInputGroup( '딜레이 설정 (밀리초)', 'number', '5000', '딜레이 입력', '⚠️ 권장: 기본값(5000ms=5초)을 유지하세요. 변경 시 차단 위험이 있습니다.' ); rangeContent.appendChild(delayInput.group); delayInput.input.min = 1000; delayInput.input.style.border = '1px solid #ffcc00'; delayInput.input.style.backgroundColor = '#fffbf0'; // Range buttons const rangeButtons = document.createElement('div'); Object.assign(rangeButtons.style, { display: 'flex', justifyContent: 'space-between', marginTop: '20px', gap: '12px' }); // Cancel button const rangeCancelButton = document.createElement('button'); rangeCancelButton.textContent = '취소'; Object.assign(rangeCancelButton.style, { flex: '1', padding: '10px', border: '1px solid #e4e9f0', borderRadius: '8px', backgroundColor: '#f9f9fb', cursor: 'pointer', fontSize: '14px', fontWeight: '500', transition: 'all 0.2s ease' }); rangeCancelButton.onmouseover = () => { rangeCancelButton.style.backgroundColor = '#f0f2f8'; }; rangeCancelButton.onmouseout = () => { rangeCancelButton.style.backgroundColor = '#f9f9fb'; }; rangeCancelButton.onclick = () => document.body.removeChild(rangeDialog); rangeButtons.appendChild(rangeCancelButton); // Download button const downloadButton = document.createElement('button'); downloadButton.textContent = '다운로드'; Object.assign(downloadButton.style, { flex: '1', padding: '10px', border: 'none', borderRadius: '8px', backgroundColor: '#3a7bd5', color: 'white', cursor: 'pointer', fontSize: '14px', fontWeight: '500', transition: 'all 0.2s ease' }); downloadButton.onmouseover = () => { downloadButton.style.backgroundColor = '#2d62aa'; }; downloadButton.onmouseout = () => { downloadButton.style.backgroundColor = '#3a7bd5'; }; rangeButtons.appendChild(downloadButton); rangeContent.appendChild(rangeButtons); // Add developer contact link at bottom const contactContainer = document.createElement('div'); Object.assign(contactContainer.style, { marginTop: '16px', textAlign: 'center', fontSize: '13px' }); const contactLink = document.createElement('a'); contactLink.href = 'mailto:yeorinhieut@gmail.com'; contactLink.textContent = '개발자에게 연락하기'; Object.assign(contactLink.style, { color: '#666', textDecoration: 'none', borderBottom: '1px dotted #999' }); contactLink.onmouseover = () => { contactLink.style.color = '#3a7bd5'; contactLink.style.borderBottom = '1px dotted #3a7bd5'; }; contactLink.onmouseout = () => { contactLink.style.color = '#666'; contactLink.style.borderBottom = '1px dotted #999'; }; contactContainer.appendChild(contactLink); // Add separator const separator = document.createElement('span'); separator.textContent = ' · '; separator.style.color = '#999'; contactContainer.appendChild(separator); // Add issue reporting link const issueLink = document.createElement('a'); issueLink.href = 'https://github.com/yeorinhieut/novel-dl/issues'; issueLink.textContent = '오류 제보하기'; issueLink.target = '_blank'; // Open in new tab Object.assign(issueLink.style, { color: '#666', textDecoration: 'none', borderBottom: '1px dotted #999' }); issueLink.onmouseover = () => { issueLink.style.color = '#3a7bd5'; issueLink.style.borderBottom = '1px dotted #3a7bd5'; }; issueLink.onmouseout = () => { issueLink.style.color = '#666'; issueLink.style.borderBottom = '1px dotted #999'; }; contactContainer.appendChild(issueLink); rangeContent.appendChild(contactContainer); rangeDialog.appendChild(rangeContent); document.body.appendChild(rangeDialog); // Handle download button click downloadButton.onclick = () => { const startEpisode = parseInt(startInput.input.value, 10); const endEpisode = parseInt(endInput.input.value, 10); if (isNaN(startEpisode) || isNaN(endEpisode) || startEpisode < 1 || endEpisode < startEpisode || endEpisode > allEpisodeLinks.length) { alert('유효한 회차 범위를 입력해주세요.'); return; } const delay = parseInt(delayInput.input.value, 10); if (isNaN(delay) || delay < 1000) { alert('유효한 딜레이 값을 입력해주세요. (최소 1000ms)'); return; } document.body.removeChild(rangeDialog); console.log(`Task Appended: Preparing to download ${title} starting from episode ${startEpisode} to ${endEpisode}`); downloadNovel(title, allEpisodeLinks, startEpisode, endEpisode, delay); }; }; } runCrawler();