kone
sub_icon

ainovel

twkan 탬퍼멍키 스크립트

2025-11-05 14:27:14
정보
조회 1655 · 좋아요 12

// ==UserScript== // @name twkan Novel Downloader (Background Fetch) // @namespace http://tampermonkey.net/ // @version 4.13 // 버전 업데이트 (제목 추가) // @description Downloads novel text from twkan.com by fetching chapters in the background. Handles encoding issues, extracts full content, handles rate-limiting, and 403 forbidden errors. // @author You // @match https://twkan.com/book/* // @grant GM_download // @grant GM_xmlhttpRequest // @require https://code.jquery.com/jquery-3.6.0.min.js // @run-at document-end // ==/UserScript== (function() { 'use strict'; // Selectors and constants const episodeListSelector = 'div#allchapter ul li a'; const textToRemovePattern = /本書由.{0,20}\.com全網首發|本文將在網站同步更新。|喜歡的給個收藏給個推薦票,不喜歡的也別噴|(本章完)/ig; let isCheckAll = false; let countLabel; 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: twkan Downloader] ${message}`); } // ======================================================= // 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: 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).closest('li').find('input[type="checkbox"]:checked').length; countLabel.text(`선택된 항목 = ${checkedCount}개`); } function addCheckboxes() { let episodeIndex = 1; const $chapterLinks = $(episodeListSelector); log(`addCheckboxes called. Found ${$chapterLinks.length} chapter links.`); $chapterLinks.each(function() { const link = $(this); const listItem = link.closest('li'); if (listItem.find('input[type="checkbox"]').length > 0) { return; } const dataNum = listItem.data('num'); const index = dataNum !== undefined ? parseInt(dataNum, 10) : episodeIndex; const checkbox = $('').css('margin-right', '5px'); checkbox.on('change', updateCheckedCount); link.data({ index: index, title: link.text().trim(), url: this.href }); episodeIndex++; 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).closest('li').find('input[type="checkbox"]').prop('checked', isCheckAll); $(this).text(isCheckAll ? '모두 해제' : '모두 선택'); updateCheckedCount(); }); uncheckAllButton.on('click', function() { $(episodeListSelector).closest('li').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; }).closest('li').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).closest('li').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'); const chapterListContainer = $('.catalog').has('#allchapter'); if (chapterListContainer.length) { controlsContainer.append(checkAllButton, uncheckAllButton, downloadButton, rangeInput, checkTBButton, uncheckTBButton, betweenChkButton); controlsContainer.insertBefore(chapterListContainer); } else { $('body').prepend(controlsContainer); } const downloadButtonBottom = $('선택 다운로드').attr('id', 'tampermonkey-download-button-bottom'); downloadButtonBottom.on('click', startBackgroundDownload); $('body').append(downloadButtonBottom); } /** * 원시 HTML 응답에서 소설 본문 텍스트를 파싱하여 추출합니다. */ function parseRawHtml(rawHtml) { const $tempDiv = $('').html(rawHtml); // 1. 제목 추출 (div.txtnav 내의 h1) const $titleElement = $tempDiv.find('.txtnav h1').first(); let chapterTitle = $titleElement.length ? $titleElement.text().trim() : ""; // 2. 본문 컨테이너 추출 const $contentContainer = $tempDiv.find('div#txtcontent0'); if ($contentContainer.length === 0) { log("Error: 'div#txtcontent0' area not found in fetched HTML."); return (chapterTitle ? chapterTitle + "\n\n" : "") + "[오류: 본문 영역을 찾을 수 없음]"; } let chapterContent = $contentContainer.html(); // 불필요한 HTML 요소 제거 (광고 스크립트 등) $contentContainer.find('script, .adsbygoogle').remove(); //
태그를 줄 바꿈 문자로 변환 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.replace(textToRemovePattern, ''); chapterContent = chapterContent.trim(); // 3. 제목과 본문을 합칩니다. if (chapterTitle) { return chapterTitle + "\n\n" + chapterContent; } return chapterContent; } function startBackgroundDownload() { const chaptersQueue = $(episodeListSelector).closest('li').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})` : ''})`); const baseUrl = chapter.url.split('/').slice(0, 3).join('/'); GM_xmlhttpRequest({ method: "GET", url: chapter.url, 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 chapterContent = ''; let errorContent = ''; const filename = `${chapter.index}.${chapter.title.replace(/[<>:"/\\|?*]/g, '_')}.txt`; if (response.status === 200) { chapterContent = parseRawHtml(response.responseText); const cleanedContent = chapterContent.replace(/[\uFEFF]/g, '').trim(); 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) { errorContent = `[다운로드 실패: HTTP 상태 코드 ${response.status}]`; needsRetry = true; } else { 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); 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.`); setTimeout(() => { processNextChapter(); }, INITIAL_PAGE_DELAY_MS); } /** * 웹페이지 스코프에 랩핑 로직을 주입합니다. */ function injectLoadMoreWrapper() { document.addEventListener('tampermonkey:chapters_loaded', function() { log("Custom event received. Re-running addCheckboxes."); setTimeout(addCheckboxes, 100); }); const script = document.createElement('script'); script.textContent = ` (function() { if (typeof LoadMore === 'function') { const originalLoadMore = LoadMore; LoadMore = function() { originalLoadMore.apply(this, arguments); setTimeout(function() { const event = new CustomEvent('tampermonkey:chapters_loaded'); document.dispatchEvent(event); }, 800); }; } })(); `; (document.body || document.head || document.documentElement).appendChild(script); script.remove(); } function initUI() { if (!window.location.href.match(/\/book\/\d+(\/index\.html)?$/)) { return; } injectLoadMoreWrapper(); addCheckboxes(); createListPageUI(); const $loadMoreButton = $('#loadmore'); if ($loadMoreButton.length) { log("LoadMore button found. Automatically clicking to expand all chapters."); $loadMoreButton.hide(); if ($loadMoreButton[0].click) { $loadMoreButton[0].click(); } else { $loadMoreButton.trigger('click'); } } } $(document).ready(function() { initUI(); }); })();

