// ==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까지 밖에 없고...
(수정) 가끔 체크박스가 안떠서 수정했습니다.