Manga Loader

Loads manga chapter into one page in a long strip format, supports switching chapters and works for a variety of sites, minimal script with no dependencies, easy to implement new sites

As of 2014-10-28. See the latest version.

// ==UserScript==
// @name       Manga Loader
// @namespace  http://www.fuzetsu.com/MangaLoader
// @version    1.3.4
// @description  Loads manga chapter into one page in a long strip format, supports switching chapters and works for a variety of sites, minimal script with no dependencies, easy to implement new sites
// @copyright  2014+, fuzetsu
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @match http://bato.to/read/*
// @match http://mangafox.me/manga/*/*/*
// @match http://readms.com/r/*/*/*/*
// @match http://g.e-hentai.org/s/*/*
// @match http://exhentai.org/s/*/*
// @match *://www.fakku.net/*/*/read*
// @match http://www.mangareader.net/*/*
// @match http://www.mangahere.co/manga/*/*
// @match http://www.mangapanda.com/*/*
// @match http://mangadeer.com/manga/*/*/*/*
// @match http://mngacow.com/*/*
// @match http://nowshelf.com/watch/*
// @match http://nhentai.net/g/*/*
// ==/UserScript==

// set to true for manga load without prompt
var BM_MODE = false;

var scriptName = 'Manga Loader';

/**
Sample Implementation:
{
    match: "http://domain.com/.*" // the url to react to for manga loading
  , img: '#image' // css selector to get the page's manga image
  , next: '#next_page' // css selector to get the link to the next page
  , numpages: '#page_select' // css selector to get the number of pages. elements like (select, span, etc)
  , curpage: '#page_select' // css selector to get the current page. usually the same as numPages if it's a select element
  , nextchap: '#next_chap' // css selector to get the link to the next chapter
  , prevchap: '#prev_chap' // same as above except for previous
  , wait: 3000 // how many ms to wait before auto loading (to wait for elements to load)
  , pages: function(next_url, current_page_number, callback, extract_function) {
    // gets called requesting a certain page number (current_page_number)
    // to continue loading execute callback with img to append as first parameter and next url as second parameter
    // only really needs to be used on sites that have really unusual ways of loading images or depend on javascript
  }

  Any of the CSS selectors can be functions instead that return the desired value.
}
*/

