Literotica Downloader

Single page HTML download for Literotica with improved readability

// ==UserScript==
// @name        Literotica Downloader
// @description Single page HTML download 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
// @version     5.7
// @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= {
	'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';

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/bootstrap-icons@1.4.1/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');
  console.log(isGM);
  var panelOptionDownloader=  `
<div id="literoticaDownloaderOption" title="Options Literotica Downloader" >
   Please Configure the options :
	<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('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("init dialog option start");
  $( "#literoticaDownloaderOption" ).dialog({ autoOpen: false, width: 450 });
  console.log("end Init");
  console.log("init dialog option end");
if (isGM) {
    GMsetValue = GM.setValue;
    GMgetValue = GM.getValue;
    GMregisterMenuCommand = GM.registerMenuCommand;
    GMunregisterMenuCommand = function() {};
    GMnotification = GM.notification;
    console.log("It's GM !");
} else {
    console.log("Other");

    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);

  /*
   * Section download on tags
   */
  document.addEventListener('DOMNodeInserted', function(e) {
    if (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("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*='_works_item__series_expanded_header_card'] [class*='_works_item__title'] [class*='_item_title']");
  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>');
	});

  parentElement.querySelectorAll('.ldSerie').forEach(element => {
    element.addEventListener('click', function() {getABookForSerieDiv(element.parentNode.parentNode)});
  });
}
function addDownloadChapterButtonTo(parentElement){
  const titlesChapter = parentElement.querySelectorAll("[class*='_series_parts__item__series_part_card'] [class*='_works_item__title'] [class*='_item_title']");
  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>');
	});

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

function getABookForStoryItemCategory(cardComponent) {
	console.log("In getABookForStoryItemCategory");
    console.log(cardComponent);
	var link = cardComponent.querySelector('h3 a').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 getABookForStoryOnStoryCardComponent(cardComponent) {
    console.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("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*='_item_title_']")[0].href;
}
function getABookForSerieDiv(serieDiv) {
    console.log("Processing series");
    console.log(serieDiv);
  openFullViewForDownloadSerie();
	var link = getSerieLinkFormDiv(serieDiv);
	var serieId = extractSerieId(link);
	var jsonSerie = getJsonSerieBySerieId(serieId).data;
        var title = jsonSerie.title;
        // Get the X Part Series
        var descriptionSeries = ""
  		var author = jsonSerie.user.username;
		var numberChapter = jsonSerie.work_count;
        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("addChapter"+itemChapter);
		  var storyId = itemChapter.url;
		  var jsonStory = getJsonStoryByStoryId(storyId);
		  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);
        }
	console.log(serieDiv.nextElementSibling);
  var allChapter = serieDiv.nextElementSibling.querySelectorAll("[class*='_series_parts__item_']")
  var lastChapter = allChapter[allChapter.length - 1];
	console.log(lastChapter);
	var linkLastChapter = getChapterLinkFormDiv(lastChapter);
    console.log("linkLastChapter => "+linkLastChapter);
	var storyId = extractStoryId(linkLastChapter);
	var jsonStory = getJsonStoryByStoryId(storyId);
    console.log(jsonStory);
	jsonStory.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*='_item_title_']")[0].href;
}

function getABookForStoryDiv(storyDiv){
  console.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;
  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('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('Request failed with status ' + xhr.status);
        }
    };
    xhr.onerror = function() {
        console.error('Request failed');
    };
    xhr.send();
    return toReturn;
}

function getJsonSerieBySerieId(serieId){
        var apiURL = "https://literotica.com/api/3/series/"+serieId;
        console.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('Request failed with status ' + xhr.status);
            }
        };
        xhr.onerror = function() {
            console.error('Request failed');
        };
        xhr.send();
        return toReturn;
}
function getJsonStoryByStoryId(storyId){
        var apiURL = "https://literotica.com/api/3/stories/"+storyId;
        console.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('Request failed with status ' + xhr.status);
            }
        };
        xhr.onerror = function() {
            console.error('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("Processing  -> " + storyId);
        var apiURL = "https://literotica.com/api/3/stories/"+storyId;
        console.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("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('Request failed with status ' + xhr.status);
            }
        };
        xhr.onerror = function() {
            console.error('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 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,'_series_parts__item')
                      || hasNodeClassStartingWith(node,'_series_parts__wrapper')
                     	|| hasNodeClassStartingWith(node,'_works_wrapper_')
                      || hasNodeClassStartingWith(node,'_works_item_')

                    )
                 ) {
                	//console.log('Element added:', node);
                	addDownloadChapterButtonTo(node);
                	addDownloadSeriesButtonTo(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);