kone
ainovel

ainovel

101kanshu 소설 받기 스크립트(수정)

11/10/2025, 02:53:08
정보
6062 views · 19 likes

// ==UserScript== // @name 101kanshu Novel Downloader (Background Fetch) // @namespace http://tampermonkey.net/ // @version 5.3 // 비동기 로딩 완료 감지 로직 추가 // @description Downloads novel text from 101kanshu.com by fetching chapters in the background. Handles rate-limiting (429) and forbidden (403) errors with retries. Adds automatic 'LoadMore' functionality with robust loading detection. // @author You // @match https://101kanshu.com/book/* // @grant GM_download // @grant GM_xmlhttpRequest // @require https://code.jquery.com/jquery-3.6.0.min.js // ==/UserScript== (function() { 'use strict'; // ... (상수 및 보조 함수는 변경 없음) ... const episodeListSelector = 'div#allchapter ul li a[href*="/txt/"]'; const textToRemove = ''; let isCheckAll = false; let countLabel; const BASE_DELAY_MS = 1500; const INITIAL_PAGE_DELAY_MS = 500; // 비동기 로딩 감지 관련 상수 추가/수정 const POLLING_INTERVAL_MS = 100; // 0.1초마다 상태 확인 const MAX_LOAD_WAIT_MS = 5000; // 최대 5초 동안 로딩 대기 const MAX_RETRIES = 5; const MIN_FILE_SIZE_KB = 1; function log(message) { console.log(`[Tampermonkey: 101kanshu Downloader] ${message}`); } // ... (addGlobalStyle, updateCheckedCount, addCheckboxes, createListPageUI, parseRawHtml 함수는 변경 없음) ... 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: 10px !important; left: 50% !important; transform: translateX(-50%) !important; z-index: 9999 !important; background-color: #007bff !important; color: white !important; padding: 8px 12px !important; border-radius: 4px !important; font-family: sans-serif !important; font-size: 14px !important; box-shadow: 0 2px 5px rgba(0,0,0,0.2) !important; pointer-events: none !important; } #tampermonkey-controls-container { margin-bottom: 10px; padding: 10px; background-color: #f0f0f0; border-radius: 5px; display: flex; align-items: center; flex-wrap: wrap; gap: 5px; } #tampermonkey-download-button-bottom { position: fixed !important; bottom: 20px !important; left: 50% !important; transform: translateX(-50%) !important; z-index: 9998 !important; background-color: #28a745 !important; border: none !important; color: white !important; padding: 15px 32px !important; text-align: center !important; font-size: 16px !important; cursor: pointer !important; border-radius: 8px !important; } #tampermonkey-download-button-bottom:disabled, #tampermonkey-download-button-top:disabled { background-color: #cccccc !important; cursor: not-allowed !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 downloadButton = $('선택 다운로드').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'); const betweenChkButton = $('선택 사이').addClass('tampermonkey-ui-element'); checkAllButton.on('click', function() { isCheckAll = !isCheckAll; $(episodeListSelector).parent().find('input[type="checkbox"]').prop('checked', isCheckAll); $(this).text(isCheckAll ? '모두 해제' : '모두 선택'); updateCheckedCount(); }); uncheckAllButton.on('click', function() { $(episodeListSelector).parent().find('input[type="checkbox"]').prop('checked', false); isCheckAll = false; checkAllButton.text('모두 선택'); updateCheckedCount(); }); downloadButton.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)); betweenChkButton.on('click', function() { 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)); }); const controlsContainer = $('


