Vozer downloader

Tải truyện từ Vozer định dạng EPUB.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name            Vozer downloader
// @namespace       https://lelinhtinh.github.io/
// @description     Tải truyện từ Vozer định dạng EPUB.
// @version         1.0.0
// @icon            https://raw.githubusercontent.com/lelinhtinh/Userscript/refs/heads/master/vozer_downloader/icon.jpg
// @author          lelinhtinh
// @oujs:author     baivong
// @license         MIT; https://lelinhtinh.mit-license.org/license.txt
// @match           https://vozer.io/*
// @require         https://code.jquery.com/jquery-3.7.1.min.js
// @require         https://unpkg.com/[email protected]/dist/jszip.min.js
// @require         https://unpkg.com/[email protected]/dist/FileSaver.min.js
// @require         https://unpkg.com/[email protected]/ejs.min.js
// @require         https://unpkg.com/[email protected]/dist/jepub.js
// @require         https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js?v=a834d46
// @noframes
// @connect         *
// @supportURL      https://github.com/lelinhtinh/Userscript/issues
// @run-at          document-idle
// @grant           GM_xmlhttpRequest
// @grant           GM.xmlHttpRequest
// ==/UserScript==

(function ($, window, document) {
  'use strict';

  // ===== SETTINGS =====
  const settings = {
    errorAlert: false,
    allowedImageExtensions: ['jpg', 'jpeg', 'png', 'webp'],
  };

  // ===== UTILITY FUNCTIONS =====
  const chunkArray = (arr, per) => {
    return arr.reduce((resultArray, item, index) => {
      const chunkIndex = Math.floor(index / per);
      if (!resultArray[chunkIndex]) resultArray[chunkIndex] = [];
      resultArray[chunkIndex].push(item);
      return resultArray;
    }, []);
  };

  const cleanHtml = (str) => {
    str = str.replace(/\s*Chương\s*\d+\s?:[^<\n]/, '');
    str = str.replace(/[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]+/gm, ''); // eslint-disable-line
    return '<div>' + str + '</div>';
  };

  const beforeleaving = (e) => {
    e.preventDefault();
    e.returnValue = '';
  };

  const shouldSkipImage = (imageUrl) => {
    const urlExtension = imageUrl.match(/\.(\w+)(\?|#|$)/i);
    const extension = urlExtension ? urlExtension[1].toLowerCase() : null;
    // Skip images with unsupported extensions
    return !extension || !settings.allowedImageExtensions.includes(extension);
  };
  const processSingleImage = async (imgElement, imgSrc, imageIndex, totalImages) => {
    if (!imgSrc) {
      imgElement.remove();
      return;
    }

    try {
      const absoluteUrl = new URL(imgSrc, locationInfo.referrer).href;

      if (shouldSkipImage(absoluteUrl)) {
        console.log(`Bỏ qua ảnh có định dạng không hỗ trợ: ${absoluteUrl}`);
        imgElement.remove();
        return;
      }

      console.log(`Đang tải ảnh ${imageIndex + 1}/${totalImages}: ${absoluteUrl}`);

      const chapId = chapterState.current.link.replace(/\W+/g, '_');
      const imageId = await downloadAndAddImage(absoluteUrl, `chap_${chapId}_img_${imageIndex}`);
      imgElement.replaceWith(`<p><%= image['${imageId}'] %></p>`); // Delay between images to avoid rate limiting
      if (imageIndex < totalImages - 1) {
        await new Promise((resolve) => setTimeout(resolve, 500));
      }
    } catch (error) {
      console.warn('Không thể tải ảnh:', imgSrc, error);
      imgElement.replaceWith('<p><a href="' + imgSrc + '">Click để xem ảnh</a></p>');
    }
  };

  const processChapterImages = async ($chapter) => {
    const $images = $chapter.find('img');
    if (!$images.length) return;

    // Process each image sequentially
    for (let i = 0; i < $images.length; i++) {
      await processSingleImage($images.eq(i), $images.eq(i).attr('src'), i, $images.length);
    }
  };

  const cleanChapterContent = ($chapter) => {
    // Remove unwanted elements
    const $unwantedElements = $chapter.find('script, style, a');
    const $hiddenElements = $chapter.find('[style]').filter(function () {
      return this.style.fontSize === '1px' || this.style.fontSize === '0px' || this.style.color === 'white';
    });
    const $textNodes = $chapter.contents().filter(function () {
      return this.nodeType === 3 && this.nodeValue.trim() !== '';
    });

    $unwantedElements.remove();
    $hiddenElements.remove();
    $textNodes.remove();

    return $chapter.text().trim() !== '' ? cleanHtml($chapter.html()) : null;
  };

  const extractChapterTitle = ($data) => {
    let title = $data.find('h1').text().trim();
    if (!title) {
      const chapterMatch = chapterState.current.link.match(/\d+/);
      title = chapterMatch ? `Chương ${chapterMatch[0]}` : 'Chương không xác định';
    }
    return title;
  };

  const updateDownloadProgress = () => {
    const progressText = `Đang tải <strong>${chapterState.current.index}/${chapterState.size}${
      partState.size ? '/' + (partState.current + 1) : ''
    }</strong>`;
    ui.$download.html(progressText);
    document.title = `[${chapterState.current.index}] ${ui.pageName}`;
    console.log(`Đã tải: ${chapterState.current.index}/${chapterState.size} - ${chapterState.current.title}`);
  };

  const downloadAndAddImage = async (imgUrl, imageId) => {
    return new Promise((resolve, reject) => {
      GM.xmlHttpRequest({
        method: 'GET',
        url: imgUrl,
        responseType: 'arraybuffer',
        timeout: 15000, // 15 second timeout
        headers: {
          'User-Agent':
            'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
          Referer: locationInfo.referrer,
        },
        onload: (response) => {
          try {
            if (response.status === 200 && response.response && response.response.byteLength > 0) {
              libs.jepub.image(response.response, imageId);
              console.log(`Đã tải thành công ảnh: ${imageId} (${Math.round(response.response.byteLength / 1024)}KB)`);
              resolve(imageId);
            } else {
              reject(new Error(`HTTP ${response.status} hoặc ảnh rỗng`));
            }
          } catch (error) {
            reject(error);
          }
        },
        onerror: (error) => {
          reject(new Error('Lỗi mạng khi tải ảnh'));
        },
        ontimeout: () => {
          reject(new Error('Timeout khi tải ảnh'));
        },
      });
    });
  };

  // ===== GLOBAL STATE OBJECTS =====

  // URL and Location Information
  const locationInfo = {
    host: location.host,
    pathname: location.pathname,
    referrer: location.protocol + '//' + location.host + location.pathname,
    novelAlias: location.pathname.slice(1, -1),
  };

  // Ebook Metadata
  const ebookInfo = {
    title: $('h1').text().trim(),
    author: null,
    cover: null,
    description: null,
    genres: [],
    credits: `<p>Truyện được tải từ <a href="${locationInfo.referrer}">Vozer</a></p><p>Userscript được viết bởi: <a href="https://lelinhtinh.github.io/jEpub/">lelinhtinh</a></p>`,
  };

  // Chapter Management
  const chapterState = {
    list: [],
    size: 0,
    current: {
      link: '',
      title: '',
      index: 0,
    },
    progress: {
      begin: '',
      end: '',
      summary: '',
    },
  };

  // Part Management (for splitting large books)
  const partState = {
    list: [],
    size: 0,
    current: 0,
  };

  // Download State
  const downloadState = {
    status: '',
    isFinished: false,
    hasErrors: false,
    delay: 0,
    errorTitles: [],
  };

  // UI Elements
  const ui = {
    pageName: document.title,
    $download: $('<a>', {
      class: 'pt-1.5 pb-1 px-2 ml-4 leading-normal font-semibold text-white rounded bg-blue-001',
      href: '#download',
      text: 'Tải xuống',
    }),
    $description: $('#chapter_001 > .font-content.smiley'),
  };

  // External Libraries
  const libs = {
    jepub: null,
  };

  // Helper function for download status
  const downloadStatus = (label) => {
    const labelStatus = {
      primary: 'bg-blue-001',
      success: 'bg-green-001',
      danger: 'bg-red-001',
      warning: 'bg-yellow-600',
    };
    downloadState.status = label;
    ui.$download
      .removeClass('bg-blue-001 bg-green-001 bg-red-001 bg-yellow-600')
      .addClass(labelStatus[label] || 'bg-blue-001');
  };

  // ===== MAIN FUNCTIONS =====
  const downloadError = (message, error, isServerError) => {
    downloadStatus('danger');

    handleErrorAlert(message);
    if (error) console.error(message, error);

    if (isServerError) {
      return handleServerError();
    }

    return handleChapterContentError(message);
  };

  const handleErrorAlert = (message) => {
    if (settings.errorAlert) {
      settings.errorAlert = confirm(`Lỗi! ${message}\nBạn có muốn tiếp tục nhận cảnh báo?`);
    }
  };

  const handleServerError = () => {
    if (downloadState.delay > 700) {
      if (chapterState.current.title) downloadState.errorTitles.push(chapterState.current.title);
      console.warn('Dừng tải do quá nhiều lỗi kết nối');
      return;
    }

    downloadStatus('warning');
    downloadState.delay += 100;
    retryGetContent();
  };

  const retryGetContent = () => {
    setTimeout(async () => {
      try {
        await getContent();
      } catch (error) {
        console.error('Lỗi trong retry getContent:', error);
      }
    }, downloadState.delay);
  };

  const handleChapterContentError = (message) => {
    if (!chapterState.current.title) return;

    downloadState.errorTitles.push(chapterState.current.title);
    return `<p class="no-indent"><a href="${chapterState.current.link}">${message}</a></p>`;
  };

  const genEbook = async () => {
    try {
      const epubZipContent = await libs.jepub.generate('blob', (metadata) => {
        ui.$download.html('Đang nén <strong>' + metadata.percent.toFixed(2) + '%</strong>');
      });

      document.title = '[⇓] ' + ebookInfo.title;
      window.removeEventListener('beforeunload', beforeleaving);
      const ebookFilename = locationInfo.novelAlias + (partState.size ? '-p' + (partState.current + 1) : '') + '.epub';

      ui.$download
        .attr({
          href: window.URL.createObjectURL(epubZipContent),
          download: ebookFilename,
        })
        .text('Hoàn thành')
        .off('click');
      if (downloadState.status !== 'danger') downloadStatus('success');

      saveAs(epubZipContent, ebookFilename);
      setTimeout(async () => {
        await checkPart();
      }, 2000);
    } catch (err) {
      downloadStatus('danger');
      console.error('Lỗi khi tạo EPUB:', err);
      ui.$download.text('Lỗi tạo EPUB');
    }
  };

  const checkPart = async () => {
    if (partState.current >= partState.size) return;
    partState.current++;
    chapterState.list = partState.list[partState.current];
    chapterState.size = chapterState.list.length;

    // Reset chapter state for new part
    chapterState.current.link = '';
    chapterState.current.title = '';
    chapterState.current.index = 0;
    chapterState.progress.begin = '';
    chapterState.progress.end = '';
    downloadState.isFinished = false;

    await init();
  };

  const saveEbook = async () => {
    if (downloadState.isFinished) {
      console.warn('saveEbook đã được gọi, bỏ qua duplicate call');
      return;
    }
    downloadState.isFinished = true;
    ui.$download.html('Bắt đầu tạo EPUB');
    console.log('Bắt đầu tạo EPUB...');

    let titleErrorHtml = '';
    if (downloadState.errorTitles.length) {
      titleErrorHtml =
        '<p class="no-indent"><strong>Các chương lỗi: </strong>' + downloadState.errorTitles.join(', ') + '</p>';
    }
    chapterState.progress.summary =
      '<p class="no-indent">Nội dung từ <strong>' +
      chapterState.progress.begin +
      '</strong> đến <strong>' +
      chapterState.progress.end +
      '</strong></p>';

    libs.jepub.notes(chapterState.progress.summary + titleErrorHtml + '<br /><br />' + ebookInfo.credits);

    try {
      const response = await new Promise((resolve, reject) => {
        GM.xmlHttpRequest({
          method: 'GET',
          url: ebookInfo.cover,
          responseType: 'arraybuffer',
          onload: resolve,
          onerror: reject,
        });
      });

      try {
        libs.jepub.cover(response.response);
      } catch (err) {
        console.error(err);
      }
    } catch (err) {
      console.error('Lỗi khi tải cover:', err);
    }
    await genEbook();
  };

  const getContent = async () => {
    if (downloadState.isFinished) return;

    chapterState.current.link = chapterState.list[chapterState.current.index];

    try {
      const response = await $.get(chapterState.current.link);
      const $data = $(response);

      if (downloadState.isFinished) return;

      chapterState.current.title = extractChapterTitle($data);
      let chapContent = await processChapterContent($data);

      libs.jepub.add(chapterState.current.title, chapContent);
      updateChapterProgress();

      if (await shouldFinishDownload()) {
        await saveEbook();
      } else {
        scheduleNextChapter();
      }
    } catch (err) {
      handleChapterError(err);
    }
  };

  const processChapterContent = async ($data) => {
    const $chapter = $data.find('#content');

    if (!$chapter.length) {
      return downloadError('Không có nội dung');
    }

    await processChapterImages($chapter);
    const cleanedContent = cleanChapterContent($chapter);

    if (!cleanedContent) {
      return downloadError('Nội dung không có');
    }

    if (downloadState.status !== 'danger') downloadStatus('warning');
    return cleanedContent;
  };

  const updateChapterProgress = () => {
    if (chapterState.current.index === 0) chapterState.progress.begin = chapterState.current.title;
    chapterState.progress.end = chapterState.current.title;
    chapterState.current.index++;
    updateDownloadProgress();
  };

  const shouldFinishDownload = async () => {
    const isComplete = chapterState.current.index >= chapterState.size;
    if (isComplete) {
      console.log('Hoàn thành tải tất cả chương, bắt đầu tạo EPUB...');
    }
    return isComplete;
  };

  const scheduleNextChapter = () => {
    setTimeout(async () => {
      try {
        await getContent();
      } catch (error) {
        console.error('Lỗi trong setTimeout getContent:', error);
        downloadError('Lỗi không mong muốn', error, true);
      }
    }, downloadState.delay);
  };

  const handleChapterError = (err) => {
    console.error('Lỗi khi tải chương:', err);
    chapterState.current.title = null;

    if (!downloadState.isFinished) {
      downloadError('Kết nối không ổn định', err, true);
    }
  };

  const customDownload = () => {
    const shouldSplitEbook = confirm('Chọn "OK" nếu muốn chia nhỏ ebook');

    if (shouldSplitEbook) {
      handleEbookSplitting();
    } else {
      handleCustomStartChapter();
    }
  };

  const handleEbookSplitting = () => {
    const shouldSplitByChapterCount = confirm('Chọn "OK" nếu muốn chia theo số lượng chương');

    let chaptersPerPart;
    if (shouldSplitByChapterCount) {
      chaptersPerPart = getChaptersPerPart();
    } else {
      chaptersPerPart = getChaptersPerPartByPartCount();
    }

    if (chaptersPerPart > 0) {
      splitChapterList(chaptersPerPart);
    }
  };

  const getChaptersPerPart = () => {
    const input = prompt('Nhập số lượng chương mỗi phần:', 2000);
    return parseInt(input, 10) || 0;
  };

  const getChaptersPerPartByPartCount = () => {
    const input = prompt('Nhập số phần muốn chia nhỏ:', 3);
    const partCount = parseInt(input, 10);
    return partCount > 0 ? Math.floor(chapterState.size / partCount) : 0;
  };

  const splitChapterList = (chaptersPerPart) => {
    partState.list = chunkArray(chapterState.list, chaptersPerPart);
    partState.size = partState.list.length;
    chapterState.list = partState.list[partState.current];
    chapterState.size = chapterState.list.length;
  };

  const handleCustomStartChapter = () => {
    const startChapterId = prompt('Nhập ID chương truyện bắt đầu tải:', chapterState.list[0]);
    const startIndex = chapterState.list.indexOf(startChapterId);

    if (startIndex !== -1) {
      chapterState.list = chapterState.list.slice(startIndex);
      chapterState.size = chapterState.list.length;
    }
  };

  const crawlChapterList = async ($document = $(document), chapterLinks = []) => {
    const $chapterLinks = $document.find('td.text-blue-001 > a');
    if (!$chapterLinks.length) return chapterLinks;
    chapterLinks.push(...$chapterLinks.map((_, link) => $(link).attr('href').trim()));

    const $nextPage = $document.find('[rel="next"]');
    if ($nextPage.length) {
      const nextPageUrl = $nextPage.attr('href').trim();
      console.log('Đang tải danh sách chương:', nextPageUrl);

      const nextPageData = await $.get(nextPageUrl);
      return crawlChapterList($(nextPageData), chapterLinks);
    }
    return chapterLinks;
  };

  const init = async () => {
    if (!chapterState.size) return;
    libs.jepub = new jEpub();
    libs.jepub
      .init({
        title: ebookInfo.title,
        author: ebookInfo.author,
        publisher: locationInfo.host,
        description: ebookInfo.description,
        tags: ebookInfo.genres,
      })
      .uuid(locationInfo.referrer + (partState.size ? '#p' + (partState.current + 1) : ''));

    window.addEventListener('beforeunload', beforeleaving);

    ui.$download.one('click', async (e) => {
      e.preventDefault();
      await saveEbook();
    });

    await getContent();
  };

  // ===== EXECUTION =====
  if (!ui.$description.length) return;

  const $bookData = $('#chapter_001').prev('script');
  if (!$bookData.length) return;
  let bookData = JSON.parse($bookData.text().trim());
  bookData = bookData['@graph']?.find((i) => i['@type'] === 'Book');
  if (!bookData) return;
  ebookInfo.author = bookData.author?.name || null;
  ebookInfo.cover = bookData.image || null;
  ebookInfo.description = ui.$description.html().trim() || null;
  ebookInfo.genres = [bookData.genre || null];

  $('#chapter_001 > div.border > div.text-justify > div').append(ui.$download);
  ui.$download.one('click contextmenu', async (e) => {
    e.preventDefault();
    document.title = '[...] Vui lòng chờ trong giây lát';

    try {
      chapterState.list = await crawlChapterList();
      chapterState.size = chapterState.list.length;

      if (e.type === 'contextmenu') {
        ui.$download.off('click');
        customDownload();
      } else {
        ui.$download.off('contextmenu');
      }

      await init();
    } catch (jqXHR) {
      downloadError(jqXHR.statusText || 'Lỗi tải danh sách chương');
    }
  });
})(jQuery, window, document);