var implementations = [
    { // Batoto
        match: "^http://bato.to/read/.*"
      , img: '#comic_page'
      , next: '#full_image + div > a'
      , numpages: '#page_select'
      , curpage: '#page_select'
      , nextchap: 'select[name=chapter_select]'
      , prevchap: 'select[name=chapter_select]'
      , invchap: true
    }
  , { // MangaPanda
        match: "^http://www.mangapanda.com/.*/[0-9]*"
      , img: '#img'
      , next: '.next a'
      , numpages: '#pageMenu'
      , curpage: '#pageMenu'
      , nextchap: 'td.c5 + td a'
      , prevchap: 'table.c6 tr:last-child td:last-child a'
  	}
  , { // MangaFox
        match: "^http://mangafox.me/manga/.*/.*/.*"
      , img: '#image'
      , next: '.next_page'
      , numpages: 'select.m'
      , curpage: 'select.m'
      , nextchap: '#chnav p + p a'
      , prevchap: '#chnav a'
    }
  , { // MangaStream
        match: "^http://readms.com/r/.*/.*/.*"
      , img: '#manga-page'
      , next: '.next a'
      , numpages: function() {
        var lastPage = getEl('.subnav-wrapper .controls .btn-group:last-child ul li:last-child');
        return parseInt(lastPage.textContent.match(/[0-9]/g).join(''), 10);
      }
      , nextchap: function(prev) {
          var found;
          var chapters = [].slice.call(document.querySelectorAll('.controls > div:first-child > .dropdown-menu > li a'));
          chapters.pop();
          for(var i = 0; i < chapters.length; i++) {
              if(window.location.href.indexOf(chapters[i].href) !== -1) {
                  found = chapters[i + (prev ? 1 : -1)];
                  if(found) return found.href;
              }
          }
      }
      , prevchap: function() {
          return this.nextchap(true);
      }
    }
  , { // MangaReader
        match: "^http://www.mangareader.net/.*/.*"
      , img:'#img'
      , next: '.next a'
      , numpages: '#pageMenu'
      , curpage: '#pageMenu'
      , nextchap: 'td.c5 + td a'
      , prevchap: 'table.c6 tr:last-child td:last-child a'
    }
  , { // MangaCow
        match: "^http://mngacow\.com/.*/[0-9]*"
      , img: '.prw > a > img'
      , next: '.prw > a:last-child'
      , numpages: 'select.cbo_wpm_pag'
      , curpage: 'select.cbo_wpm_pag'
      , nextchap: function(prev) {
          var chapSel = getEl('select.cbo_wpm_chp');
          var nextChap = chapSel.options[chapSel.selectedIndex + (prev ?  1 : -1)];
          if(nextChap) {
              return 'http://mngacow.com/' + window.location.pathname.slice(1, window.location.pathname.slice(1).indexOf('/') + 2) + nextChap.value; 
          }
      }
      , prevchap: function() {
          return this.nextchap(true);
      }
    }
  , { // MangaHere
        match: "^http://www.mangahere.co/manga/.*/.*"
      , img: '#viewer img'
      , next: '#viewer a'
      , numpages: 'select.wid60'
      , curpage: 'select.wid60'
      , nextchap: '.reader_tip > p:nth-last-child(2) > a'
      , prevchap: '.reader_tip > p:nth-last-child(1) > a'
    }
  , { // MangaDeer
        match: "^http://mangadeer\.com/manga/.*"
      , img: '.img-link > img'
      , next: '.page > span:last-child > a'
      , numpages: '#sel_page_1'
      , curpage: '#sel_page_1'
      , nextchap: function(prev) {
          var ddl = getEl('#sel_book_1');
          var index = ddl.selectedIndex + (prev ? -1 : 1);
          if(index >= ddl.options.length) return;
          var mangaName = window.location.href.slice(window.location.href.indexOf('manga/') + 6);
          mangaName = mangaName.slice(0, mangaName.indexOf('/'));
          return 'http://mangadeer.com/manga/' + mangaName + ddl.options[index].value + '/1';
      }
      , prevchap: function() {
          return this.nextchap(true);
      }
    }
  , { // GEH/EXH
        match: "^http://(g.e-hentai|exhentai).org/s/.*/.*"
      , img: '.sni > a > img, #img'
      , next: '.sni > a, #i3 a'
    }
  , { // Fakku
        match: "^http(s)?://www.fakku.net/.*/.*/read"
      , img: '.current-page'
      , next: '.current-page'
      , numpages: '.drop'
      , curpage: '.drop'
      , pages: function(url, num, cb, ex) {
        var firstNum = url.lastIndexOf('/')
          , lastDot = url.lastIndexOf('.');
        var c = url.charAt(firstNum);
        while(c && !/[0-9]/.test(c)) {
          c = url.charAt(++firstNum);
        }
        var curPage = parseInt(url.slice(firstNum, lastDot), 10);
        var url = url.slice(0, firstNum) + ('00' + (curPage + 1)).slice(-3) + url.slice(lastDot);
        cb(url, url);
      }
    }
  , { // Nowshelf
        match: "^http://nowshelf.com/watch/[0-9]*"
      , img: '#image'
      , next: '#image'
      , numpages: function() {return parseInt(getEl('#page').textContent.slice(3), 10);}
      , curpage: function() {return parseInt(getEl('#page > input').value, 10);}
      , pages: function(url, num, cb, ex) {
  		var url = url.slice(0, -7) + ('00' + num).slice(-3) + url.slice(-4);
  		cb(url, url);
      }
    }
  , { // nhentai
    	match: "^http://nhentai\.net\/g\/[0-9]*/[0-9]*"
      , img: '#image-container > a > img'
      , next: '#image-container > a'
      , numpages: '.num-pages'
      , curpage: '.current'
      , imgmod: {altProp: 'data-cfsrc'}
    }
];

var log = function(msg, type) {
  type = type || 'log';
  if(type === 'exit') {
    throw scriptName + ' exit: ' + msg;
  } else {
    console[type](scriptName + ' ' + type + ': ', msg);
  }
};

var getEl = function(q, c) {
  if(!q) return;
  return (c || document).querySelector(q);
};

