전에 만든건데 방금해보니 잘되네요.
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
8454
70
혹시 모를 상황을 대비한 대피소 구축 완료
공지
Alria
05.05 8454 70
공지
번역 요청탭의 요청 방식 수정 안내
Alria
25.12.29
39094
8
번역 요청탭의 요청 방식 수정 안내
공지
Alria
25.12.29 39094 8
공지
부관리자 모집 공지
Alria
05.05
5829
19
부관리자 모집 공지
공지
Alria
05.05 5829 19
잡담
패러디중엔 원피스가 젤 좋음
74839
03.28
3007
3
패러디중엔 원피스가 젤 좋음
잡담
74839
03.28 3007 3
[패러디]
마블에서 인간의 황제가 되었다
almost
03.28
10973
40
마블에서 인간의 황제가 되었다
[패러디]
almost
03.28 10973 40
[패러디]
[캄피오네] 비교적 손해를 보는 마왕 1-80(완)
naxlamas
03.28
9772
30
[캄피오네] 비교적 손해를 보는 마왕 1-80(완)
[패러디]
naxlamas
03.28 9772 30
[일반]
스피드 쌀먹 공유 모음 20260328
tassdar
03.28
16478
127
스피드 쌀먹 공유 모음 20260328
[일반]
tassdar
03.28 16478 127
[패러디]
[코난] 난 베이커 거리야말로 천국이라고 생각해 1-458
야돈
03.28
9329
23
[코난] 난 베이커 거리야말로 천국이라고 생각해 1-458
[패러디]
야돈
03.28 9329 23
복구 요청
(복구요청)인도대성 人道大聖 1-2971 완결
sexking98
03.28
6525
1
(복구요청)인도대성 人道大聖 1-2971 완결
복구 요청
sexking98
03.28 6525 1
잡담
새 명일방주 팬픽이 없어....
명일방주너무좋아광인
03.28
2531
4
새 명일방주 팬픽이 없어....
잡담
명일방주너무좋아광인
03.28 2531 4
복구 요청
초신기계사 복구요청합니다.
sexking98
03.28
6262
1
초신기계사 복구요청합니다.
복구 요청
sexking98
03.28 6262 1
후기
(스포) 괴이는 죽이기 힘들다고 다 읽은 후기
kgeporqwhi20
03.28
5998
6
(스포) 괴이는 죽이기 힘들다고 다 읽은 후기
후기
kgeporqwhi20
03.28 5998 6
후기
대건무성 ㅡ 고무인데 무공익히는 재미가 없다.
vango
03.28
5084
4
대건무성 ㅡ 고무인데 무공익히는 재미가 없다.
후기
vango
03.28 5084 4
복구 요청
[코난-나는 사이코패스가 아니다] 복구요청 드립니다
histórĭa
03.28
7101
2
[코난-나는 사이코패스가 아니다] 복구요청 드립니다
복구 요청
histórĭa
03.28 7101 2
[일반]
재밌어 보이는 쌀들~~
afjtj4bgjejwnf
03.28
12674
84
재밌어 보이는 쌀들~~
[일반]
afjtj4bgjejwnf
03.28 12674 84
잡담
나 분명 건전한 만화를 그리라고 했잖아 스플뎀 무엇
whenevereven
03.28
2700
5
나 분명 건전한 만화를 그리라고 했잖아 스플뎀 무엇
잡담
whenevereven
03.28 2700 5
일반
주술회전 가상을 현실로 초반 하차 이유
sjw7whch8
03.28
2273
6
주술회전 가상을 현실로 초반 하차 이유
일반
sjw7whch8
03.28 2273 6
잡담
해적사이트에서 자주 보이는 댓글 유형
金日成綜合大學
03.28
2567
7
해적사이트에서 자주 보이는 댓글 유형
잡담
金日成綜合大學
03.28 2567 7
일반
패러디가 주인공이 현대출신일때 재밌긴해
sjw7whch8
03.28
1614
3
패러디가 주인공이 현대출신일때 재밌긴해
일반
sjw7whch8
03.28 1614 3
후기
내가 비중 없는 엑스트라라고? 한번 해볼까?! 후기.
金日成綜合大學
03.28
5009
5
내가 비중 없는 엑스트라라고? 한번 해볼까?! 후기.
후기
金日成綜合大學
03.28 5009 5
후기
[다중]나의 야생 생존기에 괴이가 침입했다 후기
oiasake
03.28
5106
4
[다중]나의 야생 생존기에 괴이가 침입했다 후기
후기
oiasake
03.28 5106 4
질문
소설 제목좀 알려주세요
nkg0909
03.28
6451
1
소설 제목좀 알려주세요
질문
nkg0909
03.28 6451 1
[패러디]
나는 2차원 미소녀로 변신할 수 있다 1권~6권 11장
라스트오리진으로 팔려간 댕라
03.28
9870
29
나는 2차원 미소녀로 변신할 수 있다 1권~6권 11장
[패러디]
라스트오리진으로 팔려간 댕라
03.28 9870 29
[복구]
미식헌터1-1284.txt
poiuytrewq
03.28
7305
12
미식헌터1-1284.txt
[복구]
poiuytrewq
03.28 7305 12
잡담
현경고현 읽는데
poiuytrewq
03.28
2988
1
현경고현 읽는데
잡담
poiuytrewq
03.28 2988 1
복구 요청
[헌터x헌터] 미식헌터 복구 요청
pppppp
03.28
6353
2
[헌터x헌터] 미식헌터 복구 요청
복구 요청
pppppp
03.28 6353 2
[일반]
이른 쌀먹
quer99
03.28
12189
84
이른 쌀먹
[일반]
quer99
03.28 12189 84
[복구]
추천 소설 복구
8os889s11
03.28
13340
50
추천 소설 복구
[복구]
8os889s11
03.28 13340 50
질문
까다로운? 취향 추천점
버녁차자삼만리
03.28
6934
3
까다로운? 취향 추천점
질문
버녁차자삼만리
03.28 6934 3
[복구]
장생종산수개시(长生从散修开始) 1-889화 [AI번역](Text完).txt
poiuytrewq
03.28
10760
32
장생종산수개시(长生从散修开始) 1-889화 [AI번역](Text完).txt
[복구]
poiuytrewq
03.28 10760 32
[복구]
현환 나는 시스템 환급에 의지해 끊임없이 승급한다 1-2400.txt
poiuytrewq
03.28
10246
26
현환 나는 시스템 환급에 의지해 끊임없이 승급한다 1-2400.txt
[복구]
poiuytrewq
03.28 10246 26
잡담
텔레그램 뒤져보는데 은근히 한국 패러디를 번역한 게 좀 보이네.
rlawjrwnd
03.28
2768
2
텔레그램 뒤져보는데 은근히 한국 패러디를 번역한 게 좀 보이네.
잡담
rlawjrwnd
03.28 2768 2
복구 요청
장생종산수개시 복구 요청드립니다
loaderlight3
03.28
5735
1
장생종산수개시 복구 요청드립니다
복구 요청
loaderlight3
03.28 5735 1
s/ainovel
• 5,169명 구독중AI로 번역된 해외 소설 및 해외 패러디 소설 서브입니다.
2025-09-08 09:40:53 생성
삭제된 댓글입니다.