').attr('id', 'tampermonkey-controls-container'); controlsContainer.append(checkAllButton, uncheckAllButton, downloadButton, rangeInput, checkTBButton, uncheckTBButton, betweenChkButton); const insertionPoint = $('.mybox h3.mytitle.shuye'); if (insertionPoint.length) { controlsContainer.insertAfter(insertionPoint); } else { $('body').prepend(controlsContainer); } const downloadButtonBottom = $('선택 다운로드').attr('id', 'tampermonkey-download-button-bottom'); downloadButtonBottom.on('click', startBackgroundDownload); $('body').append(downloadButtonBottom); } /** * 원시 HTML 응답에서 소설 본문 텍스트를 파싱하여 추출합니다. */ function parseRawHtml(rawHtml) { const $tempDiv = $('').html(rawHtml); const $txtNav = $tempDiv.find('.txtnav'); if ($txtNav.length === 0) { log("Error: '.txtnav' area not found in fetched HTML."); return "[오류: 본문 영역을 찾을 수 없음]"; } const $contentContainer = $txtNav.clone(); const chapterTitle = $contentContainer.find('h1').text().trim(); const $txtContent = $contentContainer.find('#txtcontent'); if ($txtContent.length === 0) { $contentContainer.find('h1, .txtinfo, .txtcenter, .txtad, script, br').remove(); return `\n\n${chapterTitle}\n\n[오류: 본문 내용을 찾을 수 없거나 추출에 실패했습니다.]`; } $txtContent.find('.txtad, script').remove(); let chapterContent = $txtContent.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'); chapterContent = chapterContent.trim(); return `${chapterTitle}\n\n${chapterContent}`; } 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'); downloadButtons.prop('disabled', true); function processNextChapter() { if (chaptersQueue.length === 0) { log("All chapters processed."); downloadButtons.prop('disabled', false).text('다운로드 완료!'); 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})` : ''})`); GM_xmlhttpRequest({ method: "GET", url: chapter.url, headers: { "Referer": chapter.url, "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 chapterContent = ''; let errorContent = ''; const filename = `${chapter.index}.${chapter.title.replace(/[<>:"/\\|?*]/g, '_')}.txt`; if (response.status === 200) { chapterContent = parseRawHtml(response.responseText); const cleanedContent = chapterContent.replace(new RegExp(textToRemove.replace(/⊥/g, '⊥?'), 'g'), "").replace(/[\uFEFF]/g, '').trim(); if (cleanedContent.length < (MIN_FILE_SIZE_KB * 500)) { errorContent = `[본문 내용이 너무 짧음 (${cleanedContent.length}자), 재시도]`; needsRetry = true; } else { processedCount++; log(`[${processedCount}/${totalCount}] Fetched ${chapter.title} successfully.`); GM_download({ url: 'data:text/plain;charset=utf-8,' + encodeURIComponent(cleanedContent), 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; } else { processedCount++; log(`Failed to fetch ${chapter.title}, Status: ${response.status}`); errorContent = `[다운로드 실패: HTTP 상태 코드 ${response.status}]`; GM_download({ url: 'data:text/plain;charset=utf-8,' + encodeURIComponent(errorContent), name: filename }); } if (needsRetry) { if (chapter.retryCount < MAX_RETRIES) { chapter.retryCount++; delay = BASE_DELAY_MS * Math.pow(2, chapter.retryCount); if (response.status === 429 && response.responseHeaders) { const retryAfterHeaderMatch = response.responseHeaders.match(/Retry-After: (\d+)/i); 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 = `[다운로드 실패: ${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)}`); 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); } // ======================================================================= // 5. 초기화 및 LoadMore 감지 로직 (수정) // ======================================================================= function initializeUI() { // 이 함수에서 체크박스와 UI를 최종적으로 추가합니다. addCheckboxes(); createListPageUI(); log("Chapter list loaded and UI is ready."); } function waitForLoadMoreCompletion(maxAttempts, attempt = 0) { const $loadMoreButton = $('#loadmore'); if (attempt * POLLING_INTERVAL_MS >= MAX_LOAD_WAIT_MS) { log(`Max wait time (${MAX_LOAD_WAIT_MS}ms) reached. Initializing UI.`); initializeUI(); return; } // 1. LoadMore 버튼이 존재하고 숨겨져 있거나, 아예 사라졌는지 확인 // LoadMore 함수가 호출되면 AJAX가 완료된 후 버튼이 사라지거나 display:none이 됩니다. if ($loadMoreButton.length === 0 || $loadMoreButton.css('display') === 'none') { log(`LoadMore button disappeared/hidden after ${attempt * POLLING_INTERVAL_MS}ms. Initializing UI.`); initializeUI(); return; } // 2. 버튼이 아직 남아있고 visible 하다면 (로딩 중이거나 실패) 다시 시도 // (참고: 로딩 중에는 버튼 텍스트가 "正在加載..."로 바뀔 수 있지만, 사라지는 것을 기준으로 합니다.) setTimeout(() => { waitForLoadMoreCompletion(maxAttempts, attempt + 1); }, POLLING_INTERVAL_MS); } $(document).ready(function() { if (window.location.href.includes('/book/') && window.location.href.includes('index.html')) { const $loadMoreButton = $('#loadmore'); if ($loadMoreButton.length > 0 && $loadMoreButton.is(':visible')) { log("LoadMore button found. Triggering automatic click."); // LoadMore 함수를 호출하여 챕터 목록 로딩 시작 if (typeof window.LoadMore === 'function') { window.LoadMore(); } else { $loadMoreButton.trigger('click'); } // 로딩 완료를 기다리는 폴링 시작 waitForLoadMoreCompletion(); } else { // LoadMore 버튼이 없거나 이미 전체 목록이 로드된 경우 log("Chapter list assumed complete. Initializing UI directly."); initializeUI(); } } }); })();




