Literotica Downloader

Single page HTML download or EPUB (switch in option menu) for Literotica with improved readability

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

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        Literotica Downloader
// @description Single page HTML download or EPUB (switch in option menu) for Literotica with improved readability
// @namespace   literotica_downloader
// @include     https://tags.literotica.com/*
// @include     https://*.tags.literotica.com/*
// @include     https://www.literotica.com/new/stories*
// @include     https://literotica.com/c/*
// @include     https://**.literotica.com/c/*
// @include		https://www.literotica.com/c/*
// @include		https://www.literotica.com/authors/**/works/stories*
// @require     https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js
// @require     https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @version     6.4
// @grant           GM_info
// @grant           GM_registerMenuCommand
// @grant           GM.registerMenuCommand
// @grant           GM_unregisterMenuCommand
// @grant           GM_openInTab
// @grant           GM_getValue
// @grant           GM.getValue
// @grant           GM_setValue
// @grant           GM.setValue
// @grant           GM_notification
// @grant           GM.notification
// @author      Improved by a random redditor and @nylonmachete, originally by Patrick Kolodziejczyk and fixed by i23234234
// ==/UserScript==
/* jshint esversion: 8 */
// Those valuse can be modifyed by the icon GreaseMonkey > User script commands > Toggle ...
let GMsetValue, GMgetValue, GMregisterMenuCommand, GMunregisterMenuCommand, GMnotification, GMopenInTab;
var options = {
  'isToEPUB': false,
	'isNightMode': true,
	'isNumberChapterInFilename': true,
	'isUsernameInFilename': true,
	'isDescriptionInFilename': false,
	'isNoteInFilename': false,
	'isCategoryInFilename': false,
	'isBookmark': false,
	'fontsize': "2.2"
};

// Creating style for a download icon
var downloadTooltip = 'Download as html';
var bookmarkTooltip = 'Check as read';
var bookmarkCheckTooltip = 'Un-check as read';
var iconDonwload = '<i class="bi bi-arrow-down-circle" style="cursor: pointer;cursor: hand;margin-right:5px; font-size:14px"></i>';
var iconBookmark = '<i class="bi bi-bookmark" style="cursor: pointer;cursor: hand;margin-right:5px; font-size:14px"></i>';
var iconBookmarkCheck = '<i class="bi bi-bookmark-check-fill" style="cursor: pointer;cursor: hand;margin-right:5px; font-size:14px"></i>';
var PREFIX_URL_PAGE = 'https://www.literotica.com';
/**
 * Style HTML downloaded
 */
var chapterStyle = '_style="line-height: 1.4em;" ';
var descriptionStyle = ' style="line-height: 1.2em;" ';

/*
 * Constante for Filename
 */
var SEPARATOR = '_';
var PREFIX_NOTE = 'RATING_';
var POSTFIX_NUMER_CHAPTER = '_Part_Series';

/*
 * Constant For logging
 */
var PREFIX_LOG = '[Literotica Downloader]';

function calculateBodyStyle() {
	var toReturn = " style=\"";
	if (options.isNightMode) {
		toReturn += "background-color:#333333; color: #EEEEEE;";
	}
	toReturn += "font-family: Helvetica,Arial,sans-serif; width: 50%; margin: 0 auto; line-height: 1.5em;";
	toReturn += "font-size:" + options.fontsize + "em; padding: 50px 0 50px 0;";
	toReturn += "\"";
	return toReturn;
}


// Create link elements
const link1 = document.createElement('link');
link1.href = 'https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css';
link1.rel = 'stylesheet';
link1.type = 'text/css';

const link2 = document.createElement('link');
link2.href = 'https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css';
link2.rel = 'stylesheet';

const styleElement = document.createElement('style');

// Set the type attribute
styleElement.type = 'text/css';

// Set the CSS rules
styleElement.textContent = '.ldText{background-color: lightgray;width: 3em;},.ldChapter,.ldSerie {margin-left: auto;} ._works_wrapper_29o2p_1 ._works_item__series_expanded_parts_29o2p_40{margin-left: 0px !important} .lbIconOnStoryCardComponent {float:left}';

// Append link elements to the head
document.head.appendChild(link1);
document.head.appendChild(link2);
document.head.appendChild(styleElement);

console.log("Literotica Downloader start addEventListener");