12
댓글 7개
댓글 쓰기
25.11.05
정말 감사합니다 이것도 아래에 올리신것처럼 정렬해주셔서 올려주실수 있으실까요?
25.11.05 (25.11.05)
그냥 입력해도 됩니다. 아니면 코드를 보려면 긁어서 ai한테 정렬해달라고 하면 바로 해줄걸요.
25.11.05
아래 69shuba ai한테 정렬 부탁해서 하니까 되네요! 이것도 해보겠습니다
25.11.05
이것도 ai한테 정렬시키니 되네요! 정말 감사합니다
25.11.06
감사합니다
25.11.25
이거 지금은 되는거 맞음? 실행 버튼이 안보이는데.
25.11.26
방금해봤는데 잘됩니다. 전체 목록이 보이게 버튼 눌러서 들어가야함.
0 / 500
💥 26년 1분기, [AINOVEL] 순위 발표 (일부 줄거리 포함)
정보
코네
04.01 24252 59
53dushu에서 파일 찾으시는 분은
정보
naxlamas
03.11 10383 26
누더기 3.2 기반 번역 프롬프트
정보
greener-straper
02.09 9703 15
중국소설공유방 링크
정보
1atchup98fy
02.06 11019 21
패러디 본것들 주관적인 평점겸 작품 추천 구걸
정보
아스파가
01.03 9128 16
3.0 flash 누락 많으시면 프롬프트로 존나게 가스라이팅 돌리세요
정보
naxlamas
25.12.26 9596 21
고유명사 찐빠 거르는 프롬프트 연구
정보
greener-straper
25.12.18 9987 17
콜로모 프롬프트 공유
정보
Sorkim
25.12.08 12138 15
69shuba 탬퍼몽키 업데이트
정보
kone1133
25.11.26 8267 17
gemini blocked contents. 금지 컨텐츠 번역하는 프롬프트
정보
kone1133
25.11.25 4374 20
지금 내가 쓰는 배치번역기 잼민이 2.5pro용 프롬프트
정보
naxlamas
25.11.23 7311 28
코딩을 아예 몰라도 코딩을 하는 방법 with Gemini. 이미지 多
정보
74839
25.11.11 5249 23
101kanshu 소설 받기 스크립트(수정)
정보
kone1133
25.11.10 5997 19
제가 쓰는 검수용 파이썬 스크립트.
정보
kone1133
25.11.03 2998 20
!!! ㅡ Ai번역 너도 할 수 있다 (하는법)ㅡ !!!
정보
Ghost
25.11.02 8686 34
중국 소설 txt 다운로드 가능한 곳.
정보
cncjs2222
25.10.27 3890 15
사이버펑크2071,2075 용어집
정보
kone1133
25.10.21 4135 15
중국 소설 사이트 모음
정보
naxlamas
25.09.30 13185 41
AI소설 갤에 올리는 소설 번역 하는 법 정리
정보
dlatldyd123
25.09.10 8611 30