정식 사이트 외에 사이버펑크 2071 최신이 가장 먼저 올라오는 것 같더라고요.

이 사이트는 123화까지 있고 69shuba는 117까지 밖에 없고...


(수정) 가끔 체크박스가 안떠서 수정했습니다.


19
6 comments
Sign in to comment
11/10/25
감사합니다, 수고하셨습니다!!
11/10/25
오 새로운 사이트 알아가네요
11/10/25
감사합니당
11/10/25
오 여기 업데이트 빠르네요. 감사합니다
11/10/25
여긴 69shuba 같다
11/15/25
방금 쓰는데 오늘 무슨 제한 패치를 했네요. 1분에 10화만 받을 수 있게 서버쪽에서 제한을 걸었나 봅니다. 딜레이를 5.5초로 해놓고 받아야 합니다.
Sign in to comment
[뱅드림]내가 바로 너희가 사별한 마음의 안식처다我就是你们已故的感情寄托 1-483 (완결+외전)
[패러디]
hyun6871
05/05 1524 22
명일방주 속 소울나이트 1-193
[패러디]
명일방주너무좋아광인
05/05 2600 15
내 마법에는 한계가 없다! 1-375
[일반]
yadongwang1
05/05 3394 57
뱅드림:나는 너희들이 이미 떠나보낸 감정적 안식처다 1-483 완결
[패러디]
DPTapt
05/05 1422 20
하멜른에 있는 일본 패러디 소설들도 번역 요청 가능할까요?
질문
katerell
05/05 837 0
[복구] 포켓몬, 그들은 모두 여성화되었다 1-224
[복구]
金日成綜合大學
05/05 2267 19
[패러디]다중 우리 대요괴들은 원래 그렇다 我们大妖怪就是这样的 345화
[패러디]
rlawjrwnd
05/05 2125 27
이거 번역 됬나요? [뱅드림]내가 바로 너희가 사별한 마음의 안식처다 (我就是你们已故的感情寄托)
잡담
hyun6871
05/05 1396 3
[패러디? 표절물o] 짱구 세계관에서의 만화 그리기
후기
rlawjrwnd
05/05 1086 4
"제 4차 영석 금융위기" (도궤이선 작가 추천작)
잡담
blackship
05/05 2178 7
뱅도리 팬픽 같은건 어떤 재미로 보나요
잡담
srkone
05/05 499 2
명일방주 소설들 혹시 복구 가능하신 분 계신가요?
복구 요청
saraisaorin
05/05 776 -1
신비의제왕 원본은 대우가 좀 안 좋나요?
잡담
andjeiu
05/05 2200 -1
결과적으로 볼드모트는 항상 한수아래아님?
잡담
하이바라
05/05 844 1
[패러디]로저가 우즈마키 나루토에게 돌아가는 배에서 보낸 메시지 입니다 후기
후기
74839
05/05 1113 2
최애 쪽 패러디 소설 아는거 있음 추천좀요
질문
wang95
05/05 813 0
여태까지 읽은거 총정리
후기
wkdwo
05/04 7279 51
[타입문] 시작부터 성녀 잔다르크를 구한다니, 여기가 정말 타입문인가! 1-4권
[패러디]
金日成綜合大學
05/04 2773 22
도쿄 신목 원문 찾아보니까 진작에 완결했었나보구만
잡담
金日成綜合大學
05/04 1565 1
스네이프가 비호감이라 패러디를 못보겠음
잡담
0생_scripts
05/04 1036 1
나는 도코에서 신목(십미)를 심는다 오랜만에 취향맞네
후기
mirorjsdud
05/04 1744 4
나뭇잎일지에서 좋았던 부분
후기
하이바라
05/04 990 10
해리포터 록허트 교수는
잡담
rt7777
05/04 1606 2
빌드업 긴 모자근친 야설 추천 '해 줘'
질문
skehahffkdydydy
05/04 1338 0
탄서성공 짜증나는게
일반
sankhara
05/04 1089 0
그러고보면 오로치마루 주인도 개사기임
잡담
reyell0101
05/04 971 0
나루토 원피스보면 해외인기는 왜 나루토인지 알거같음
일반
ildoryu
05/04 1517 0
나는 도쿄에 신목십미를 심는다 재밌네요
잡담
327373
05/04 1739 0
마법사 보석합성 이거 정규 마법사 되고나서 전개가
잡담
cjdthqn
05/04 912 1
원나블도 우리나라에서나 원나블이라..
일반
ildoryu
05/04 890 0