window.addEventListener("load", function() {
	'use strict';
	setTimeout(function() {
		const GMinfo = GM_info;
		const handlerInfo = GMinfo.scriptHandler;
		const isGM = Boolean(handlerInfo.toLowerCase() === 'greasemonkey');
		var panelOptionDownloader = `
<div id="literoticaDownloaderOption" title="Options Literotica Downloader" >
   Please Configure the options :
   <fieldset>
	  <legend>Filename: </legend>
	  <div>
		 <input type="checkbox" name="isToEPUB" id="isToEPUB" class="ldCheckbox">
		 <span>Export to Epub instead of HTML</span>
	  </div>
   </fieldset>
	<fieldset>
	  <legend>Interface: </legend>
			<div>
		<input type="checkbox" name="isBookmark" id="isBookmark" class="ldCheckbox">
		  <span>Bookmark (locale storage)</span>
	</div>
<div>Please refresh the page for thoses options</div>
	</fieldset>
	<fieldset>
	  <legend>Reading: </legend>
			<div>
		<input type="checkbox" name="isNightMode" id="isNightMode" class="ldCheckbox">
		  <span>Night Mode</span>
	</div>
	</fieldset>
   <fieldset>
	  <legend>Filename: </legend>
	  <div>
		 <input type="checkbox" name="isNumberChapterInFilename" id="isNumberChapterInFilename" class="ldCheckbox">
		 <span>Number of chapters in filename (for series)</span>
	  </div>
	  <div>
		 <input type="checkbox" name="isUsernameInFilename" id="isUsernameInFilename" class="ldCheckbox">
		 <span>Username in filename</span>
	  </div>
	  <div>
		 <input type="checkbox" name="isDescriptionInFilename" id="isDescriptionInFilename" class="ldCheckbox">
		 <span>Description in filename (Not on series)</span>
	  </div>
	  <div>
		 <input type="checkbox" name="isNoteInFilename" id="isNoteInFilename" class="ldCheckbox">
		 <span>Rating in filename (Not on series)</span>
	  </div>
	  <div>
		 <input type="checkbox" name="isCategoryInFilename" id="isCategoryInFilename" class="ldCheckbox">
		 <span>Category in filename (Not on series)</span>
	  </div>
	  <div>
		 <span>FontSize (in em  with . for decimal)</span>
		 <input type="text" name="fontsize" id="fontsize" class="ldText">
	  </div>
   </fieldset>
</div>
`;
		document.body.insertAdjacentHTML('beforeend', panelOptionDownloader);
		var ldCheckboxes = document.querySelectorAll('.ldCheckbox');

		ldCheckboxes.forEach(function(checkbox) {
			checkbox.addEventListener('change', async function() {
				console.log(this.id);
				GMsetValue(this.id, this.checked);
				console.log(PREFIX_LOG + 'new value =>' + await GMgetValue(this.id));
				options[this.id] = this.checked;
			});
		});
		var ldInputTexts = document.querySelectorAll('.ldText');

		ldInputTexts.forEach(function(checkbox) {
			checkbox.addEventListener('change', async function() {
				console.log(this.id);
				GMsetValue(this.id, this.value);
				console.log('new value =>' + await GMgetValue(this.id));
				options[this.id] = this.value;
				console.log(options);
			});
		});
		console.log(PREFIX_LOG + "Start init dialog option");
		$("#literoticaDownloaderOption").dialog({
			autoOpen: false,
			width: 450
		});
		console.log(PREFIX_LOG + "Snd init dialog option");
		if (isGM) {
			GMsetValue = GM.setValue;
			GMgetValue = GM.getValue;
			GMregisterMenuCommand = GM.registerMenuCommand;
			GMunregisterMenuCommand = function() {};
			GMnotification = GM.notification;
			console.log(PREFIX_LOG + "It's GM !");
		} else {
			console.log(PREFIX_LOG + "Other than GM...");

			GMsetValue = GM_setValue;
			GMgetValue = GM_getValue;
			GMregisterMenuCommand = GM_registerMenuCommand;
			GMunregisterMenuCommand = GM_unregisterMenuCommand;
		}
		initOptions();

		// Open dialog when the menu command is clicked
		GMregisterMenuCommand("Literotica Downloader Options", function() {
			$("#literoticaDownloaderOption").dialog("open");
		});

		// Dialog setup
		var dialog = document.getElementById('literoticaDownloaderOption');
		dialog.style.display = 'none'; // Initially hide the dialog

		/*
		 * Download Button for Chapter
		 */

		addDownloadChapterButtonTo(document);

		/*
		 * Download Button for Series
		 */
		addDownloadSeriesButtonTo(document);

		/*
		 * Download Button for individual Story
		 */
		addDownloadIndividualStoryButtonTo(document);

		/*
		 * Section download on tags
		 */
		document.addEventListener('DOMNodeInserted', function(e) {
			if (e.target != null && e.target.classList != null && e.target.classList.contains('ai_gJ')) {
				var target = e.target;

				var ai_iGElements = target.querySelectorAll('.ai_iG');
				if (ai_iGElements) {
					ai_iGElements.forEach(function(element) {
						element.insertAdjacentHTML('afterbegin', '<span class="ldDownloadTag lbIconOnStoryCardComponent">' + iconDonwload + '</span>');
					});

					target.querySelectorAll('.ldDownloadTag.lbIconOnStoryCardComponent')
						.forEach(function(tag) {
							tag.addEventListener('click', function() {
								getABookForStoryOnStoryCardComponent(tag.parentNode.parentNode);
							});
						});
				}
			}
		});
		/*
		 * Section download on category
		 */
		console.log(PREFIX_LOG + "init category");
		document.querySelectorAll('.b-slb-item').forEach(function(element) {
			element.insertAdjacentHTML('afterbegin', '<span class="ldChapter lbIconOnStoryCardComponent">' + iconDonwload + '</span>');
		});
		document.querySelectorAll(".ldChapter.lbIconOnStoryCardComponent").forEach(function(tag) {
			tag.addEventListener('click', function(tag) {
				getABookForStoryItemCategory(tag.target.parentNode.parentNode);
			});
		});
	}, 250); // 1000 milliseconds = 1 second
});

function addDownloadSeriesButtonTo(parentElement) {
	const titlesSeries = parentElement.querySelectorAll("[class*='_series_head_expanded_'] [class*='_title_'] [class*='_title_link_']");
  console.log("addDownloadSeriesButtonTo");
  console.log(titlesSeries);
	titlesSeries.forEach(element => {
		// Create a new element to prepend
		const newElement = document.createElement('div');
		newElement.textContent = 'Prepended Element'; // Change this to whatever content you want
		var idIcon = Math.floor(Math.random() * 1000);
		// Prepend the new element before the selected element
		element.insertAdjacentHTML('beforebegin', '<span id="' + idIcon + '" class="ldSerie"> ' + iconDonwload + '</span>');
		element.classList.add("lbButtonAdded");
	});

	parentElement.querySelectorAll('.ldSerie').forEach(element => {
		element.addEventListener('click', function() {
			getABookForSerieDiv(element.parentNode.parentNode.parentNode)
		});
	});
}

