Vozer downloader

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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);