novel2txt

downloading novel as txt from 69shuba.com

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         novel2txt
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  downloading novel as txt from 69shuba.com
// @author       @gzmonkey
// @match        *://www.69shuba.com/*
// @grant        none
// @license MIT
// ==/UserScript==

function initButton() {
    'use strict';

    const button = document.createElement('button');
    button.innerText = 'Copy TXT';
    button.style.position = 'fixed';
    button.style.top = '16px';
    button.style.right = '16px';
    button.style.padding = '14px 20px';
    button.style.fontSize = '18px';
    button.style.fontWeight = '700';
    button.style.background = '#ffcc00';
    button.style.color = '#000';
    button.style.border = '2px solid #000';
    button.style.borderRadius = '8px';
    button.style.zIndex = '2147483647';
    button.style.boxShadow = '0 4px 10px rgba(0, 0, 0, 0.25)';

    button.onclick = function() {
        console.log('[TM] Button clicked.');
        const chapterLinks = Array.from(document.querySelectorAll('#catalog ul li a'));
        if (chapterLinks.length === 0) {
            console.warn('[TM] No chapter links found with selector:', '#catalog ul li a');
            return;
        }
        const listText = chapterLinks.map((link, idx) => {
            const title = (link.textContent || '').trim();
            const url = (link.href || '').trim();
            return `${idx + 1}. ${title}\n${url}`;
        }).join('\n\n');
        showEditablePopup(listText, chapterLinks);
    };

    if (!document.body) {
        console.warn('[TM] document.body is not available.');
        return;
    }
    document.body.appendChild(button);
}

function showEditablePopup(listText, chapterLinks) {
    const overlay = document.createElement('div');
    overlay.style.position = 'fixed';
    overlay.style.inset = '0';
    overlay.style.background = 'rgba(0, 0, 0, 0.6)';
    overlay.style.zIndex = '2147483647';

    const panel = document.createElement('div');
    panel.style.position = 'absolute';
    panel.style.top = '50%';
    panel.style.left = '50%';
    panel.style.transform = 'translate(-50%, -50%)';
    panel.style.width = '80vw';
    panel.style.maxWidth = '900px';
    panel.style.height = '70vh';
    panel.style.background = '#fff';
    panel.style.borderRadius = '10px';
    panel.style.padding = '16px';
    panel.style.boxShadow = '0 12px 30px rgba(0, 0, 0, 0.35)';
    panel.style.display = 'flex';
    panel.style.flexDirection = 'column';
    panel.style.gap = '10px';

    const title = document.createElement('div');
    title.textContent = 'Chapter List (edit to select)';
    title.style.fontSize = '18px';
    title.style.fontWeight = '700';

    const textarea = document.createElement('textarea');
    textarea.value = listText;
    textarea.style.flex = '1';
    textarea.style.width = '100%';
    textarea.style.resize = 'none';
    textarea.style.fontSize = '14px';
    textarea.style.fontFamily = 'monospace';

    const status = document.createElement('input');
    status.type = 'text';
    status.readOnly = true;
    status.value = '';
    status.style.fontSize = '14px';
    status.style.fontFamily = 'monospace';
    status.style.border = '1px solid #ccc';
    status.style.padding = '4px';
    status.style.marginTop = '4px';

    const actions = document.createElement('div');
    actions.style.display = 'flex';
    actions.style.gap = '8px';
    actions.style.justifyContent = 'flex-end';

    const startBtn = document.createElement('button');
    startBtn.textContent = 'Start Fetch';
    startBtn.style.padding = '8px 12px';
    const stopBtn = document.createElement('button');
    stopBtn.textContent = 'Stop';
    stopBtn.style.padding = '8px 12px';
    stopBtn.disabled = true;
    let stopRequested = false;
    startBtn.onclick = () => {
        const lines = textarea.value.split('\n').filter(Boolean);
        const urls = lines.filter(line => line.startsWith('https://'));
        startBtn.disabled = true;
        startBtn.textContent = 'Fetching...';
        stopBtn.disabled = false;
        stopRequested = false;
        fetchAndAppend(urls, textarea, status, () => {
            startBtn.textContent = 'Done';
            stopBtn.disabled = true;
        }, () => stopRequested);
    };
    stopBtn.onclick = () => {
        stopRequested = true;
        stopBtn.disabled = true;
        stopBtn.textContent = 'Stopping...';
        textarea.value += '\n[Stopped by user]';
    };

    const closeBtn = document.createElement('button');
    closeBtn.textContent = 'Close';
    closeBtn.style.padding = '8px 12px';
    closeBtn.onclick = () => overlay.remove();

    actions.appendChild(startBtn);
    actions.appendChild(stopBtn);
    actions.appendChild(closeBtn);
    panel.appendChild(title);
    panel.appendChild(textarea);
    panel.appendChild(status);
    panel.appendChild(actions);
    overlay.appendChild(panel);
    overlay.addEventListener('click', (event) => {
        if (event.target === overlay) {
            overlay.remove();
        }
    });
    document.body.appendChild(overlay);
}

async function fetchAndAppend(urls, textarea, status, doneCallback, shouldStop) {
    textarea.value = '';
    const maxSize = 3.5 * 1024 * 1024; // 3.5 MB
    for (let i = 0; i < urls.length; i++) {
        if (shouldStop && shouldStop()) {
            textarea.value += '\n[Stopped by user]';
            status.value = 'Stopped by user';
            console.warn('[TM] Stop requested by user.');
            break;
        }
        const url = urls[i];
        try {
            status.value = `Fetching ${i + 1}/${urls.length}...`;
            const content = await fetchChapterContent(url);
            const title = `Chapter ${i + 1}`;
            const block = `[${title}]\n${content}\n\n`;
            const newSize = new Blob([textarea.value + block]).size;
            if (newSize > maxSize) {
                textarea.value += `\n[Stopped: size limit reached]`;
                status.value = 'Stopped: size limit reached';
                console.warn('[TM] Size limit reached, stopping.');
                break;
            }
            textarea.value += block;
            textarea.scrollTop = textarea.scrollHeight;
            status.value = `Fetched: ${title}`;
            console.log('[TM] Fetched:', title);
            if (i < urls.length - 1) {
                const delayMs = 500 + Math.floor(Math.random() * 2500);
                await delay(delayMs);
            }
        } catch (error) {
            console.error('[TM] Fetch failed:', url, error);
            textarea.value += `[Fetch failed]\n${url}\n\n`;
            status.value = `Fetch failed: Chapter ${i + 1}`;
            break;
        }
    }
    if (doneCallback) doneCallback();
}

function delay(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchChapterContent(url) {
    const response = await fetch(url, { credentials: 'include' });
    if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
    }
    const buffer = await response.arrayBuffer();
    const html = new TextDecoder('gbk').decode(buffer);
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const contentRoot = doc.querySelector('.txtnav');
    if (!contentRoot) {
        return '';
    }
    const cleanupSelectors = ['.txtinfo', '#txtright', '.contentadv', '.bottom-ad', '.bottom-ad2', '.page1', 'script'];
    cleanupSelectors.forEach((selector) => {
        contentRoot.querySelectorAll(selector).forEach((node) => node.remove());
    });
    const htmlWithBreaks = contentRoot.innerHTML.replace(/<br\s*\/?>/gi, '\n');
    const temp = doc.createElement('div');
    temp.innerHTML = htmlWithBreaks;
    const rawText = temp.textContent || '';
    return rawText.replace(/\n{3,}/g, '\n\n').trim();
}

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initButton);
} else {
    initButton();
}