function addDownloadChapterButtonTo(parentElement) {
	const titlesChapter = parentElement.querySelectorAll("[class*='_part_card_'] [class*='_title_'] [class*=' _title_']");
  console.log("addDownloadChapterButtonTo");
  console.log(titlesChapter);
	titlesChapter.forEach(element => {
		// Create a new element to prepend
		const newElement = document.createElement('div');
		newElement.textContent = 'Prepended Element'; // Change this to whatever content you want
		var idIcon = Math.floor(Math.random() * 1000);
		// Prepend the new element before the selected element
		element.insertAdjacentHTML('beforebegin', '<span id="' + idIcon + '" class="ldChapter"> ' + iconDonwload + '</span>');
		element.classList.add("lbButtonAdded");
	});

	parentElement.querySelectorAll('.ldChapter').forEach(element => {
		element.addEventListener('click', function() {
			getABookForStoryDiv(element.parentNode)
		});
	});
}

function addDownloadIndividualStoryButtonTo(parentElement) {
	const titlesChapter = parentElement.querySelectorAll("[class*='_content_'] [class*='_title_link_']:not(.lbButtonAdded)");
  console.log("addDownloadIndividualStoryButtonTo");
  console.log(titlesChapter);
	titlesChapter.forEach(element => {
		// Create a new element to prepend
		const newElement = document.createElement('div');
		newElement.textContent = 'Prepended Element'; // Change this to whatever content you want
		var idIcon = Math.floor(Math.random() * 1000);
		// Prepend the new element before the selected element
		element.insertAdjacentHTML('beforebegin', '<span id="' + idIcon + '" class="ldChapter"> ' + iconDonwload + '</span>');
		element.classList.add("lbButtonAdded");
	});

	parentElement.querySelectorAll('.ldChapter').forEach(element => {
		element.addEventListener('click', function() {
			getABookForStoryDiv(element.parentNode)
		});
	});
}

async function initOptions() {
	// Init of all options
	for (const element of Object.getOwnPropertyNames(options)) {
		await initCheckBox(element);
		await initInputText(element);
	}
}
async function initCheckBox(element, index, array) {
	options[element] = await GMgetValue(element, options[element]);
	$("#" + element)[0].checked = options[element];

	return true;
}
async function initInputText(element, index, array) {
	options[element] = await GMgetValue(element, options[element]);
	$("#" + element)[0].value = options[element];
	console.log(PREFIX_LOG + element + "-> " + options[element]);
	return true;
}

function getABookForStoryItemCategory(cardComponent) {
	console.log(PREFIX_LOG + "In getABookForStoryItemCategory");
	console.log(cardComponent);
	var link = cardComponent.querySelector('h3 a').getAttribute('href');
	var storyId = extractStoryId(link);
	var jsonStory = getJsonStoryByStoryId(extractStoryId(link));
	console.log(jsonStory);
	var title = jsonStory.submission.title;
	var note = jsonStory.submission.rate_all;
	var chapterTitle = jsonStory.submission.title;
	var description = jsonStory.submission.description;
	var category = jsonStory.submission.category_info.pageUrl;
	var author = jsonStory.submission.author.username;
	getABookForStory(storyId, title, note, chapterTitle, description, category, author);
}

function getABookForStoryOnStoryCardComponent(cardComponent) {
	console.log(PREFIX_LOG + "In getABookForStoryOnStoryCardComponent");
	console.log(cardComponent);
	var link = cardComponent.querySelector('.ai_ii').getAttribute('href');
	var storyId = extractStoryId(link);
	var jsonStory = getJsonStoryByStoryId(extractStoryId(link));
	var title = jsonStory.submission.title;
	var note = jsonStory.submission.rate_all;
	var chapterTitle = jsonStory.submission.title;
	var description = jsonStory.submission.description;
	var category = jsonStory.submission.category_info.pageUrl;
	var author = jsonStory.submission.author.username;
	getABookForStory(storyId, title, note, chapterTitle, description, category, author);
}

function openFullViewForDownloadSerie() {
	var moreExist = false;
	document.querySelectorAll("[class*='_show_more']").forEach(element => {
		if (element.innerText.trim().startsWith('View Full')) {
			moreExist = true;
			console.log(PREFIX_LOG + "Opening " + element);
			element.click();
		}
	});
	if (moreExist) {
		setTimeout(function() {
			openFullViewForDownloadSerie();
		}, 250);
	}
}

function extractSerieId(url) {
	// Split the URL by slashes
	const parts = url.split('/');
	// Get the last part of the array
	const lastPart = parts[parts.length - 1];
	return lastPart;
}

function getSerieLinkFormDiv(serieDiv) {
	return serieDiv.querySelectorAll("[class*=_title_link_]")[0].href;
}

