kone
ainovel

ainovel

twkan 탬퍼멍키 스크립트

11/05/2025, 14:27:14
정보
1819 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
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
방금해봤는데 잘됩니다. 전체 목록이 보이게 버튼 눌러서 들어가야함.
[뱅드림]설마 나, 진짜 여자 성우 중독인가?难道我真有女声优瘾? 1-363
[패러디]
hyun6871
01/21 3250 65
중국 1980년대 근대물 3대장
후기
8os889s11
01/20 4469 29
[ai 번역] 뒤죽박죽 이세계에서 엄청 야한 슬로 라이프를 하려던 결과, 뒷세계의 지배자가 된 건 1-7 완
[야설]
loadinside
01/20 12622 33
역류 1982 1-2432 (완)
[복구]
serv-2004
01/20 2883 30
스피드 쌀먹 공유 모음 20260120
[일반]
tassdar
01/20 5773 89
[경벽유연] 포회야상활 / 조연도 살고싶다 (ai 번역)
[언정]
nannakhs11
01/20 9793 26
워해머:니르바나의 이름으로 1-380
[패러디]
asd333t
01/20 3173 36
[후기 번역] 최면 뽕빨물 심층 리뷰 & 추천 2탄~
후기
greener-straper
01/20 10228 43
오랜만에 짧게 조금 모음.
[패러디]
닉네임을뭐라고하냐
01/20 4184 49
나보다 악마의 열매를 잘 알지는 못할걸[-234]
[패러디]
reyell0101
01/19 2816 36
[원신]원신 악인 시뮬레이터, 내가 그녀들의 공공의 적이라고?原神恶人模拟器,我是她们的公敌?1-1311(완결)
[패러디]
hyun6871
01/19 3481 73
쌀먹 나머지~~~
[일반]
afjtj4bgjejwnf
01/19 4887 99
스피드 쌀먹 공유 모음 20260119
[일반]
tassdar
01/19 6080 110
[타입문] 당신들은 이걸 대한 이라고 부르는가? (完)
[패러디]
金日成綜合大學
01/19 3136 34
[AI 번역]『하렘 최면 일기』(后宫催眠日记) 1~503
[야설]
greener-straper
01/19 13801 68
[다중]붕괴로 시작하는 기물제작자 1-474
[패러디]
네르고
01/19 2692 74
4개 갱신및 완결
[일반]
xau111
01/19 5441 90
진말장검 1 - 1516(완)
[일반]
songcow123
01/19 4147 48
[다중] 워해머40K 에서 귀환한 낙오자들1-280
[패러디]
金日成綜合大學
01/18 2703 28
스피드 쌀먹 공유 모음 20260118
[일반]
tassdar
01/18 5287 89
[뱅드림x마마마]토가와 사키코는 마녀가 되고 싶지 않아丰川祥子不想变成魔女 (완결)
[패러디]
hyun6871
01/18 3358 22
[AI 번역]『나의 고등학교 국어 선생님』(我的高中语文老师) 1~33
[야설]
greener-straper
01/18 11398 33
구재무도세계성성 1-444 연재중
[일반]
vkqlffhs103
01/17 4101 46
[AI번역][신비의 제왕]신비:행운아 诡秘:幸运儿 1-709 完 재번역
[패러디]
basileus31
01/17 3965 42
쌀먹 나머지~~~
[일반]
afjtj4bgjejwnf
01/17 5151 96
(갱신)[뱅드림x걸밴크]밴드 소녀 육성 가이드乐队少女培养指南 1-2권146장
[패러디]
hyun6871
01/17 2621 51
스피드 쌀먹 공유 모음 20260117
[일반]
tassdar
01/17 7586 152
[AI 번역]『낙화미앙』(落花未央) 1~51
[야설]
greener-straper
01/17 10416 32
[드래곤볼] 사이어인은 강해지고 싶을 뿐! (完)
[패러디]
金日成綜合大學
01/16 3868 51
[뱅드림x봇치x걸밴크]네가 토가와 사키코라면, 나는 누구지?你是丰川祥子,那我是谁?1-201 (완결)
[패러디]
hyun6871
01/16 2953 51