var storeGet = function(key) {
  if(typeof GM_getValue === "undefined") {
    var value = localStorage.getItem(key);
    if(value === "true" || value === "false") {
      return (value === "true") ? true : false;
    }
    return value;
  }
  return GM_getValue(key);
};

var storeSet = function(key, value) {
  if(typeof GM_setValue === "undefined") {
    return localStorage.setItem(key, value);
  }
  return GM_setValue(key, value);
};

var storeDel = function(key) {
  if(typeof GM_deleteValue === "undefined") {
    return localStorage.removeItem(key);
  }
  return GM_deleteValue(key);
};

var extractInfo = function(selector, mod, context) {
  selector = this[selector];
  if(typeof selector === 'function') {
    return selector.call(this);
  }
  var elem = getEl(selector, context)
    , option;
  mod = mod || {};
  if(elem) {
    switch (elem.nodeName.toLowerCase()) {
      case 'img':
        return (mod.altProp && elem.getAttribute(mod.altProp)) || elem.getAttribute('src');
      case 'a':
        return elem.getAttribute('href');
      case 'ul':
        return elem.children.length;
      case 'select':
        switch(mod.type) {
          case 'index':
            return elem.options.selectedIndex + 1;
          case 'value':
            option = elem.options[elem.options.selectedIndex + (mod.val || 0)] || {};
            return option.value;
          default:
            return elem.options.length;
        }
        break;
      default:
        return elem.textContent;
    }
  }
};

var toStyleStr = function(obj) {
  var stack = []
    , key;
  for(key in obj) {
    if(obj.hasOwnProperty(key)) {
      stack.push(key + ':' + obj[key]);
    }
  }
  return stack.join(';');
};

var throttle = function(callback, limit) {
  var wait = false;
  return function() {
    if(!wait) {
      callback();
      wait = true;
      setTimeout(function() {
        wait = false;
      }, limit);
    }
  };
};

var createButton = function(text, action, styleStr) {
  var button = document.createElement('button');
  button.textContent = text;
  button.onclick = action;
  button.setAttribute('style', styleStr || '');
  return button;
};

var getViewer = function(prevChapter, nextChapter) {
  var viewerCss = toStyleStr({
        'background-color': 'black'
      , 'text-align': 'center'
      , 'font': '.9em sans-serif'
    })
  	, imagesCss = toStyleStr({
        'margin': '5px 0'
  	})
    , navCss = toStyleStr({
        'text-decoration': 'none'
      , 'color': 'black'
      , 'background': 'linear-gradient(white, #ccc)'
      , 'padding': '3px 10px'
      , 'border': '1px solid #ccc'
      , 'border-radius': '5px'
    })
  ;
  // clear all styles and scripts
  var title = document.title;
  document.head.innerHTML = '';
  document.title = title;
  // and navigation
  var nav = (prevChapter ? '<a href="' + prevChapter + '" style="' + navCss + '" class="ml-chap-nav">Prev Chapter</a> ' : '') +
    (storeGet('mAutoload') ? '' : '<a href="" style="' + navCss + '">Exit</a> ') +
    (nextChapter ? '<a href="' + nextChapter + '" style="' + navCss + '" class="ml-chap-nav">Next Chapter</a>' : '');
  document.body.innerHTML = nav + '<div id="images" style="' + imagesCss + '"></div>' + nav;
  // set the viewer css
  document.body.setAttribute('style', viewerCss);
  // set up listeners for chapter navigation
  document.addEventListener('click', function(evt) {
    if(evt.target.className.indexOf('ml-chap-nav') !== -1) {
      log('next chapter will autoload');
      storeSet('autoload', 'yes');
    }
  }, false);
  return getEl('#images');
};

var imageCss = toStyleStr({
      'max-width': '100%'
    , 'display': 'block'
    , 'margin': '3px auto'
});

var addImage = function(src, loc, callback) {
  var image = new Image();
  image.onerror = function() {
    log('failed to load ' + src);
    image.remove();
  };
  image.onload = callback;
  image.src = src;
  image.setAttribute('style', imageCss);
  loc.appendChild(image);
};