function getABookForSerieDiv(serieDiv) {
	console.log(PREFIX_LOG + "Processing series");
	console.log(serieDiv);
	openFullViewForDownloadSerie();
	var link = getSerieLinkFormDiv(serieDiv);
	console.log(link)
	var serieId = extractSerieId(link);
	var jsonSerie = getJsonSerieBySerieId(serieId).data;
	console.log(serieDiv.nextElementSibling);
	var allChapter = serieDiv.nextElementSibling.querySelectorAll("[class*='_part_card_'] h3[class*='_title_']");
	var lastChapter = allChapter[allChapter.length - 1];
	console.log(lastChapter);
	var linkLastChapter = getChapterLinkFormDiv(lastChapter);
	console.log(PREFIX_LOG + "linkLastChapter => " + linkLastChapter);
	var storyId = extractStoryId(linkLastChapter);
	var jsonLastChapter = getJsonStoryByStoryId(storyId);
  if (jsonLastChapter.submission.series.items == null) {
    console.log(PREFIX_LOG + "Last Chapter doesn't have series informations");
		console.log(PREFIX_LOG + "Falback on previous one");
		console.log(allChapter);
		lastChapter = allChapter[allChapter.length - 2];
		console.log(lastChapter);
		linkLastChapter = getChapterLinkFormDiv(lastChapter);
		console.log(PREFIX_LOG + "linkLastChapter => " + linkLastChapter);
		storyId = extractStoryId(linkLastChapter);
    jsonLastChapter = getJsonStoryByStoryId(storyId);
  }
  if (options.isToEPUB) {
    downloadSerieAsEPUBFromJsonAndDiv(jsonSerie, jsonLastChapter, allChapter);
  }else {
		downloadSerieAsHTMLFromJsonAndDiv(jsonLastChapter, allChapter);
  }
	
}

function downloadSerieAsHTMLFromJsonAndDiv(jsonLastChapter, allChapter) {
	var title = jsonLastChapter.submission.series.meta.title;
	// Get the X Part Series
	var descriptionSeries = ""
	var author = jsonLastChapter.submission.author.username;
	var numberChapter = jsonLastChapter.submission.series.meta.order.length;
	alert("Starting building file for " + title + " of " + author + " with " + numberChapter + ".\nPlease wait...");

	var book = '<html>\n<head>\n<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">\n';
	book += '<title>' + title + '</title>';
	book += '<meta content="' + author + '" name="author">';
	book += '</head>\n<body ' + calculateBodyStyle() + ' >';

	function addChapter(itemChapter) {
		console.log(PREFIX_LOG + "addChapter" + itemChapter);
		var storyId = itemChapter.url;
		var jsonStory = getJsonStoryByStoryId(storyId);
		console.log(jsonStory);
		var chapterTitle = jsonStory.submission.title;
		var description = jsonStory.submission.description;
		book += '<h1 class=\'chapter\'' + chapterStyle + '>' + chapterTitle + '</h1>';
		book += '<h2 class=\'chapter\'' + descriptionStyle + '>' + description + '</h2>';
		book += getContentOfStory(storyId);
	}
  jsonLastChapter.submission.series.items.forEach(element => {
    console.log(element);
    // Your logic to perform on each element
    addChapter(element);
  });
	saveTextAsFile(book, buildFilename(title, author, descriptionSeries, null, null, numberChapter));
}

function getChapterLinkFormDiv(storyDiv) {
	return storyDiv.querySelectorAll("[class*='_title_link_']")[0].href;
}

function getABookForStoryDiv(storyDiv) {
	console.log(PREFIX_LOG + "Processing getABookForStoryDiv");
	console.log(storyDiv);
	var link = getChapterLinkFormDiv(storyDiv);
	var storyId = extractStoryId(link);
	var jsonStory = getJsonStoryByStoryId(extractStoryId(link));
	var title = jsonStory.submission.title;
	var note = jsonStory.submission.rate_all;
	var chapterTitle = jsonStory.submission.title;
	var description = jsonStory.submission.description;
	var category = jsonStory.submission.category_info.pageUrl;
	var author = jsonStory.submission.author.username;
  if (options.isToEPUB) {
  	downloadStoryAsEPUBFromJsonAndDiv(jsonStory);
  } else {
		getABookForStory(storyId, title, note, chapterTitle, description, category, author);
  }
}

function getABookForStory(storyId, title, note, chapterTitle, description, category, author) {
	var book = '<html>\n<head>\n<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">\n';
	console.log(PREFIX_LOG + 'title' + title);
	book += '<title>' + title + '</title>';
	book += '<meta content="' + author + '" name="author">';
	book += '</head>\n<body ' + calculateBodyStyle() + ' >';
	if (chapterTitle != null) {
		book += '<h1 class=\'chapter\'' + chapterStyle + '>' + chapterTitle + '</h1>';
	}
	if (description != null) {
		book += '<h2 class=\'chapter\'' + descriptionStyle + '>' + description + '</h2>';
	}
	book += getContentOfStory(storyId);

	saveTextAsFile(book, buildFilename(title, author, description, note, category, null));
}

function extractStoryId(url) {
	// Split the URL by slashes
	const parts = url.split('/');
	// Get the last part of the array
	const lastPart = parts[parts.length - 1];
	return lastPart;
}

function getContentOfStoryOfPage(storyId, current_page) {
	var apiURL = "https://literotica.com/api/3/stories/" + storyId + '?params=%7B"contentPage"%3A' + current_page + '%7D';
	var xhr = new XMLHttpRequest();
	var toReturn = "[Page " + current_page + " not Found]";
	xhr.open('GET', apiURL, false); // false makes the request synchronous
	xhr.onload = function() {
		if (xhr.status >= 200 && xhr.status < 300) {
			const response = JSON.parse(xhr.responseText);
			toReturn = response.pageText;
		} else {
			console.error(PREFIX_LOG + 'Request failed with status ' + xhr.status);
		}
	};
	xhr.onerror = function() {
		console.error(PREFIX_LOG + 'Request failed');
	};
	xhr.send();
	return toReturn;
}

