기존의 것이 잘되어서 업데이트는 필요없는 것 같은데 일단 지금 쓰고 있는걸 올립니다.
// ==UserScript==
// @name 69shuba Novel Downloader (Background Fetch)
// @namespace http://tampermonkey.net/
// @version 4.13 // 하단 버튼 크기 축소 및 '모두 해제' 버튼 추가
// @description Downloads novel text from 69shuba.com by fetching chapters in the background. Fixes encoding, extracts full content, handles 429/403 errors, and improves UI layout.
// @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;
const INITIAL_PAGE_DELAY_MS = 500;
const MAX_RETRIES = 5;
const MIN_FILE_SIZE_KB = 1;
function log(message) { console.log(`[Tampermonkey: 69shuba Downloader] ${message}`); }
// =======================================================================
// 1. CSS 및 UI 수정
// =======================================================================
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: 60px !important;
left: 50% !important;
transform: translateX(-50%) !important;
z-index: 99999 !important;
background-color: #007bff !important;
color: white !important;
padding: 8px 15px !important;
border-radius: 20px !important;
font-family: sans-serif !important;
font-size: 14px !important;
font-weight: bold !important;
box-shadow: 0 2px 5px rgba(0,0,0,0.2) !important;
pointer-events: none !important;
min-width: 120px !important;
text-align: center !important;
opacity: 0.9;
}
#tampermonkey-controls-container {
margin-bottom: 10px;
padding: 10px;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
position: sticky !important;
top: 0 !important;
z-index: 1000 !important;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
}
#tampermonkey-controls-container button {
padding: 5px 10px;
border: 1px solid #ced4da;
border-radius: 4px;
background-color: #fff;
cursor: pointer;
}
#tampermonkey-controls-container button:hover { background-color: #e2e6ea; }
#tampermonkey-download-button-top { background-color: #28a745 !important; color: white !important; border: none !important; }
#tampermonkey-between-button { background-color: #17a2b8 !important; color: white !important; border: none !important; }
/* 하단 버튼 컨테이너 스타일 */
#tampermonkey-bottom-container {
position: fixed !important;
bottom: 20px !important;
left: 50% !important;
transform: translateX(-50%) !important;
z-index: 9998 !important;
display: flex !important;
gap: 8px !important; /* 간격 조정 */
background-color: rgba(255, 255, 255, 0.9) !important;
padding: 8px !important;
border-radius: 8px !important;
box-shadow: 0 2px 10px rgba(0,0,0,0.2) !important;
border: 1px solid #ddd !important;
}
/* 하단 버튼 공통 스타일 (크기 축소) */
.tm-bottom-btn {
border: none !important;
color: white !important;
padding: 8px 16px !important; /* 크기 절반 정도로 축소 */
text-align: center !important;
font-size: 14px !important; /* 폰트 사이즈 축소 */
cursor: pointer !important;
border-radius: 6px !important;
font-weight: bold !important;
line-height: 1.2 !important;
}
#tampermonkey-download-button-bottom { background-color: #28a745 !important; }
#tampermonkey-between-button-bottom { background-color: #17a2b8 !important; }
#tampermonkey-uncheck-button-bottom { background-color: #6c757d !important; } /* 회색 계열 */
.tm-bottom-btn:disabled { background-color: #cccccc !important; cursor: not-allowed !important; }
/* 챕터 목록 레이아웃 */
ul li { display: flex !important; align-items: center !important; line-height: normal !important; height: auto !important; }
ul li a { white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; }
ul li input[type="checkbox"] { flex-shrink: 0 !important; margin-top: 1px !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 betweenChkButtonTop = $('선택 사이').attr('id', 'tampermonkey-between-button').addClass('tampermonkey-ui-element');
const downloadButtonTop = $('선택 다운로드').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');
// 공통 핸들러
function handleUncheckAll() {
$(episodeListSelector).parent().find('input[type="checkbox"]').prop('checked', false);
isCheckAll = false;
checkAllButton.text('모두 선택');
updateCheckedCount();
}
checkAllButton.on('click', function() {
isCheckAll = !isCheckAll;
$(episodeListSelector).parent().find('input[type="checkbox"]').prop('checked', isCheckAll);
$(this).text(isCheckAll ? '모두 해제' : '모두 선택');
updateCheckedCount();
});
uncheckAllButton.on('click', handleUncheckAll);
downloadButtonTop.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));
function handleBetweenCheck() {
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));
}
betweenChkButtonTop.on('click', handleBetweenCheck);
const controlsContainer = $('
').attr('id', 'tampermonkey-controls-container');
controlsContainer.append(checkAllButton, uncheckAllButton, betweenChkButtonTop, downloadButtonTop, rangeInput, checkTBButton, uncheckTBButton);
controlsContainer.insertBefore($(episodeListSelector).first().closest('ul'));
// =======================================================================
// 하단 고정 버튼 영역
// =======================================================================
const bottomContainer = $('
').attr('id', 'tampermonkey-bottom-container');
// 1. 선택 사이
const betweenButtonBottom = $('선택 사이')
.attr('id', 'tampermonkey-between-button-bottom')
.addClass('tm-bottom-btn');
betweenButtonBottom.on('click', handleBetweenCheck);
// 2. 선택 다운로드
const downloadButtonBottom = $('선택 다운로드')
.attr('id', 'tampermonkey-download-button-bottom')
.addClass('tm-bottom-btn');
downloadButtonBottom.on('click', startBackgroundDownload);
// 3. 모두 해제 (추가됨)
const uncheckButtonBottom = $('모두 해제')
.attr('id', 'tampermonkey-uncheck-button-bottom')
.addClass('tm-bottom-btn');
uncheckButtonBottom.on('click', handleUncheckAll);
// 순서: [선택 사이] [선택 다운로드] [모두 해제]
bottomContainer.append(betweenButtonBottom, downloadButtonBottom, uncheckButtonBottom);
$('body').append(bottomContainer);
}
/**
* 원시 HTML 응답에서 소설 본문 텍스트와 제목을 파싱하여 객체로 반환합니다.
*/
function parseRawHtml(rawHtml) {
const $tempDiv = $('
').html(rawHtml);
const $txtNav = $tempDiv.find('.txtnav');
let chapterTitle = "[제목 추출 실패]";
let finalContent = "[오류: 본문 영역을 찾을 수 없음]";
if ($txtNav.length === 0) {
log("Error: '.txtnav' area not found in fetched HTML.");
return { title: chapterTitle, content: finalContent };
}
const $contentContainer = $txtNav.clone();
const $titleElement = $contentContainer.find('h1.hide720');
if ($titleElement.length > 0) {
chapterTitle = $titleElement.text().trim();
}
$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, ' ');
chapterContent = chapterContent.replace(/<[^>]*>/g, '');
chapterContent = chapterContent.split('\n').map(line => line.trim()).join('\n');
chapterContent = chapterContent.replace(/(\n\s*){3,}/g, '\n\n');
finalContent = chapterContent.trim();
return { title: chapterTitle, content: finalContent };
}
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');
const otherBottomButtons = $('.tm-bottom-btn').not(downloadButtons); // 다른 하단 버튼들
downloadButtons.prop('disabled', true);
otherBottomButtons.prop('disabled', true);
function processNextChapter() {
if (chaptersQueue.length === 0) {
log("All chapters processed.");
downloadButtons.prop('disabled', false).text('다운로드 완료!');
otherBottomButtons.prop('disabled', false);
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})` : ''})`);
const baseUrl = chapter.url.split('/').slice(0, 3).join('/');
GM_xmlhttpRequest({
method: "GET",
url: chapter.url,
overrideMimeType: "text/html; charset=gbk",
headers: {
"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 finalFileContent = '';
let errorContent = '';
const filename = `${chapter.index}.${chapter.title.replace(/[<>:"/\\|?*]/g, '_')}.txt`;
if (response.status === 200) {
const parsedData = parseRawHtml(response.responseText);
let rawContent = `${parsedData.title}\n\n${parsedData.content}`;
const cleanedContent = rawContent.replace(new RegExp(textToRemove.replace(/⊥/g, '⊥?'), 'g'), "").replace(/[\uFEFF]/g, '').trim();
if (parsedData.content.length < (MIN_FILE_SIZE_KB * 500)) {
errorContent = `[본문 내용이 짧음, 재시도]`;
needsRetry = true;
finalFileContent = cleanedContent;
} else {
processedCount++;
log(`[${processedCount}/${totalCount}] Fetched ${chapter.title} successfully.`);
finalFileContent = cleanedContent;
GM_download({
url: 'data:text/plain;charset=utf-8,' + encodeURIComponent(finalFileContent),
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;
finalFileContent = `${chapter.title}\n\n${errorContent}`;
} else {
processedCount++;
log(`Failed to fetch ${chapter.title}, Status: ${response.status}`);
errorContent = `[다운로드 실패: HTTP 상태 코드 ${response.status}]`;
finalFileContent = `${chapter.title}\n\n${errorContent}`;
GM_download({
url: 'data:text/plain;charset=utf-8,' + encodeURIComponent(finalFileContent),
name: filename
});
}
if (needsRetry) {
if (chapter.retryCount < MAX_RETRIES) {
chapter.retryCount++;
delay = BASE_DELAY_MS * Math.pow(2, chapter.retryCount);
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 = `${chapter.title}\n\n[다운로드 실패: ${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)}`);
const errorFileName = `${chapter.index}.${chapter.title.replace(/[<>:"/\\|?*]/g, '_')}.txt`;
const errorMsg = `${chapter.title}\n\n[다운로드 실패: 네트워크 오류]`;
GM_download({
url: 'data:text/plain;charset=utf-8,' + encodeURIComponent(errorMsg),
name: errorFileName
});
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);
}
$(document).ready(function() {
if (window.location.href.includes('/book/') && !window.location.href.includes('/txt/')) {
addCheckboxes();
createListPageUI();
}
});
})();
17
공지
[필독] 임시 채널 규칙 & 신문고
Alria
25.09.08
71840
22
[필독] 임시 채널 규칙 & 신문고
공지
Alria
25.09.08 71840 22
공지
번역 정보글 및 팁 모음
Alria
25.11.03
42301
11
번역 정보글 및 팁 모음
공지
Alria
25.11.03 42301 11
공지
혹시 모를 상황을 대비한 대피소 구축 완료
Alria
05.05
8304
70
혹시 모를 상황을 대비한 대피소 구축 완료
공지
Alria
05.05 8304 70
공지
번역 요청탭의 요청 방식 수정 안내
Alria
25.12.29
38991
8
번역 요청탭의 요청 방식 수정 안내
공지
Alria
25.12.29 38991 8
공지
부관리자 모집 공지
Alria
05.05
5681
19
부관리자 모집 공지
공지
Alria
05.05 5681 19
정보
ai소설 보다가 등장인물 이름이 자꾸 바뀐다거나
poiuytrewq
01.03
8406
6
ai소설 보다가 등장인물 이름이 자꾸 바뀐다거나
정보
poiuytrewq
01.03 8406 6
정보
패러디 본것들 주관적인 평점겸 작품 추천 구걸
아스파가
01.03
9123
16
패러디 본것들 주관적인 평점겸 작품 추천 구걸
정보
아스파가
01.03 9123 16
정보
러시아 참독사이트 찾았다
paldus
01.01
8195
13
러시아 참독사이트 찾았다
정보
paldus
01.01 8195 13
정보
3.0 flash 누락 많으시면 프롬프트로 존나게 가스라이팅 돌리세요
naxlamas
25.12.26
9593
21
3.0 flash 누락 많으시면 프롬프트로 존나게 가스라이팅 돌리세요
정보
naxlamas
25.12.26 9593 21
정보
雷子 뜻이
sankhara
25.12.22
7090
1
雷子 뜻이
정보
sankhara
25.12.22 7090 1
정보
저도 소소한 base64팁입니다.
koronabi3154
25.12.19
8146
13
저도 소소한 base64팁입니다.
정보
koronabi3154
25.12.19 8146 13
정보
고유명사 찐빠 거르는 프롬프트 연구
greener-straper
25.12.18
9984
17
고유명사 찐빠 거르는 프롬프트 연구
정보
greener-straper
25.12.18 9984 17
정보
콜로모 프롬프트 공유
Sorkim
25.12.08
12135
15
콜로모 프롬프트 공유
정보
Sorkim
25.12.08 12135 15
정보
사이버펑크 2071 공식용어 바꾸기 팁(셀프검수)
parksangyong
25.12.07
11794
7
사이버펑크 2071 공식용어 바꾸기 팁(셀프검수)
정보
parksangyong
25.12.07 11794 7
정보
콜로모 번역기 사용중에 에러 뜨면
sankhara
25.12.06
9336
1
콜로모 번역기 사용중에 에러 뜨면
정보
sankhara
25.12.06 9336 1
정보
용어집을 좀 더 강력하게 적용시키는 법
74839
25.12.06
7995
8
용어집을 좀 더 강력하게 적용시키는 법
정보
74839
25.12.06 7995 8
정보
패러디 프롬프트에는 반드시 들어가야 할 것
74839
25.12.05
3519
7
패러디 프롬프트에는 반드시 들어가야 할 것
정보
74839
25.12.05 3519 7
정보
웹소설 분석용 템플릿 프롬프트 (수정)
부캐
25.12.05
9739
7
웹소설 분석용 템플릿 프롬프트 (수정)
정보
부캐
25.12.05 9739 7
정보
패러디 관련 정보 공유해요
t0po2
25.12.01
4436
14
패러디 관련 정보 공유해요
정보
t0po2
25.12.01 4436 14
정보
69shuba 탬퍼몽키 업데이트
kone1133
25.11.26
8264
17
69shuba 탬퍼몽키 업데이트
정보
kone1133
25.11.26 8264 17
정보
gemini blocked contents. 금지 컨텐츠 번역하는 프롬프트
kone1133
25.11.25
4374
20
gemini blocked contents. 금지 컨텐츠 번역하는 프롬프트
정보
kone1133
25.11.25 4374 20
정보
지금 내가 쓰는 배치번역기 잼민이 2.5pro용 프롬프트
naxlamas
25.11.23
7308
28
지금 내가 쓰는 배치번역기 잼민이 2.5pro용 프롬프트
정보
naxlamas
25.11.23 7308 28
정보
중뽕 제거 프롬프트 연구
greener-straper
25.11.14
3215
13
중뽕 제거 프롬프트 연구
정보
greener-straper
25.11.14 3215 13
정보
Xbanxia 소설 다운로드 스크립트 템퍼멍키
kone1133
25.11.12
1911
8
Xbanxia 소설 다운로드 스크립트 템퍼멍키
정보
kone1133
25.11.12 1911 8
정보
프롬프트에서 한국 음역규칙에 따라 적용하라 , 라는 어구는 수정하는게 좋음
sonic001
25.11.12
3198
6
프롬프트에서 한국 음역규칙에 따라 적용하라 , 라는 어구는 수정하는게 좋음
정보
sonic001
25.11.12 3198 6
정보
코랩도 좋은데 gemini-cli 도 한번 써보셈 되게 쉬움
greener-straper
25.11.12
3136
8
코랩도 좋은데 gemini-cli 도 한번 써보셈 되게 쉬움
정보
greener-straper
25.11.12 3136 8
정보
번역시 한자 오류 줄이는 방법
darksun
25.11.12
2919
7
번역시 한자 오류 줄이는 방법
정보
darksun
25.11.12 2919 7
정보
코딩을 아예 몰라도 코딩을 하는 방법 with Gemini. 이미지 多
74839
25.11.11
5245
23
코딩을 아예 몰라도 코딩을 하는 방법 with Gemini. 이미지 多
정보
74839
25.11.11 5245 23
정보
101kanshu 소설 받기 스크립트(수정)
kone1133
25.11.10
5994
19
101kanshu 소설 받기 스크립트(수정)
정보
kone1133
25.11.10 5994 19
정보
twkan 탬퍼멍키 스크립트
kone1133
25.11.05
1651
12
twkan 탬퍼멍키 스크립트
정보
kone1133
25.11.05 1651 12
정보
wordfix2팁
kone1133
25.11.05
778
12
wordfix2팁
정보
kone1133
25.11.05 778 12
정보
69shuba 탬퍼몽키
kone1133
25.11.05
7133
12
69shuba 탬퍼몽키
정보
kone1133
25.11.05 7133 12
정보
Base64 디코딩 아이콘 추가 스크립트.
kone1133
25.11.05
899
10
Base64 디코딩 아이콘 추가 스크립트.
정보
kone1133
25.11.05 899 10
정보
제가 쓰는 검수용 파이썬 스크립트.
kone1133
25.11.03
2997
20
제가 쓰는 검수용 파이썬 스크립트.
정보
kone1133
25.11.03 2997 20
정보
소개글/ 리뷰글 바로 딸깍 완성하는법 (검도제일선)
LG
25.11.03
5508
7
소개글/ 리뷰글 바로 딸깍 완성하는법 (검도제일선)
정보
LG
25.11.03 5508 7
s/ainovel
• 5,169명 구독중AI로 번역된 해외 소설 및 해외 패러디 소설 서브입니다.
2025-09-08 09:40:53 생성
삭제된 댓글입니다.