전에 만든건데 방금해보니 잘되네요.
https://kio.ac/c/dlR4CKA6vYLTT1ZeKuV5Ob
스크립트 다운로드 링크 추가.
// ==UserScript==
// @name 69shuba Novel Downloader (Background Fetch)
// @namespace http://tampermonkey.net/
// @version 4.8 // 버전 업데이트
// @description Downloads novel text from 69shuba.com by fetching chapters in the background. Fixes character encoding (GBK) issue, extracts full content, handles 429 rate-limiting, and 403 forbidden errors.
// @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; // 요청 사이의 기본 지연 시간 (1.5초)
const INITIAL_PAGE_DELAY_MS = 500; // 페이지 진입 시 초기 지연 시간 (추가 요구사항)
const MAX_RETRIES = 5; // 최대 재시도 횟수
const MIN_FILE_SIZE_KB = 1; // 본문 추출 성공 최소 크기 (1KB)
function log(message) { console.log(`[Tampermonkey: 69shuba Downloader] ${message}`); }
// ... (addGlobalStyle, updateCheckedCount, addCheckboxes, createListPageUI 함수는 변경 없음) ...
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);
controlsContainer.insertBefore($(episodeListSelector).first().closest('ul'));
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();
// 불필요한 요소들을 제거
$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, ' ');
// 남아있는 HTML 태그를 모두 제거
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 chapterContent;
}
function startBackgroundDownload() {
// 기존 chapterToDownload 대신, 재시도 횟수를 저장할 구조를 사용합니다.
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})` : ''})`);
// Referer 헤더 설정을 위해 base URL 추출
const baseUrl = chapter.url.split('/').slice(0, 3).join('/');
GM_xmlhttpRequest({
method: "GET",
url: chapter.url,
overrideMimeType: "text/html; charset=gbk",
headers: {
// 403 에러 회피를 위해 Referer와 User-Agent 설정
"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 chapterContent = '';
let errorContent = '';
const filename = `${chapter.index}.${chapter.title.replace(/[<>:"/\\|?*]/g, '_')}.txt`;
if (response.status === 200) {
// 1. 200 성공 처리
chapterContent = parseRawHtml(response.responseText);
const cleanedContent = chapterContent.replace(new RegExp(textToRemove.replace(/⊥/g, '⊥?'), 'g'), "").replace(/[\uFEFF]/g, '').trim();
// 2. 파일 크기 검사 (1KB 미만이면 재시도)
// 주의: encodeURIComponent로 인코딩된 바이트 크기는 실제 파일 크기와 다를 수 있으므로, 대략적인 길이로 판단합니다.
// 1KB = 1024 bytes. UTF-8 한글 1글자는 3바이트. 1024 / 3 = 약 340글자.
if (cleanedContent.length < (MIN_FILE_SIZE_KB * 500)) {
errorContent = `[본문 내용이 짧음, 재시도]`;
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) {
// 3. 429 또는 403 에러 처리
errorContent = `[다운로드 실패: HTTP 상태 코드 ${response.status}]`;
needsRetry = true;
} else {
// 4. 기타 HTTP 에러 처리 (404, 500 등)
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);
// 서버의 Retry-After 헤더 확인 (429 에러 시)
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 = `[다운로드 실패: ${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.`);
// 500ms 초기 딜레이 후 다운로드 시작
setTimeout(() => {
processNextChapter();
}, INITIAL_PAGE_DELAY_MS);
}
$(document).ready(function() {
if (window.location.href.includes('/book/') && !window.location.href.includes('/txt/')) {
addCheckboxes();
createListPageUI();
}
});
})();
12
공지
[필독] 임시 채널 규칙 & 신문고
Alria
25.09.08
71898
22
[필독] 임시 채널 규칙 & 신문고
공지
Alria
25.09.08 71898 22
공지
번역 정보글 및 팁 모음
Alria
25.11.03
42363
11
번역 정보글 및 팁 모음
공지
Alria
25.11.03 42363 11
공지
혹시 모를 상황을 대비한 대피소 구축 완료
Alria
05.05
8529
70
혹시 모를 상황을 대비한 대피소 구축 완료
공지
Alria
05.05 8529 70
공지
번역 요청탭의 요청 방식 수정 안내
Alria
25.12.29
39131
8
번역 요청탭의 요청 방식 수정 안내
공지
Alria
25.12.29 39131 8
공지
부관리자 모집 공지
Alria
05.05
5937
19
부관리자 모집 공지
공지
Alria
05.05 5937 19
잡담
하시라마는 근데 어떻게 죽은걸까
quer99
04.26
980
2
하시라마는 근데 어떻게 죽은걸까
잡담
quer99
04.26 980 2
후기
[패러디]우치하 여자친구가 너무 츤데레다 후기
74839
04.26
1625
6
[패러디]우치하 여자친구가 너무 츤데레다 후기
후기
74839
04.26 1625 6
잡담
중국 주술 팬픽에서는 공시우 어케 나옴??
야돈
04.26
1722
1
중국 주술 팬픽에서는 공시우 어케 나옴??
잡담
야돈
04.26 1722 1
[선협]
금·토 영근으로 시작하는 장생지도 1-100
야돈
04.26
2767
25
금·토 영근으로 시작하는 장생지도 1-100
[선협]
야돈
04.26 2767 25
[선협]
천재수선, 천령근도 별거 아니군 1-101
야돈
04.26
3253
22
천재수선, 천령근도 별거 아니군 1-101
[선협]
야돈
04.26 3253 22
[선협]
악녀수선,온 가족을 제물로 바치다 1-200
야돈
04.26
3075
18
악녀수선,온 가족을 제물로 바치다 1-200
[선협]
야돈
04.26 3075 18
[패러디]
[영쉘든] 스티븐 쿠퍼로 환생하다 1-74
야돈
04.26
1883
11
[영쉘든] 스티븐 쿠퍼로 환생하다 1-74
[패러디]
야돈
04.26 1883 11
[패러디]
[영쉘든] 조지로 다시 환생하다 1-123
야돈
04.26
1664
16
[영쉘든] 조지로 다시 환생하다 1-123
[패러디]
야돈
04.26 1664 16
[패러디]
[헌터X헌터] 무한 성장 1-518
야돈
04.26
2680
32
[헌터X헌터] 무한 성장 1-518
[패러디]
야돈
04.26 2680 32
[패러디]
[학사신공] 매응수선전 1-82
야돈
04.26
788
13
[학사신공] 매응수선전 1-82
[패러디]
야돈
04.26 788 13
[패러디]
[학사신공] 대진뇌수 1-197
야돈
04.26
2337
19
[학사신공] 대진뇌수 1-197
[패러디]
야돈
04.26 2337 19
[패러디]
[학사신공] 나는 검조가 되었다 1-172
야돈
04.26
1227
22
[학사신공] 나는 검조가 되었다 1-172
[패러디]
야돈
04.26 1227 22
[패러디]
[코난] 코난 세계의 킬러퀸 1-100
야돈
04.26
2883
17
[코난] 코난 세계의 킬러퀸 1-100
[패러디]
야돈
04.26 2883 17
[패러디]
[주술회전] 천여주박이 약하다고?난 분노로 팔문둔갑을 연다! 1-196
야돈
04.26
2961
25
[주술회전] 천여주박이 약하다고?난 분노로 팔문둔갑을 연다! 1-196
[패러디]
야돈
04.26 2961 25
[패러디]
[원피스] 나 야마토, 효자 노릇은 안한다 1-154
야돈
04.26
1279
17
[원피스] 나 야마토, 효자 노릇은 안한다 1-154
[패러디]
야돈
04.26 1279 17
[패러디]
[워해머] 처음부터 시작하는 0호 프라이마크의 생활 1-53
야돈
04.26
2653
16
[워해머] 처음부터 시작하는 0호 프라이마크의 생활 1-53
[패러디]
야돈
04.26 2653 16
[패러디]
[신조협려] 칠 년이 지나서야 비로소 무협임을 알았다 1-198
야돈
04.26
2901
28
[신조협려] 칠 년이 지나서야 비로소 무협임을 알았다 1-198
[패러디]
야돈
04.26 2901 28
[패러디]
[나루토] 히나타:나뭇잎 마을은 이제 내 손아귀에 있다 1-30
야돈
04.26
1749
17
[나루토] 히나타:나뭇잎 마을은 이제 내 손아귀에 있다 1-30
[패러디]
야돈
04.26 1749 17
[패러디]
[나루토] 나뭇잎 일지 1-478
야돈
04.26
3020
35
[나루토] 나뭇잎 일지 1-478
[패러디]
야돈
04.26 3020 35
[패러디]
[나루토] 나뭇잎 마을의 대검호 1-27
야돈
04.26
1330
20
[나루토] 나뭇잎 마을의 대검호 1-27
[패러디]
야돈
04.26 1330 20
잡담
원피스 패러디 해적의 재앙 재밌네요
개꿀페페
04.26
857
3
원피스 패러디 해적의 재앙 재밌네요
잡담
개꿀페페
04.26 857 3
잡담
닭장 아줌마가 인기 캐릭터였다니
金日成綜合大學
04.26
1412
5
닭장 아줌마가 인기 캐릭터였다니
잡담
金日成綜合大學
04.26 1412 5
후기
나루토 천재 캐릭터 설정부터 시작
aabbp
04.26
1372
4
나루토 천재 캐릭터 설정부터 시작
후기
aabbp
04.26 1372 4
후기
나루토 수많은 미래 상속 후기
aabbp
04.26
999
4
나루토 수많은 미래 상속 후기
후기
aabbp
04.26 999 4
잡담
붕3플 1~930 250/370 진행중
시라카미 후부키
04.26
1074
6
붕3플 1~930 250/370 진행중
잡담
시라카미 후부키
04.26 1074 6
[패러디]
붕괴 스타레일, 이세계에서 돌아왔다 1-480
tammy
04.26
2399
41
붕괴 스타레일, 이세계에서 돌아왔다 1-480
[패러디]
tammy
04.26 2399 41
[패러디]
붕괴 스타레일, 시작부터 에이언즈 키레네를 줍다 1-248
tammy
04.26
2447
34
붕괴 스타레일, 시작부터 에이언즈 키레네를 줍다 1-248
[패러디]
tammy
04.26 2447 34
후기
나루토 아카츠키 추상적 전략가 후기
aabbp
04.26
1055
4
나루토 아카츠키 추상적 전략가 후기
후기
aabbp
04.26 1055 4
[패러디]
나루토 드림마스터 1ㅡ375
하이바라
04.26
1974
97
나루토 드림마스터 1ㅡ375
[패러디]
하이바라
04.26 1974 97
[복구]
폐령근부터 시작하는 마도수행, 천생선종, 합환종의 노정으로 시작 죽을수록 강해진다, 프로의 주인 복구
tassdar
04.26
2537
32
폐령근부터 시작하는 마도수행, 천생선종, 합환종의 노정으로 시작 죽을수록 강해진다, 프로의 주인 복구
[복구]
tassdar
04.26 2537 32
s/ainovel
• 5,169명 구독중AI로 번역된 해외 소설 및 해외 패러디 소설 서브입니다.
2025-09-08 09:40:53 생성
삭제된 댓글입니다.