function getJsonSerieBySerieId(serieId) {
	var apiURL = "https://literotica.com/api/3/series/" + serieId;
	console.log(PREFIX_LOG + "Fetching on API -> " + apiURL);
	var toReturn;
	var xhr = new XMLHttpRequest();
	xhr.open('GET', apiURL, false); // false makes the request synchronous
	xhr.onload = function() {
		if (xhr.status >= 200 && xhr.status < 300) {
			toReturn = JSON.parse(xhr.responseText);
			console.log(toReturn);
		} else {
			console.error(PREFIX_LOG + 'Request failed with status ' + xhr.status);
		}
	};
	xhr.onerror = function() {
		console.error(PREFIX_LOG + 'Request failed');
	};
	xhr.send();
	return toReturn;
}

function getJsonStoryByStoryId(storyId) {
	var apiURL = "https://literotica.com/api/3/stories/" + storyId;
	console.log(PREFIX_LOG + "Fetching on API -> " + apiURL);
	var toReturn;
	var xhr = new XMLHttpRequest();
	xhr.open('GET', apiURL, false); // false makes the request synchronous
	xhr.onload = function() {
		if (xhr.status >= 200 && xhr.status < 300) {
			toReturn = JSON.parse(xhr.responseText);
			console.log(toReturn);
		} else {
			console.error(PREFIX_LOG + 'Request failed with status ' + xhr.status);
		}
	};
	xhr.onerror = function() {
		console.error(PREFIX_LOG + 'Request failed');
	};
	xhr.send();
	return toReturn;
}

function convertTextToHTML(text) {
	// Replace CRLF with <br> tags
	var htmlText = text.replace(/\r\n/g, "<br>");
	// Replace LF with <br> tags
	htmlText = htmlText.replace(/\n/g, "<br>");
	// Replace CR with <br> tags
	htmlText = htmlText.replace(/\r/g, "<br>");
	return htmlText;
}

// Function parsing all pages to get the storie based
function getContentOfStory(storyId) {
	console.log(PREFIX_LOG + "Processing  -> " + storyId);
	var apiURL = "https://literotica.com/api/3/stories/" + storyId;
	console.log(PREFIX_LOG + "Fetching on API -> " + apiURL);
	var toReturn;
	var xhr = new XMLHttpRequest();
	xhr.open('GET', apiURL, false); // false makes the request synchronous
	xhr.onload = function() {
		if (xhr.status >= 200 && xhr.status < 300) {
			const response = JSON.parse(xhr.responseText);
			toReturn = response.pageText;
			var pageCount = response.meta.pages_count;
			console.log(PREFIX_LOG + "pageCount => " + pageCount);
			if (pageCount > 0) {
				for (let currentPage = 2; currentPage <= pageCount; currentPage++) {
					const result = getContentOfStoryOfPage(storyId, currentPage); // Call the given function
					toReturn += result; // Concatenate the result to toReturn
				}
			}
		} else {
			console.error(PREFIX_LOG + 'Request failed with status ' + xhr.status);
		}
	};
	xhr.onerror = function() {
		console.error(PREFIX_LOG + 'Request failed');
	};
	xhr.send();
	toReturn = convertTextToHTML(toReturn);
	return toReturn;
}
// Function used to return content as a file for the user.
function saveTextAsFile(textToWrite, fileNameToSaveAs) {
	var textFileAsBlob = new Blob([textToWrite], {
		type: 'text/javascript'
	});
	var downloadLink = document.createElement('a');
	downloadLink.download = fileNameToSaveAs;
	downloadLink.innerHTML = 'Download File';
	// Firefox requires the link to be added to the DOM
	// before it can be clicked.
	downloadLink.href = window.URL.createObjectURL(textFileAsBlob);
	//downloadLink.onclick = destroyClickedElement;
	downloadLink.style.display = 'none';
	document.body.appendChild(downloadLink);
	downloadLink.click();
}

function buildFilename(title, author, description, note, category, numberChapter) {
	var toReturn = title;
	if (options.isNumberChapterInFilename) {
		toReturn = toReturn + SEPARATOR + numberChapter + POSTFIX_NUMER_CHAPTER;
	}
	if (options.isUsernameInFilename) {
		toReturn = toReturn + SEPARATOR + author;
	}
	if (options.isDescriptionInFilename) {
		if (description != null && description != "") {
			toReturn = toReturn + SEPARATOR + description.replace(/[^\w\s]/gi, '');
		}
	}
	if (options.isNoteInFilename) {
		if (note != null && note != "") {
			toReturn = toReturn + SEPARATOR + PREFIX_NOTE + note;
		}
	}
	if (options.isCategoryInFilename) {
		if (category != null && category != "") {
			toReturn = toReturn + SEPARATOR + category;
		}
	}
	// Add file extension;
	toReturn = toReturn + '.html';
	return toReturn;
}
function buildFilenameEpub(title, author, description, note, category, numberChapter) {
	var toReturn = title;
	if (options.isNumberChapterInFilename) {
		toReturn = toReturn + SEPARATOR + numberChapter + POSTFIX_NUMER_CHAPTER;
	}
	if (options.isUsernameInFilename) {
		toReturn = toReturn + SEPARATOR + author;
	}
	if (options.isDescriptionInFilename) {
		if (description != null && description != "") {
			toReturn = toReturn + SEPARATOR + description.replace(/[^\w\s]/gi, '');
		}
	}
	if (options.isNoteInFilename) {
		if (note != null && note != "") {
			toReturn = toReturn + SEPARATOR + PREFIX_NOTE + note;
		}
	}
	if (options.isCategoryInFilename) {
		if (category != null && category != "") {
			toReturn = toReturn + SEPARATOR + category;
		}
	}
	// Add file extension;
	toReturn = toReturn + '.epub';
	return toReturn;
}