var loadManga = function(imp) {
  var ex = extractInfo.bind(imp)
    , imgUrl = ex('img')
    , nextUrl = ex('next')
    , numPages = ex('numpages')
    , curPage = ex('curpage', {type:'index'}) || 1
    , nextChapter = ex('nextchap', {type:'value', val: (imp.invchap && -1) || 1})
    , prevChapter = ex('prevchap', {type:'value', val: (imp.invchap && 1) || -1})
    , xhr = new XMLHttpRequest()
    , d = document.implementation.createHTMLDocument()
    , addAndLoad = function(img, next) {
      addImage(img, loc);
      loadNextPage(next);
    }
    , getPageInfo = function() {
      var page = d.body;
      d.body.innerHTML = xhr.response;
      try {
        // find image and link to next page
        addAndLoad(ex('img', imp.imgmod, page), ex('next', null, page));
      } catch(e) {
        log('error getting details from next page, assuming end of chapter.');
      }
    }
    , loadNextPage = function(url) {
      if(mLoadLess && count % 10 === 0) {
        if(resumeUrl) {
          resumeUrl = null;
        } else {
          resumeUrl = url;
          log('waiting for user to scroll further before loading more images, loaded ' + count + ' pages so far, next url is ' + resumeUrl);
          return;
        }
      }
      if(++curPage > numPages) {
        log('reached "numPages" ' + numPages + ', assuming end of chapter');
        return;
      }
      if(lastUrl === url) {
        log('last url is the same as current, assuming end of chapter');
        return;
      }
      lastUrl = url;
      if(imp.pages) {
        imp.pages(url, curPage, addAndLoad, ex);
      } else {
        xhr.open('get', url);
        xhr.onload = getPageInfo;
        xhr.onerror = function() {
          log('failed to load page, aborting', 'error');
        };
        xhr.send();
      }
      count += 1;
    }
    , count = 1
    , lastUrl, loc, resumeUrl
  ;

  if(!imgUrl || !nextUrl) {
    log('failed to retrieve ' + (!imgUrl ? 'image url' : 'next page url'), 'exit');
  }

  loc = getViewer(prevChapter, nextChapter);
  
  if(mLoadLess) {
    window.onscroll = throttle(function(e) {
      if(!resumeUrl) return; // exit early if we don't have a position to resume at
      var scrollBottom = document.body.scrollHeight - ((document.body.scrollTop || document.documentElement.scrollTop) + window.innerHeight);
      if(scrollBottom < 4500) {
        log('user scroll nearing end, loading more images starting from ' + resumeUrl);
        loadNextPage(resumeUrl);
      }
    }, 100);
  }
  
  addAndLoad(imgUrl, nextUrl);

};

var pageUrl = window.location.href
  , btnLoadCss = toStyleStr({
      'position': 'fixed'
    , 'bottom': 0
    , 'right': 0
    , 'padding': '5px'
    , 'margin': '0 10px 10px 0'
    , 'z-index': '99999'
  })
  , btnLoad
;

// used when switching chapters
var autoload = storeGet('autoload');
// manually set by user in menu
var mAutoload = storeGet('mAutoload') || false;
// should we load less pages at a time?
var mLoadLess = storeGet('mLoadLess') || true;
    
// clear autoload
storeDel('autoload');

// register menu commands
if(typeof GM_registerMenuCommand === 'function') {
  GM_registerMenuCommand('ML: ' + (mAutoload ? 'Disable' : 'Enable') + ' manga autoload', function() {
    storeSet('mAutoload', !mAutoload);
    window.location.reload();
  });
  GM_registerMenuCommand('ML: Load ' + (mLoadLess ? 'full chapter in one go' : '10 pages at a time'), function() {
    storeSet('mLoadLess', !mLoadLess);
    window.location.reload();
  });
}

log('starting...');

var success = implementations.some(function(imp) {
  if(imp.match && (new RegExp(imp.match, 'i')).test(pageUrl)) {
    if(BM_MODE || mAutoload || autoload) {
      setTimeout(loadManga.bind(null, imp),imp.wait || 0);
      return true;
    }
    // append button to dom that will trigger the page load
    btnLoad = createButton('Load Manga', function(evt) {
      loadManga(imp);
      this.remove();
    }, btnLoadCss);
    document.body.appendChild(btnLoad);
    return true;
  }
});

if(!success) {
  log('no implementation for ' + pageUrl, 'error');
}