kone
ainovel

ainovel

twkan 탬퍼멍키 스크립트

11/05/2025, 14:27:14
정보
1746 views · 12 likes

// ==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 comments
Sign in to comment
11/05/25
정말 감사합니다 이것도 아래에 올리신것처럼 정렬해주셔서 올려주실수 있으실까요?
11/05/25 Edited 11/05/25
그냥 입력해도 됩니다. 아니면 코드를 보려면 긁어서 ai한테 정렬해달라고 하면 바로 해줄걸요.
11/05/25
아래 69shuba ai한테 정렬 부탁해서 하니까 되네요! 이것도 해보겠습니다
11/05/25
이것도 ai한테 정렬시키니 되네요! 정말 감사합니다
11/06/25
감사합니다
11/25/25
이거 지금은 되는거 맞음? 실행 버튼이 안보이는데.
11/26/25
방금해봤는데 잘됩니다. 전체 목록이 보이게 버튼 눌러서 들어가야함.
Sign in to comment
백간성제 번역좀요
번역 요청
bobozi
04/28 1494 -12
오레키 호타로의 실력지상주의 교실 1-801 복구요청
복구 요청
alex10
04/28 1186 1
(복구)유키노시타의 사랑하는 남편 일기 [1-986]
[복구]
youwh2002
04/28 2395 14
약스포) 환생한 만화의 신 초기 하차
후기
siuuuuuuu
04/28 1152 5
중국어를 못하니까 회원제 소설사이트 가입이 안되네
잡담
hotchillibrush
04/28 931 0
선협 ... 추천 원해요 강력하게 ... ㅠㅠ
잡담
inkzip
04/28 1785 1
앰포리어스 해피엔딩을 팬픽에서라도 보니 기분 좋네요
잡담
tarclu
04/28 1805 3
전생안으로 바라본 나루토 세계 재밌었다
후기
북도선인
04/28 1674 2
전업 예술가 보신분 중뽕있나요?
질문
sm
04/28 1242 0
중국인든 한국이든
잡담
mcnong
04/28 1443 7
후기) 전업 예술가 개재밌음
후기
oo12345
04/28 1740 7
맛있는 도작물이나 요리사 패러디 추천부탁드립니다.
질문
fubukidaisuki12
04/28 878 2
나씨 가문 사위가 되었다 재밌긴 한데, 하차 후기
후기
hkrjjngndjsj1
04/28 3463 4
(스포약간)바보선생의 위엄을 미리세워주었다 반정도봤는데 역시 로사고선생이다.
잡담
syuranian
04/28 863 1
마법사 합성보석부터 시작 복구요청 드립니다.
복구 요청
siwa109
04/28 1305 0
사탄의 ntr 전설 복구요청 드려요
복구 요청
24kmagic
04/27 1428 0
개인적으로 원피스 패러디에서 문문열매 멋있던거
잡담
하이바라
04/27 859 0
해적의 재앙 여기 올라온적이 있나요?
잡담
vhzkf
04/27 1286 3
유키노시타의 사랑하는 남편 일기 복구 요청합니다
복구 요청
astelaskin
04/27 2576 0
중국 팬픽 유료화가 무법천지인 이유는 김용 신조협려 사건이 오히려 나쁜 사례를 만들어줌
잡담
naxlamas
04/27 2882 17
중국소설은 이런게 이해가 잘 안됨
잡담
wmi1004
04/27 2641 -3
헌터×헌터 4차원 주머니에 로그인) 와 나루토
후기
aowlr
04/27 2123 0
새삼 미나토가 대단하네
잡담
brlosnb2744
04/27 1447 1
아니 던전버섯 진짜 재미있기는 한데ㅋㅋㅋ
잡담
aaaaaa1234
04/27 1387 2
새삼 비뢰신<< 진짜 개사기기술
잡담
reyell0101
04/27 1427 1
[식극의소마x다중]나는 인생 시뮬레이터를 주웠다我捡到了一个人生模拟器 (완결)
[패러디]
hyun6871
04/27 2616 29
제가 오늘 쓴 쌀먹 글에서 소개 누락된 부분들 다 채워넣었습니다
잡담
tassdar
04/27 1633 8
[다중] 지고의 주인부터 시작되는 이세계 여행 (재번역)
[패러디]
金日成綜合大學
04/27 3555 58
검수하면서 무한 츠쿠요미 발견함
잡담
金日成綜合大學
04/27 1132 8
[패러디]테루미 메이에게 발굴당했다 후기
후기
74839
04/27 2271 2