기존의 것이 잘되어서 업데이트는 필요없는 것 같은데 일단 지금 쓰고 있는걸 올립니다.
// ==UserScript== // @name 69shuba Novel Downloader (Background Fetch) // @namespace http://tampermonkey.net/ // @version 4.13 // 하단 버튼 크기 축소 및 '모두 해제' 버튼 추가 // @description Downloads novel text from 69shuba.com by fetching chapters in the background. Fixes encoding, extracts full content, handles 429/403 errors, and improves UI layout. // @author You // @match https://www.69shuba.com/book/* // @grant GM_download // @grant GM_xmlhttpRequest // @require https://code.jquery.com/jquery-3.6.0.min.js // ==/UserScript== (function() { 'use strict'; // Selectors and constants const episodeListSelector = 'ul li a[href*="/txt/"]'; const textToRemove = '最⊥新⊥小⊥说⊥在⊥六⊥9⊥⊥书⊥⊥吧⊥⊥首⊥发!'; let isCheckAll = false; let countLabel; // 429 에러 대응 및 딜레이 설정 const BASE_DELAY_MS = 1500; const INITIAL_PAGE_DELAY_MS = 500; const MAX_RETRIES = 5; const MIN_FILE_SIZE_KB = 1; function log(message) { console.log(`[Tampermonkey: 69shuba Downloader] ${message}`); } // ======================================================================= // 1. CSS 및 UI 수정 // ======================================================================= function addGlobalStyle() { if ($('#tampermonkey-shuba-style').length > 0) return; const style = document.createElement('style'); style.id = 'tampermonkey-shuba-style'; style.innerHTML = ` .tampermonkey-ui-element { display: inline-block; margin-left: 5px; vertical-align: middle; } #tampermonkey-count-label { position: fixed !important; top: 60px !important; left: 50% !important; transform: translateX(-50%) !important; z-index: 99999 !important; background-color: #007bff !important; color: white !important; padding: 8px 15px !important; border-radius: 20px !important; font-family: sans-serif !important; font-size: 14px !important; font-weight: bold !important; box-shadow: 0 2px 5px rgba(0,0,0,0.2) !important; pointer-events: none !important; min-width: 120px !important; text-align: center !important; opacity: 0.9; } #tampermonkey-controls-container { margin-bottom: 10px; padding: 10px; background-color: #f8f9fa; border-bottom: 1px solid #dee2e6; display: flex; align-items: center; flex-wrap: wrap; gap: 5px; position: sticky !important; top: 0 !important; z-index: 1000 !important; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); } #tampermonkey-controls-container button { padding: 5px 10px; border: 1px solid #ced4da; border-radius: 4px; background-color: #fff; cursor: pointer; } #tampermonkey-controls-container button:hover { background-color: #e2e6ea; } #tampermonkey-download-button-top { background-color: #28a745 !important; color: white !important; border: none !important; } #tampermonkey-between-button { background-color: #17a2b8 !important; color: white !important; border: none !important; } /* 하단 버튼 컨테이너 스타일 */ #tampermonkey-bottom-container { position: fixed !important; bottom: 20px !important; left: 50% !important; transform: translateX(-50%) !important; z-index: 9998 !important; display: flex !important; gap: 8px !important; /* 간격 조정 */ background-color: rgba(255, 255, 255, 0.9) !important; padding: 8px !important; border-radius: 8px !important; box-shadow: 0 2px 10px rgba(0,0,0,0.2) !important; border: 1px solid #ddd !important; } /* 하단 버튼 공통 스타일 (크기 축소) */ .tm-bottom-btn { border: none !important; color: white !important; padding: 8px 16px !important; /* 크기 절반 정도로 축소 */ text-align: center !important; font-size: 14px !important; /* 폰트 사이즈 축소 */ cursor: pointer !important; border-radius: 6px !important; font-weight: bold !important; line-height: 1.2 !important; } #tampermonkey-download-button-bottom { background-color: #28a745 !important; } #tampermonkey-between-button-bottom { background-color: #17a2b8 !important; } #tampermonkey-uncheck-button-bottom { background-color: #6c757d !important; } /* 회색 계열 */ .tm-bottom-btn:disabled { background-color: #cccccc !important; cursor: not-allowed !important; } /* 챕터 목록 레이아웃 */ ul li { display: flex !important; align-items: center !important; line-height: normal !important; height: auto !important; } ul li a { white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } ul li input[type="checkbox"] { flex-shrink: 0 !important; margin-top: 1px !important; } `; document.head.appendChild(style); } function updateCheckedCount() { if (!countLabel) return; const checkedCount = $(episodeListSelector).parent().find('input[type="checkbox"]:checked').length; countLabel.text(`선택된 항목 = ${checkedCount}개`); } function addCheckboxes() { let episodeIndex = 1; $(episodeListSelector).each(function() { const link = $(this); const listItem = link.parent(); if (link.attr('id') === 'bookcase' || listItem.find('input[type="checkbox"]').length > 0) return; const checkbox = $('').css('margin-right', '5px'); checkbox.on('change', updateCheckedCount); link.data({ index: episodeIndex++, title: link.text().trim(), url: this.href }); listItem.prepend(checkbox); }); updateCheckedCount(); } function createListPageUI() { if ($('#tampermonkey-controls-container').length > 0) return; addGlobalStyle(); countLabel = $('').attr('id', 'tampermonkey-count-label'); $('body').append(countLabel); // 상단 버튼들 const checkAllButton = $('모두 선택').addClass('tampermonkey-ui-element'); const uncheckAllButton = $('모두 해제').addClass('tampermonkey-ui-element'); const betweenChkButtonTop = $('선택 사이').attr('id', 'tampermonkey-between-button').addClass('tampermonkey-ui-element'); const downloadButtonTop = $('선택 다운로드').attr('id', 'tampermonkey-download-button-top').addClass('tampermonkey-ui-element'); const rangeInput = $('').addClass('tampermonkey-ui-element'); const checkTBButton = $('범위 선택').addClass('tampermonkey-ui-element'); const uncheckTBButton = $('범위 해제').addClass('tampermonkey-ui-element'); // 공통 핸들러 function handleUncheckAll() { $(episodeListSelector).parent().find('input[type="checkbox"]').prop('checked', false); isCheckAll = false; checkAllButton.text('모두 선택'); updateCheckedCount(); } checkAllButton.on('click', function() { isCheckAll = !isCheckAll; $(episodeListSelector).parent().find('input[type="checkbox"]').prop('checked', isCheckAll); $(this).text(isCheckAll ? '모두 해제' : '모두 선택'); updateCheckedCount(); }); uncheckAllButton.on('click', handleUncheckAll); downloadButtonTop.on('click', startBackgroundDownload); function handleRangeAction(shouldCheck, start, end) { if (start === undefined || end === undefined) { const rangeString = rangeInput.val(); const range = rangeString.split('-').map(s => parseInt(s.trim(), 10)); if (range.length < 1 || isNaN(range[0])) return; start = range.length > 1 && !isNaN(range[1]) ? Math.min(...range) : range[0]; end = range.length > 1 && !isNaN(range[1]) ? Math.max(...range) : range[0]; } $(episodeListSelector).filter((i, el) => { const index = $(el).data('index'); return index >= start && index <= end; }).parent().find('input[type="checkbox"]').prop('checked', shouldCheck); updateCheckedCount(); } checkTBButton.on('click', () => handleRangeAction(true)); uncheckTBButton.on('click', () => handleRangeAction(false)); function handleBetweenCheck() { const checkedIndexes = $(episodeListSelector).parent().find('input:checked').siblings('a').map((i, el) => $(el).data('index')).get(); if (checkedIndexes.length < 2) { alert("범위를 지정하려면 최소 두 개의 체크박스를 선택해주세요."); return; } handleRangeAction(true, Math.min(...checkedIndexes), Math.max(...checkedIndexes)); } betweenChkButtonTop.on('click', handleBetweenCheck); const controlsContainer = $('
').attr('id', 'tampermonkey-controls-container'); controlsContainer.append(checkAllButton, uncheckAllButton, betweenChkButtonTop, downloadButtonTop, rangeInput, checkTBButton, uncheckTBButton); controlsContainer.insertBefore($(episodeListSelector).first().closest('ul')); // ======================================================================= // 하단 고정 버튼 영역 // ======================================================================= const bottomContainer = $('
').attr('id', 'tampermonkey-bottom-container'); // 1. 선택 사이 const betweenButtonBottom = $('선택 사이') .attr('id', 'tampermonkey-between-button-bottom') .addClass('tm-bottom-btn'); betweenButtonBottom.on('click', handleBetweenCheck); // 2. 선택 다운로드 const downloadButtonBottom = $('선택 다운로드') .attr('id', 'tampermonkey-download-button-bottom') .addClass('tm-bottom-btn'); downloadButtonBottom.on('click', startBackgroundDownload); // 3. 모두 해제 (추가됨) const uncheckButtonBottom = $('모두 해제') .attr('id', 'tampermonkey-uncheck-button-bottom') .addClass('tm-bottom-btn'); uncheckButtonBottom.on('click', handleUncheckAll); // 순서: [선택 사이] [선택 다운로드] [모두 해제] bottomContainer.append(betweenButtonBottom, downloadButtonBottom, uncheckButtonBottom); $('body').append(bottomContainer); } /** * 원시 HTML 응답에서 소설 본문 텍스트와 제목을 파싱하여 객체로 반환합니다. */ function parseRawHtml(rawHtml) { const $tempDiv = $('
').html(rawHtml); const $txtNav = $tempDiv.find('.txtnav'); let chapterTitle = "[제목 추출 실패]"; let finalContent = "[오류: 본문 영역을 찾을 수 없음]"; if ($txtNav.length === 0) { log("Error: '.txtnav' area not found in fetched HTML."); return { title: chapterTitle, content: finalContent }; } const $contentContainer = $txtNav.clone(); const $titleElement = $contentContainer.find('h1.hide720'); if ($titleElement.length > 0) { chapterTitle = $titleElement.text().trim(); } $contentContainer.find('h1.hide720, .txtinfo, #txtright, .bottom-ad, script').remove(); let chapterContent = $contentContainer.html(); chapterContent = chapterContent.replace(//ig, '\n'); chapterContent = chapterContent.replace(/ /g, ' '); chapterContent = chapterContent.replace(/[\u2003\u3000]/g, ' '); chapterContent = chapterContent.replace(/<[^>]*>/g, ''); chapterContent = chapterContent.split('\n').map(line => line.trim()).join('\n'); chapterContent = chapterContent.replace(/(\n\s*){3,}/g, '\n\n'); finalContent = chapterContent.trim(); return { title: chapterTitle, content: finalContent }; } function startBackgroundDownload() { const chaptersQueue = $(episodeListSelector).parent().find('input:checked').siblings('a').map((i, el) => { return { ...$(el).data(), retryCount: 0 }; }).get(); if (chaptersQueue.length === 0) return alert("다운로드할 에피소드를 선택해주세요."); let processedCount = 0; const totalCount = chaptersQueue.length; const downloadButtons = $('#tampermonkey-download-button-top, #tampermonkey-download-button-bottom'); const otherBottomButtons = $('.tm-bottom-btn').not(downloadButtons); // 다른 하단 버튼들 downloadButtons.prop('disabled', true); otherBottomButtons.prop('disabled', true); function processNextChapter() { if (chaptersQueue.length === 0) { log("All chapters processed."); downloadButtons.prop('disabled', false).text('다운로드 완료!'); otherBottomButtons.prop('disabled', false); setTimeout(() => downloadButtons.text('선택 다운로드'), 5000); return; } const chapter = chaptersQueue.shift(); const currentProgress = totalCount - chaptersQueue.length; downloadButtons.text(`다운로드 중... (${currentProgress}/${totalCount}${chapter.retryCount > 0 ? ` (재시도 ${chapter.retryCount}/${MAX_RETRIES})` : ''})`); const baseUrl = chapter.url.split('/').slice(0, 3).join('/'); GM_xmlhttpRequest({ method: "GET", url: chapter.url, overrideMimeType: "text/html; charset=gbk", headers: { "Referer": baseUrl + '/', "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" }, onload: function(response) { let delay = BASE_DELAY_MS; let needsRetry = false; let finalFileContent = ''; let errorContent = ''; const filename = `${chapter.index}.${chapter.title.replace(/[<>:"/\\|?*]/g, '_')}.txt`; if (response.status === 200) { const parsedData = parseRawHtml(response.responseText); let rawContent = `${parsedData.title}\n\n${parsedData.content}`; const cleanedContent = rawContent.replace(new RegExp(textToRemove.replace(/⊥/g, '⊥?'), 'g'), "").replace(/[\uFEFF]/g, '').trim(); if (parsedData.content.length < (MIN_FILE_SIZE_KB * 500)) { errorContent = `[본문 내용이 짧음, 재시도]`; needsRetry = true; finalFileContent = cleanedContent; } else { processedCount++; log(`[${processedCount}/${totalCount}] Fetched ${chapter.title} successfully.`); finalFileContent = cleanedContent; GM_download({ url: 'data:text/plain;charset=utf-8,' + encodeURIComponent(finalFileContent), name: filename, onload: () => log(`Successfully triggered download for: ${filename}`), onerror: (err) => log(`Error downloading ${filename}: ${JSON.stringify(err)}`) }); } } else if (response.status === 429 || response.status === 403) { errorContent = `[다운로드 실패: HTTP 상태 코드 ${response.status}]`; needsRetry = true; finalFileContent = `${chapter.title}\n\n${errorContent}`; } else { processedCount++; log(`Failed to fetch ${chapter.title}, Status: ${response.status}`); errorContent = `[다운로드 실패: HTTP 상태 코드 ${response.status}]`; finalFileContent = `${chapter.title}\n\n${errorContent}`; GM_download({ url: 'data:text/plain;charset=utf-8,' + encodeURIComponent(finalFileContent), name: filename }); } if (needsRetry) { if (chapter.retryCount < MAX_RETRIES) { chapter.retryCount++; delay = BASE_DELAY_MS * Math.pow(2, chapter.retryCount); if (response.status === 429) { const retryAfterHeaderMatch = response.responseHeaders ? response.responseHeaders.match(/Retry-After: (\d+)/i) : null; if (retryAfterHeaderMatch) { const serverDelay = parseInt(retryAfterHeaderMatch[1], 10) * 1000; delay = Math.max(delay, serverDelay); } } log(`[RETRY] ${errorContent} for ${chapter.title}. Retrying in ${delay / 1000}s. Attempt ${chapter.retryCount}/${MAX_RETRIES}`); chaptersQueue.unshift(chapter); } else { processedCount++; log(`[FATAL] Max retries reached for ${chapter.title}. Skipping.`); const finalErrorContent = `${chapter.title}\n\n[다운로드 실패: ${errorContent}, 최대 재시도 횟수 ${MAX_RETRIES}회 초과]`; GM_download({ url: 'data:text/plain;charset=utf-8,' + encodeURIComponent(finalErrorContent), name: filename }); } } setTimeout(processNextChapter, delay); }, onerror: function(error) { processedCount++; log(`Error fetching ${chapter.title} (Network Error): ${JSON.stringify(error)}`); const errorFileName = `${chapter.index}.${chapter.title.replace(/[<>:"/\\|?*]/g, '_')}.txt`; const errorMsg = `${chapter.title}\n\n[다운로드 실패: 네트워크 오류]`; GM_download({ url: 'data:text/plain;charset=utf-8,' + encodeURIComponent(errorMsg), name: errorFileName }); setTimeout(processNextChapter, BASE_DELAY_MS); } }); } log(`Starting background download for ${totalCount} individual chapters with ${BASE_DELAY_MS/1000}s base delay.`); setTimeout(() => { processNextChapter(); }, INITIAL_PAGE_DELAY_MS); } $(document).ready(function() { if (window.location.href.includes('/book/') && !window.location.href.includes('/txt/')) { addCheckboxes(); createListPageUI(); } }); })();
