전에 만든건데 방금해보니 잘되네요.
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
8522
70
혹시 모를 상황을 대비한 대피소 구축 완료
공지
Alria
05.05 8522 70
공지
번역 요청탭의 요청 방식 수정 안내
Alria
25.12.29
39122
8
번역 요청탭의 요청 방식 수정 안내
공지
Alria
25.12.29 39122 8
공지
부관리자 모집 공지
Alria
05.05
5871
19
부관리자 모집 공지
공지
Alria
05.05 5871 19
잡담
진짜 번역 심하네
야돈
04.02
11610
4
진짜 번역 심하네
잡담
야돈
04.02 11610 4
[일반]
쌀 2개~~
quer99
04.02
19103
54
쌀 2개~~
[일반]
quer99
04.02 19103 54
잡담
여담으로 이무(원피스) 디자인 공개됐네요
rt7777
04.02
11076
5
여담으로 이무(원피스) 디자인 공개됐네요
잡담
rt7777
04.02 11076 5
일반
주술가디안 지금 진입합니다
rt7777
04.02
9308
2
주술가디안 지금 진입합니다
일반
rt7777
04.02 9308 2
복구 요청
페어리 테일 길드 마스터는 되기 싫어 복구 부탁 드립니다
yasking
04.02
13136
3
페어리 테일 길드 마스터는 되기 싫어 복구 부탁 드립니다
복구 요청
yasking
04.02 13136 3
[패러디]
탄서성공지 시광 모의 1-627 완
nannakhs11
04.02
17586
41
탄서성공지 시광 모의 1-627 완
[패러디]
nannakhs11
04.02 17586 41
[패러디]
제천 탄서성공에서 신이 되기 시작하다 諸天 從吞噬星空成神開始 1-297
nannakhs11
04.02
16524
19
제천 탄서성공에서 신이 되기 시작하다 諸天 從吞噬星空成神開始 1-297
[패러디]
nannakhs11
04.02 16524 19
잡담
동물마이고 대충 주인공 시야가 이런거지?
하이바라
04.02
10934
2
동물마이고 대충 주인공 시야가 이런거지?
잡담
하이바라
04.02 10934 2
[일반]
[대체역사] 중화민국의 투기꾼 미생략본 1-1544 완결
naxlamas
04.02
20376
43
[대체역사] 중화민국의 투기꾼 미생략본 1-1544 완결
[일반]
naxlamas
04.02 20376 43
복구 요청
선협세계에서 기방 열기 복구 요청드립니다
dron1925
04.01
14319
2
선협세계에서 기방 열기 복구 요청드립니다
복구 요청
dron1925
04.01 14319 2
후기
강스포)성진지주->근래 가장 재밌던 소설
collectersss
04.01
15928
6
강스포)성진지주->근래 가장 재밌던 소설
후기
collectersss
04.01 15928 6
후기
페어리테일 길드마스터 짧 후기
goblin0721
04.01
15120
5
페어리테일 길드마스터 짧 후기
후기
goblin0721
04.01 15120 5
잡담
중국 애들은 유튜브에 별걸 다 올리네요
siyss1900
04.01
11731
1
중국 애들은 유튜브에 별걸 다 올리네요
잡담
siyss1900
04.01 11731 1
복구 요청
드래곤의 힘으로 온 세상을 무너뜨린다 복구 요청
tttuuiy
04.01
14229
3
드래곤의 힘으로 온 세상을 무너뜨린다 복구 요청
복구 요청
tttuuiy
04.01 14229 3
잡담
스톰윈드 노동실록 같은 소설 있음?
ftjjj
04.01
11103
2
스톰윈드 노동실록 같은 소설 있음?
잡담
ftjjj
04.01 11103 2
후기
산하직 재밌네
qqq2we123123
04.01
14305
4
산하직 재밌네
후기
qqq2we123123
04.01 14305 4
[일반]
수신외전 170~173 부근 누락 텍스트 / 누락? 관련 보실분들 참고사항
kiwitre
04.01
19389
12
수신외전 170~173 부근 누락 텍스트 / 누락? 관련 보실분들 참고사항
[일반]
kiwitre
04.01 19389 12
복구 요청
제3제국 복구 요청드립니다.
suppy121
04.01
14065
1
제3제국 복구 요청드립니다.
복구 요청
suppy121
04.01 14065 1
복구 요청
프로의 주인 복구 요청드립니다.
kkkggg123
04.01
13432
5
프로의 주인 복구 요청드립니다.
복구 요청
kkkggg123
04.01 13432 5
[일반]
스피드 쌀먹 공유 모음 20260401
tassdar
04.01
23125
99
스피드 쌀먹 공유 모음 20260401
[일반]
tassdar
04.01 23125 99
복구 요청
산하직 복구 요청드립니다
wj1886
04.01
13373
4
산하직 복구 요청드립니다
복구 요청
wj1886
04.01 13373 4
잡담
청운종에서 시작 <- 지루하고 현학적임
blue3227
04.01
11175
4
청운종에서 시작 <- 지루하고 현학적임
잡담
blue3227
04.01 11175 4
[일반]
쌀!!!!!
afjtj4bgjejwnf
04.01
18417
98
쌀!!!!!
[일반]
afjtj4bgjejwnf
04.01 18417 98
번역 요청
산하직(山河稷) 번역 부탁드립니다
kokonene818
04.01
12663
4
산하직(山河稷) 번역 부탁드립니다
번역 요청
kokonene818
04.01 12663 4
[복구]
야무강 복구요청
necrosis
04.01
11503
-18
야무강 복구요청
[복구]
necrosis
04.01 11503 -18
잡담
히로인이 전부 여장남자? 이건 ...
金日成綜合大學
04.01
9347
4
히로인이 전부 여장남자? 이건 ...
잡담
金日成綜合大學
04.01 9347 4
정보
💥 26년 1분기, [AINOVEL] 순위 발표 (일부 줄거리 포함)
코네
04.01
24308
59
💥 26년 1분기, [AINOVEL] 순위 발표 (일부 줄거리 포함)
정보
코네
04.01 24308 59
후기
26년 3월 치디엔 랭킹 소설 후기(읽은 것만)
펭귄
04.01
15819
18
26년 3월 치디엔 랭킹 소설 후기(읽은 것만)
후기
펭귄
04.01 15819 18
후기
스톰윈드 노동실록) 작가가 급완결 때리는 느낌?
aowlr
04.01
12558
1
스톰윈드 노동실록) 작가가 급완결 때리는 느낌?
후기
aowlr
04.01 12558 1
잡담
랫킨 림월드 진짜 엄청 연재하네
rt7777
04.01
8672
4
랫킨 림월드 진짜 엄청 연재하네
잡담
rt7777
04.01 8672 4
s/ainovel
• 5,169명 구독중AI로 번역된 해외 소설 및 해외 패러디 소설 서브입니다.
2025-09-08 09:40:53 생성
삭제된 댓글입니다.