function hasNodeClassStartingWith(node, startclass) {
	const classList = node.classList;

	for (let i = 0; i < classList.length; i++) {
		if (classList[i].startsWith(startclass)) {
			return true;
		}
	}

	return false;
}

// Callback function to be called when an element is added to the DOM
function handleElementAdded(mutationsList, observer) {

	for (var mutation of mutationsList) {
		if (mutation.type === 'childList') {
			// Check if nodes were added

			mutation.addedNodes.forEach(function(node) {
				if (node.nodeType === 1 &&
					(hasNodeClassStartingWith(node, '_part_row_') ||
						hasNodeClassStartingWith(node, '_card_') ||
						hasNodeClassStartingWith(node, '_content_') ||
						hasNodeClassStartingWith(node, 'container_2025')

					)
				) {
					addDownloadChapterButtonTo(node);
					addDownloadSeriesButtonTo(node);
					addDownloadIndividualStoryButtonTo(node);
					// Call your function here or do any other necessary action
				}
			});
		}
	}
}

// Create a MutationObserver instance
var observer = new MutationObserver(handleElementAdded);

// Define the options for the observer (in this case, we're observing changes to the child list)
var observerConfig = {
	childList: true,
	subtree: true // This option allows us to observe changes within the entire subtree of the target node
};

// Start observing the target node (in this example, we're observing the entire document)
observer.observe(document, observerConfig);

function generateUUID() {
      return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
        const r = Math.random() * 16 | 0;
        const v = c === 'x' ? r : (r & 0x3 | 0x8);
        return v.toString(16);
      });
    }

