kone
sub_icon

ainovel

69shuba 탬퍼몽키 업데이트

2025-11-26 03:22:02
정보
조회 8267 · 좋아요 17

기존의 것이 잘되어서 업데이트는 필요없는 것 같은데 일단 지금 쓰고 있는걸 올립니다.



    // ==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
댓글 5개
댓글 쓰기
25.11.26 (25.11.26)
템퍼몽키 새로 만들기 한 다음에 // Your code here... 아래쪽에 붙여넣기 하면 되나요? 다 지우고 붙여넣기 했더니 4행에서 Parsing error : unexpected token id 라는 에러가 뜨는데요?
25.11.26
이거 항상 안되던데 여기 북붙하면서 줄바꿈 문제인지 뭐가 항상안되요
25.11.26 (25.11.26)
https://kio.ac/c/dlR4CKA6vYLTT1ZeKuV5Ob 안되는 분들은 이 링크에서 받으세요.
25.11.26 (25.11.26)

삭제된 댓글입니다.

25.12.01
감사합니다
0 / 500
!!! ㅡ Ai번역 너도 할 수 있다 (하는법)ㅡ !!!
정보
Ghost
25.11.02 8687 34
혼자 간단하게 보기엔 소설번역기 앱 괜찮음
정보
kemomo
25.10.30 2921 2
중국 소설 txt 다운로드 가능한 곳.
정보
cncjs2222
25.10.27 3891 15
사이버펑크2071,2075 용어집
정보
kone1133
25.10.21 4136 15
제가 현재 쓰고있는 사이버펑크 2071, 2075 프롬프트.
정보
kone1133
25.10.21 1095 13
중국어 소설 번역 방밥을 알고 싶어요 가리쵸주세요
정보
rrrr5
25.10.10 779 -4
중국 소설 사이트 모음
정보
naxlamas
25.09.30 13185 41
구글 API Key 여러개 만들기
정보
shakeshack
25.09.23 1379 8
개인적으로 크롬 구글 번역기로 보는 사이트 목록
정보
cks2015
25.09.21 1049 6
69shuba 다운용 템퍼몽키 스크립트
정보
shakeshack
25.09.21 1045 7
민감한 콘텐츠라고 잠긴 자료들 확인하는법.
정보
enzhuai
25.09.11 10771 3
여러분 키오스크는 가입하면 무제한 기간이 가능합니다.
정보
Ghost
25.09.11 1623 8
AI소설 갤에 올리는 소설 번역 하는 법 정리
정보
dlatldyd123
25.09.10 8611 30
번역 새싹을 위한 ai 스튜디오 사용법 링크
정보
tassdar
25.09.09 2862 2
링크 사용법 사진 (임시)
정보
이운팬도
25.09.09 5546 5
중-한 번역 프롬프트 참고용
정보
tassdar
25.09.09 5653 8
일-한 번역 프롬프트 참고용
정보
tassdar
25.09.09 3945 2
영-한 번역 프롬프트 참고용
정보
tassdar
25.09.09 3188 1
(수정)추천 사이트 정리
정보
Ghost
25.09.08 7067 10