async function downloadSerieAsEPUBFromJsonAndDiv(jsonSerie, jsonLastChapter, allChapter) {
  console.log(PREFIX_LOG+"[jsonSerie]");console.log(jsonSerie);
	var title = jsonSerie.title;
	// Get the X Part Series
	var author = jsonLastChapter.submission.author.username;
	var numberChapter = jsonLastChapter.submission.series.meta.order.length;
	alert("Starting building EPUB for " + title + " of " + author + " with " + numberChapter + ".\nPlease wait...");
	// ============================================================
	// SECTION E.1 Static part
	// ============================================================
	// ============================================================
	// SECTION E.1.a Zip
	// ============================================================
	var zip = new JSZip();
	// ============================================================
	// SECTION E.1.b mimetype (Required: uncompressed mimetype)
	// ============================================================
	zip.file("mimetype", "application/epub+zip", {compression: "STORE"});
	// ============================================================
	// SECTION E.1.c META-INF\container.xml
	// ============================================================
	zip.folder("META-INF").file("container.xml",
		`<?xml version="1.0"?>
<container version="1.0"
xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
  <rootfiles>
    <rootfile
      full-path="OEBPS/content.opf"
      media-type="application/oebps-package+xml"/>
  </rootfiles>
</container>`
	);
	// ============================================================
	// SECTION E.2 Global part
	// ============================================================
	var folderOEBPS = zip.folder("OEBPS");
	// SECTION E.2 cover.xhtml
	var coverContent = 
`<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <title>[[title]]</title>
<!-- optional style-->
</head>
<body>
  <div class="cover">
    <h1>[[title]]</h1>
    
    <p class="desc">[[description]]</p>
  </div>
</body>
</html>`;
	coverContent = coverContent.replaceAll("[[title]]", jsonSerie.title!=null ? jsonSerie.title: "");
	coverContent = coverContent.replaceAll("[[description]]", jsonSerie.description!=null ? jsonSerie.description: "");
	folderOEBPS.file("cover.xhtml", coverContent);
	// SECTION E.2 toc.ncx (Table of Contents)
	var tocContent = 
`<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
  <head>
    <meta name="dtb:uid" content="urn:uuid:[[uid]]"/>
    <meta name="dtb:depth" content="1"/>
    <meta name="dtb:totalPageCount" content="0"/>
    <meta name="dtb:maxPageNumber" content="0"/>
  </head>
  <docTitle><text>[[title]]</text></docTitle>
  <navMap>
    <navPoint id="cover" playOrder="1"><navLabel><text>Cover</text></navLabel><content src="cover.xhtml"/></navPoint>
	[[allFragments]]
  </navMap>
</ncx>`;
	var fragmentTOC = `<navPoint id="[[chapter-id]]" playOrder="[[order]]"><navLabel><text>[[chapter-title]]</text></navLabel><content src="[[chapter-id]].xhtml"/></navPoint>`;
	var allFragments ="";
  jsonLastChapter.submission.series.items.forEach((value, index) => {
		currentFragment = fragmentTOC.replaceAll("[[chapter-id]]", "chapter_"+index+1);
		currentFragment = currentFragment.replaceAll("[[order]]", index+2);
		allFragments+=currentFragment;
	});
	tocContent = tocContent.replaceAll("[[title]]", jsonSerie.title!=null ? jsonSerie.title: "");
	tocContent = tocContent.replaceAll("[[uid]]", generateUUID());
	tocContent = tocContent.replaceAll("[[allFragments]]", allFragments);
	folderOEBPS.file("toc.ncx", tocContent);
	// ============================================================
	// SECTION E.2 content.opf (Table of Contents)
	// ============================================================
	opfContent =`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId" version="2.0">
  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
    <dc:identifier id="BookId">urn:uuid:c065e548-d835-4f91-b4eb-fc918a7e2639</dc:identifier>
    <dc:title>[[title]]</dc:title>
    <dc:creator opf:role="aut">[[author]]</dc:creator>
    <dc:subject>[[subject]]</dc:subject>
    <dc:description>[[description]]</dc:description>
    <dc:publisher>Literotica Downloader</dc:publisher>
    <dc:date>[[date]]</dc:date>
    <dc:language>en</dc:language>
    <dc:source>[[source]]</dc:source>
    <meta name="cover" content="cover"/>
  </metadata>
  <manifest>
    <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
    <item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>
	[[allFragmentsManifest]]
  </manifest>
  <spine toc="ncx">
    <itemref idref="cover"/>
    [[allFragmentsSpine]]
  </spine>
</package>`;
	var fragmentManifestOpf = `<<item id="[[chapter-id]]" href="[[chapter-id]].xhtml" media-type="application/xhtml+xml"/>`;
	var fragmentSpineOpf = `<itemref idref="[[chapter-id]]"/>`;
	var allFragmentsManifest ="";
	var allFragmentsSpine ="";
	jsonLastChapter.submission.series.items.forEach((value, index) => {
		allFragmentsManifest+= fragmentManifestOpf.replaceAll("[[chapter-id]]", "chapter_"+index+1);
		allFragmentsSpine+= fragmentSpineOpf.replaceAll("[[chapter-id]]", "chapter_"+index+1);
	});
	opfContent = opfContent.replaceAll("[[title]]", jsonSerie.title!=null ? jsonSerie.title: "");
	opfContent = opfContent.replaceAll("[[author]]", jsonLastChapter.submission.author.username);
	opfContent = opfContent.replaceAll("[[subject]]", jsonLastChapter.submission.category_info.pageUrl);
	opfContent = opfContent.replaceAll("[[description]]", jsonSerie.description!=null ? jsonSerie.description: "");
	opfContent = opfContent.replaceAll("[[author]]", jsonLastChapter.submission.author.username);
	opfContent = opfContent.replaceAll("[[date]]", new Date().toISOString().split('T')[0]);
	opfContent = opfContent.replaceAll("[[source]]", "literotica.com");
	opfContent = opfContent.replaceAll("[[allFragmentsManifest]]", allFragmentsManifest);
	opfContent = opfContent.replaceAll("[[allFragmentsSpine]]", allFragmentsSpine);
	folderOEBPS.file("content.opf", opfContent);
	// ============================================================
	// SECTION E.3 chapter(s) chapter-XXX.xhtml
	// ============================================================
	jsonLastChapter.submission.series.items.forEach((value, index) => {
		var chapterId = "chapter_"+index+1;
		var chapterContent = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <title>[[chapter-title]]</title>
  <!-- optional style-->
</head>
<body>
<h1>[[chapter-title]]</h1>
<h2>[[chapter-description]]</h2>
<h3>[[chapter-tags]]</h3>
  [[chapter-content]]
</body>
</html>`;
		console.log(PREFIX_LOG + "addChapter" + value);
		var storyId = value.url;
		var jsonStory = getJsonStoryByStoryId(storyId);
		console.log(jsonStory);
		chapterContent = chapterContent.replaceAll("[[chapter-title]]",value.title);
		chapterContent = chapterContent.replaceAll("[[chapter-description]]",jsonStory.submission.description);
    chapterContent = chapterContent.replaceAll("[[chapter-tags]]","Tags : "+jsonStory.submission.tags.map(item => item.tag).join(", "));
		chapterContent = chapterContent.replaceAll("[[chapter-content]]",getContentOfStory(storyId));
		folderOEBPS.file(chapterId+".xhtml",chapterContent);
	});
	

	try {
		console.log(PREFIX_LOG+"generateAsync");
		const blob = await zip.generateAsync({
			type: "blob",
			mimeType: "application/epub+zip"
		});
		console.log(blob);
		const url = URL.createObjectURL(blob);

		const a = document.createElement("a");
		a.href = url;
		a.download = buildFilenameEpub(title, author, null, null, null, numberChapter);
		a.click();
	} catch (e) {
		console.error(e);
	}
}

async function downloadStoryAsEPUBFromJsonAndDiv(jsonStory) {
  console.log(PREFIX_LOG+"jsonStory]");console.log(jsonStory);
	var title = jsonStory.submission.title;
	// Get the X Part Series
	var author = jsonStory.submission.author.username;
	// ============================================================
	// SECTION E.1 Static part
	// ============================================================
	// ============================================================
	// SECTION E.1.a Zip
	// ============================================================
	var zip = new JSZip();
	// ============================================================
	// SECTION E.1.b mimetype (Required: uncompressed mimetype)
	// ============================================================
	zip.file("mimetype", "application/epub+zip", {compression: "STORE"});
	// ============================================================
	// SECTION E.1.c META-INF\container.xml
	// ============================================================
	zip.folder("META-INF").file("container.xml",
		`<?xml version="1.0"?>
<container version="1.0"
xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
  <rootfiles>
    <rootfile
      full-path="OEBPS/content.opf"
      media-type="application/oebps-package+xml"/>
  </rootfiles>
</container>`
	);
	// ============================================================
	// SECTION E.2 Global part
	// ============================================================
	var folderOEBPS = zip.folder("OEBPS");
	// SECTION E.2 cover.xhtml
	var coverContent = 
`<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <title>[[title]]</title>
<!-- optional style-->
</head>
<body>
  <div class="cover">
    <h1>[[title]]</h1>
    
    <p class="desc">[[description]]</p>
  </div>
</body>
</html>`;
	coverContent = coverContent.replaceAll("[[title]]", jsonStory.submission.title!=null ? jsonStory.submission.title: "");
	coverContent = coverContent.replaceAll("[[description]]", jsonStory.submission.description!=null ? jsonStory.submission.description: "");
	folderOEBPS.file("cover.xhtml", coverContent);
	// SECTION E.2 toc.ncx (Table of Contents)
	var tocContent = 
`<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
  <head>
    <meta name="dtb:uid" content="urn:uuid:[[uid]]"/>
    <meta name="dtb:depth" content="1"/>
    <meta name="dtb:totalPageCount" content="0"/>
    <meta name="dtb:maxPageNumber" content="0"/>
  </head>
  <docTitle><text>[[title]]</text></docTitle>
  <navMap>
    <navPoint id="cover" playOrder="1"><navLabel><text>Cover</text></navLabel><content src="cover.xhtml"/></navPoint>
	[[allFragments]]
  </navMap>
</ncx>`;
	var fragmentTOC = `<navPoint id="[[chapter-id]]" playOrder="[[order]]"><navLabel><text>[[chapter-title]]</text></navLabel><content src="[[chapter-id]].xhtml"/></navPoint>`;
	var allFragments ="";
		currentFragment = fragmentTOC.replaceAll("[[chapter-id]]", "chapter_"+1);
		currentFragment = currentFragment.replaceAll("[[order]]", 2);
		allFragments+=currentFragment;
	tocContent = tocContent.replaceAll("[[title]]", jsonStory.submission.title!=null ? jsonStory.submission.title: "");
	tocContent = tocContent.replaceAll("[[uid]]", generateUUID());
	tocContent = tocContent.replaceAll("[[allFragments]]", allFragments);
	folderOEBPS.file("toc.ncx", tocContent);
	// ============================================================
	// SECTION E.2 content.opf (Table of Contents)
	// ============================================================
	opfContent =`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId" version="2.0">
  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
    <dc:identifier id="BookId">urn:uuid:c065e548-d835-4f91-b4eb-fc918a7e2639</dc:identifier>
    <dc:title>[[title]]</dc:title>
    <dc:creator opf:role="aut">[[author]]</dc:creator>
    <dc:subject>[[subject]]</dc:subject>
    <dc:description>[[description]]</dc:description>
    <dc:publisher>Literotica Downloader</dc:publisher>
    <dc:date>[[date]]</dc:date>
    <dc:language>en</dc:language>
    <dc:source>[[source]]</dc:source>
    <meta name="cover" content="cover"/>
  </metadata>
  <manifest>
    <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
    <item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>
	[[allFragmentsManifest]]
  </manifest>
  <spine toc="ncx">
    <itemref idref="cover"/>
    [[allFragmentsSpine]]
  </spine>
</package>`;
	var fragmentManifestOpf = `<<item id="[[chapter-id]]" href="[[chapter-id]].xhtml" media-type="application/xhtml+xml"/>`;
	var fragmentSpineOpf = `<itemref idref="[[chapter-id]]"/>`;
	var allFragmentsManifest ="";
	var allFragmentsSpine ="";
		allFragmentsManifest+= fragmentManifestOpf.replaceAll("[[chapter-id]]", "chapter_"+1);
		allFragmentsSpine+= fragmentSpineOpf.replaceAll("[[chapter-id]]", "chapter_"+1);
	opfContent = opfContent.replaceAll("[[title]]", jsonStory.submission.title!=null ? jsonStory.submission.title: "");
	opfContent = opfContent.replaceAll("[[author]]", jsonStory.submission.author.username);
	opfContent = opfContent.replaceAll("[[subject]]", jsonStory.submission.category_info.pageUrl);
	opfContent = opfContent.replaceAll("[[description]]", jsonStory.submission.description!=null ? jsonStory.submission.description: "");
	opfContent = opfContent.replaceAll("[[author]]", jsonStory.submission.author.username);
	opfContent = opfContent.replaceAll("[[date]]", new Date().toISOString().split('T')[0]);
	opfContent = opfContent.replaceAll("[[source]]", "literotica.com");
	opfContent = opfContent.replaceAll("[[allFragmentsManifest]]", allFragmentsManifest);
	opfContent = opfContent.replaceAll("[[allFragmentsSpine]]", allFragmentsSpine);
	folderOEBPS.file("content.opf", opfContent);
	// ============================================================
	// SECTION E.3 chapter(s) chapter-XXX.xhtml
	// ============================================================
		var chapterId = "chapter_"+1;
		var chapterContent = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <title>[[chapter-title]]</title>
  <!-- optional style-->
</head>
<body>
<h1>[[chapter-title]]</h1>
<h2>[[chapter-description]]</h2>
<h3>[[chapter-tags]]</h3>
  [[chapter-content]]
</body>
</html>`;
		chapterContent = chapterContent.replaceAll("[[chapter-title]]",jsonStory.submission.title);
		chapterContent = chapterContent.replaceAll("[[chapter-description]]",jsonStory.submission.description);
    chapterContent = chapterContent.replaceAll("[[chapter-tags]]","Tags : "+jsonStory.submission.tags.map(item => item.tag).join(", "));
		chapterContent = chapterContent.replaceAll("[[chapter-content]]",getContentOfStory(jsonStory.submission.url));
		folderOEBPS.file(chapterId+".xhtml",chapterContent);
	

	try {
		console.log(PREFIX_LOG+"generateAsync");
		const blob = await zip.generateAsync({
			type: "blob",
			mimeType: "application/epub+zip"
		});
		console.log(blob);
		const url = URL.createObjectURL(blob);

		const a = document.createElement("a");
		a.href = url;
		a.download = buildFilenameEpub(title, author, jsonStory.submission.description, jsonStory.submission.rate_all, jsonStory.submission.category_info.pageUrl, null);
		a.click();
	} catch (e) {
		console.error(e);
	}
}