Pixiv Infinite Scroll/Download Links

Adds infinite scroll and inline expansion on the search page and artists' works pages. For manga mode a two-step expansion is used.

当前为 2014-10-08 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        Pixiv Infinite Scroll/Download Links
// @description Adds infinite scroll and inline expansion on the search page and artists' works pages. For manga mode a two-step expansion is used.
// @namespace   https://github.com/an-electric-sheep/userscripts
// @match       *://www.pixiv.net/search*
// @match       *://www.pixiv.net/member_illust*
// @match       *://www.pixiv.net/new_illust*
// @match       *://www.pixiv.net/bookmark_new_illust*
// @require     https://cdnjs.cloudflare.com/ajax/libs/jszip/2.4.0/jszip.js
// @version     0.5.6
// @grant       GM_xmlhttpRequest
// @run-at      document-start
// ==/UserScript==

"use strict";

function Maybe(wrapped) {
  if (typeof this !== "object" || Object.getPrototypeOf(this) !== Maybe.prototype) {
    var o = Object.create(Maybe.prototype);
    o.constructor.apply(o, arguments);
    return o;
  }
  
  this.wrapped = wrapped;
}

Maybe.prototype.isEmpty = function(){return null == this.wrapped}
Maybe.prototype.orElse = function(other){return this.isEmpty() ? Maybe(other) : this}
Maybe.prototype.apply = function(f){if(!this.isEmpty()){f.apply(null, [this.wrapped].concat(Array.prototype.slice.call(arguments, 1)))};return this;}
Maybe.prototype.map = function(f){return this.isEmpty() ? this :  Maybe(f.apply(null, [this.wrapped].concat(Array.prototype.slice.call(arguments,1))));}
Maybe.prototype.get = function(){return this.wrapped;}

// incomplete shim for older FF versions
if(!Array.hasOwnProperty("from"))
  Object.defineProperty(Array, "from",  {
    enumerable: false,
    configurable: true,
    value: function(e) {
        return Array.prototype.slice.call(e)
    }
  });

if(!Array.prototype.hasOwnProperty("last"))
  Object.defineProperty(Array.prototype, 'last', {
    enumerable: false,
    configurable: true,
    get: function() {
        return this.length > 0 ? this[this.length - 1] : undefined;
    },
    set: undefined
  });

Object.defineProperty(Function.prototype, "passThis", {value: function(){var f = this; return function(){f.apply(null, [this].concat(arguments))}}})


function xpathAt(path, element){
  var result = document.evaluate(path, element || document.documentElement, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)
  return result.singleNodeValue
}

const imgContainerSelector = "._image-items, .image-items, .display_works > ul";

document.addEventListener("DOMContentLoaded", function() {
  for(var e of document.querySelectorAll("iframe, .ad-printservice, .popular-introduction")){e.remove()}
  var sheet = document.querySelector("head").appendChild(document.createElement("style")).sheet;
  
  Array(
    // global
    "#wrapper {width: unset;}",
    // search page
    ".layout-body {width: 85vw;}",
    // member page
    ".layout-a {width: unset;}",
    ".layout-a .layout-column-2 {width: calc(100vw - 190px);}",
    // member works list
    ".display_works {width: unset;}",
    ".display_works .image-item {float: none; }",
    // member illust page
    ".works_display {width: unset;}",
    ".works_display img, .works_display ._layout-thumbnail {max-width: -moz-available; max-width: available}",
    // search and member works list
    "._image-items, .image-items, .display_works > ul {display: flex;flex-wrap: wrap;}",
    ".image-item img {padding: 0px; border: none;}",
    ".inline-expandable {cursor: pointer;}",
    ".image-item.expanded {width: 100%; height: unset;}",
    ".image-item.expanded .image-item-main {max-width: 80%; }",
    ".inline-expandable img {max-width: 100%; }",
    ".image-item.expanded .inline-expandable, .image-item.expanded img.manga {max-width: -moz-available; max-width: available;}",
    ".manga-item {background-color: #f3f3f3 !important;}",
    ".image-item img.manga-medium {max-width: 156px; max-height: 230px; cursor: pointer;}",
    // animated content inlined in the search page
    ".exploded-animation-scroller {overflow-x: auto; width: 100%; margin: 5px 0px; box-shadow: 0px 0px 4px 1px #444;}",
    ".exploded-animation {display: flex; width: -moz-fit-content; width: fit-content; }",
    ".exploded-animation img {margin-left: 5px;}",
    ".has-extended-info {display: flex; flex-wrap: wrap; justify-content: center; min-width: 342px; width: unset; height: unset;}",
    ".extended-info {margin-left: 0.8em;}",
    ".extended-info > * {margin-bottom: 1em; text-align: left; }",
    ".extended-info .tags .tag {float: unset; text-align: left; height: unset; width: unset; border: unset; padding: unset; background: unset; display: list-item; margin: 0px;}",
    "._layout-thumbnail:after {pointer-events: none;}"

  ).reverse().forEach(r => sheet.insertRule(r,0))
  
  window.addEventListener("scroll", NextPageHandler.checkAll)
  window.addEventListener("resize", NextPageHandler.checkAll)
  window.requestAnimationFrame(AnimatedCanvas.updateAll)
  
  for(var e of document.querySelectorAll(".image-item")){customizeImageItem(e)}
  
  
  Maybe(Array.from(document.querySelectorAll(".pager-container")).last).apply(paginator => {
    Maybe(document.querySelector(".image-item:last-child")).apply(lastItem => {
      var trigger = new NextPageHandler(lastItem)
      trigger.paginator = paginator
      trigger.url = paginator.querySelector("a[rel=next]").href
    })
  })
  
  NextPageHandler.checkAll();
  
  mediumPageHandler();
})


function mediumPageHandler() {
  var modeLink = document.querySelector('.works_display a[href*="mode"]')
  if(!modeLink)
   return;

  var modeLinkUrl = modeLink.href
  var mode = modeLinkUrl.match(/mode=(.+?)&/)[1]

  var mediumSrc = modeLink.querySelector("img").src
  var container = modeLink.parentNode;
  
  modeLink.addEventListener("click",(e) => {
    e.preventDefault();

    if(greasedImageItems.has(modeLink))
      return;
    
    greasedImageItems.set(modeLink, true)
    
    if(mode == "big") {
      insertBigItem(container, mediumSrc, modeLink, window.location.href)
    }
    
    if(mode == "manga"){
      insertMangaItems(container, modeLinkUrl)
    }
    
    
  })

}


function NextPageHandler(e) {
  if(!e)
    throw "element required";

  this.element = e;
  NextPageHandler.paginationTriggers.add(this)
}

NextPageHandler.paginationTriggers = new Set()

NextPageHandler.checkAll = function() {
  NextPageHandler.paginationTriggers.forEach(e => e.tryLoad())
}


NextPageHandler.prototype.tryLoad = function(){
  if(this.loading || !this.url || !inViewport(this.element))
    return;
  this.loading = true
  
  var req = new XMLHttpRequest();
  req.open("get", this.url)
  req.onabort = () => this.loading = false
  req.onerror = () => this.loading = false
  req.onload = () => {
    var rsp = req.responseXML;
    var nextItem = this.element.nextSibling;
    var container = this.element.parentNode;
    
    var newPaginator = rsp.querySelector(".pager-container")
    var newItems = Array.from(rsp.querySelectorAll(".image-item"))
    
    var lastItem = newItems.map(e => {
      var imageItem = document.importNode(e, true)
      container.insertBefore(imageItem, nextItem)
      customizeImageItem(imageItem)
      return imageItem
    }).last
    
    if(lastItem)    
      Maybe(newPaginator.querySelector("a[rel=next]")).map(e => e.href).apply(url => {
        var nextHandler = new NextPageHandler(lastItem)
        nextHandler.url = url
        nextHandler.paginator = this.paginator
      })
    
    if(this.paginator) {
      while(this.paginator.hasChildNodes())
        this.paginator.firstChild.remove()
      Array.from(newPaginator.childNodes).forEach(e => this.paginator.appendChild(document.importNode(e, true)))
    }
    this.destroy()
    this.loading = false
    NextPageHandler.checkAll();
  }
  req.responseType = "document"
  req.send()
}


NextPageHandler.prototype.destroy = function(){NextPageHandler.paginationTriggers.delete(this)}



function inViewport (el) {

    var rect = el.getBoundingClientRect();

    return (
        rect.bottom >= 0 &&
        rect.right >= 0 &&
        rect.top <= (window.innerHeight || document.documentElement.clientHeight) && 
        rect.left <= (window.innerWidth || document.documentElement.clientWidth)
    );
}

function MangaItem(container, insertBefore, mediumPageElement) {
  let mediumImg = mediumPageElement.querySelector(".image")
  this.bigUrl = mediumPageElement.querySelector(".full-size-container").href
    
  let item = this.item = document.createElement("li")
  item.className = "image-item manga-item"
  let img = this.img = document.createElement("img")
  img.src = mediumImg.dataset.src
  img.className = "manga-medium"
  img.addEventListener("click", () => this.expand())
  item.appendChild(img)
  
  container.insertBefore(item, insertBefore)
}

MangaItem.prototype = {
  expand: function() {
    let mediumSrc = this.img.src

    let newImg = document.createElement("img")
    newImg.className = "manga"

    // old image format
    // test with http://www.pixiv.net/member_illust.php?mode=medium&illust_id=43499240
    mediumSrc = mediumSrc.replace(/_p(\d+)\./, "_big_p$1.")
    // new image format
    // test with http://www.pixiv.net/member_illust.php?mode=medium&illust_id=46288162
    mediumSrc = mediumSrc.replace(/\/c\/1200x1200\/img-master\//, "/img-original/");
    mediumSrc = mediumSrc.replace(/_master1200\./, ".");

    let extensions = [".png", ".gif"]

    newImg.addEventListener("load", () => this.insertExpanded(newImg))
    newImg.addEventListener("error", () => {
      if(extensions.length > 0) {
        let ext = extensions.shift()
        newImg.src = mediumSrc.replace(/\.jpg$/, ext)
      } else {
        // TODO: load big page and get proper url from there
      }
    })
    newImg.src = mediumSrc;

  },
  insertExpanded: function(expandedImg) {
    this.img.parentNode.replaceChild(expandedImg,this.img)
    this.item.classList.add("expanded")
  }
}


function insertMangaItems(parentItem,url) {   
  var req = new XMLHttpRequest
  req.open("get", url)
  req.onload = function() {
    var rsp = this.responseXML
    
    var nextItem = parentItem.nextSibling
    var container = parentItem.parentNode
    
    for(var e of rsp.querySelectorAll(".item-container")) {
      new MangaItem(container, nextItem, e)
    }
  }
  req.responseType = "document"
  req.send()
  
}

function AnimatedCanvas(container, frames) {
  this.container = container
  this.frames = frames
  this.currentFrame = 0
  
  this.canvas = document.createElement("canvas")
  this.canvas.setAttribute("width", frames[0].img.naturalWidth)
  this.canvas.setAttribute("height", frames[0].img.naturalHeight)
  
  var img = container.querySelector(".inline-expandable img")
  img.parentNode.replaceChild(this.canvas, img)
  
  this.ctx = this.canvas.getContext("2d")
  
  this.ctx.drawImage(frames[0].img, 0, 0)
  this.timestamp = null
  
  AnimatedCanvas.instances.add(this)
}

AnimatedCanvas.prototype.update = function(timestamp) {
  if(!inViewport(this.canvas))
    return;

  if(!this.timestamp){
    this.timestamp = timestamp
    return;
  }
  
  if(timestamp - this.timestamp > this.frames[this.currentFrame].delay)
  {
    this.timestamp = timestamp
    this.currentFrame = (this.currentFrame + 1) % this.frames.length;
    this.ctx.drawImage(this.frames[this.currentFrame].img, 0, 0)
  }
}

AnimatedCanvas.instances = new Set();
AnimatedCanvas.updateAll = function(timestamp) {
  AnimatedCanvas.instances.forEach(i => i.update(timestamp))
  window.requestAnimationFrame(AnimatedCanvas.updateAll)
}


function insertAnimationItems(container, mediumDoc) {
  var script = mediumDoc.querySelector("#wrapper script")

  console.log(script.firstChild.data)


  // it's not a strong sandbox. it just avoids the loaded script writing to the main window
  var sandbox = document.createElement("iframe")
  //sandbox.src = window.location.href
  sandbox.seamless = true
  sandbox.setAttribute("srcdoc", "<!DOCTYPE html><html><head><script>window.pixiv = {context: {}}</script><script>"+ script.firstChild.data +"</script></head></html>")
  sandbox.onload = () => {
    let sandboxWindow = sandbox.contentWindow

    // access unsafe window to read data structure created by the script
    if(sandboxWindow.wrappedJSObject)
      sandboxWindow = sandboxWindow.wrappedJSObject

    // sanitize via json encode/decode
    var pixivContext = JSON.parse(JSON.stringify(sandboxWindow.pixiv.context))

    let illustData = pixivContext.ugokuIllustFullscreenData
     
    var req = new XMLHttpRequest
    req.open("get", illustData.src)
    req.responseType = "arraybuffer"
    req.onload = function () {
    
      var buffer = this.response
      var zip = new JSZip(buffer)

      var downloadLink = document.createElement("a")
      downloadLink.innerHTML = downloadLink.download = pixivContext.illustId + ".zip"
      downloadLink.className = "animation-download";

      var downloadInfo = document.createElement("div");
      downloadInfo.className = "animated-item-download";
      
      [
        document.createTextNode("Download: "),
        downloadLink,
        document.createElement("br"),
        document.createTextNode("pixiv2webm and pixiv2gif available "),
        Maybe(document.createElement("a")).apply(e => {e.href = "https://github.com/an-electric-sheep/userscripts"; e.innerHTML = "on github"}).get()
      ].forEach(e => downloadInfo.appendChild(e))
      
      container.querySelector(".extended-info").appendChild(downloadInfo)
      
      var scrollContainer = document.createElement("div")
      var explodedAnimation = document.createElement("div")
      scrollContainer.className = "exploded-animation-scroller"
      explodedAnimation.className = "exploded-animation"
      
      scrollContainer.appendChild(explodedAnimation)
      container.appendChild(scrollContainer)
      
      var timingInformation = []
      
      var frames = []

      for(var name in zip.files){
        let file = zip.file(name)
        let imgBuf = file.asArrayBuffer()
        let imgBlob = new Blob([imgBuf])
        
        let img = document.createElement("img")
        let delay = illustData.frames.find((e) => e.file == name).delay
        
        img.src = URL.createObjectURL(imgBlob)
        
        frames.push({"img": img, "delay": delay})
        timingInformation.push(file.name  + "\t" + delay)
        explodedAnimation.appendChild(img)
      }
      container.classList.add("expanded")
      
      frames[0].img.onload = () => new AnimatedCanvas(container, frames)
      
      zip.file("frame_delays.txt", timingInformation.join("\n"))
      
      downloadLink.href = URL.createObjectURL(zip.generate({type: "blob"}))
      
      sandbox.remove();
    }
    req.send()
  }
  document.body.appendChild(sandbox)

}

function insertItemTags(container, responseDoc) {
  var tags = document.importNode(responseDoc.querySelector(".tags"), true)
  container.querySelector(".extended-info").appendChild(tags)
  container.classList.add("has-extended-info")
}

function insertBigItem(container, mediumSrc, bigLinkUrl, mediumLinkUrl) {
  let newImg = document.createElement("img")
  let curImg = container.querySelector("img")
  newImg.setAttribute("class", curImg.getAttribute("class"))
  
  if(mediumSrc.match(/_m\./)) {
    // old format, just derive big url from medium url
    newImg.src = mediumSrc.replace("_m.", ".");
    
  
  } else {
    // new/complex format, e.g.  http://www.pixiv.net/member_illust.php?mode=medium&illust_id=46204420
    // requires a full "mode=big" request to determine the correct img uri

    GM_xmlhttpRequest({
      method: "GET",
      url: bigLinkUrl,
      headers: {
        // we are only allowed to load the mode=big page when referer is mode=medium
        "Referer": mediumLinkUrl
      },
      onerror: function() {
        console.log("big mode load error")
      },
      onload: function(response) {
        console.log("complex load")
        let rsp = response.responseXML;
        // Inject responseXML into existing Object (only appropriate for XML content).
        if (!response.responseXML) {
          rsp = new DOMParser().parseFromString(response.responseText, "text/html");
        }
        newImg.src = rsp.querySelector("img").src
      }
    });

  }
  
  newImg.addEventListener("load", () => {curImg.parentNode.replaceChild(newImg, curImg);container.classList.add("expanded")})
  newImg.addEventListener("error", () => {
    container.appendChild(document.createTextNode("Failed to load full size image. If this problem persists please report a bug for the infinite scroll userscript"))
  })


  
}


const greasedImageItems = new WeakMap();

function customizeImageItem(itemElement) {
  if(greasedImageItems.has(itemElement))
   return;

  let wrapper = new ImageItem(itemElement)

  greasedImageItems.set(itemElement, wrapper);
}

function ImageItem(item) {
  let workLink = this.workLink = item.querySelector("a.work")

  this.container = item



  let img = this.img = item.querySelector("img")
  let mainInfoContainer = document.createElement("div")
  let imgContainer = document.createElement("div")
  let expandedInfo = document.createElement("aside") 


  mainInfoContainer.appendChild(imgContainer)
  imgContainer.appendChild(img)
  while(item.hasChildNodes())
    mainInfoContainer.appendChild(item.firstChild)

  item.appendChild(mainInfoContainer)
  item.appendChild(expandedInfo)

  mainInfoContainer.className = "image-item-main"

  imgContainer.classList.add("inline-expandable")
  // copy a few classes over so the user knows what kind of item it is
  Array("_work", "multiple", "manga", "ugoku-illust").forEach(cl => {
    if(workLink.classList.contains(cl))
      imgContainer.classList.add(cl)
  })

  img.className = ""

  img.addEventListener("click", () => this.listItemExpand())

  // remove from dom due to after/before styles
  workLink.remove()

  expandedInfo.className = "extended-info"
}

ImageItem.prototype = {
  listItemExpand: function() {
    if(this.expanded)
      return;
    this.expanded = true

    let container = this.container;
    while(!container.classList.contains("image-item"))
      container = container.parentNode;
    let mediumLink = this.workLink.href
    let req = new XMLHttpRequest()
    req.open("get", mediumLink)
    req.onerror = () => {this.expanded = false}
    req.onload = function() {
      let rsp = this.responseXML;
      
      insertItemTags(container, rsp)
      
      if(rsp.querySelector("._ugoku-illust-player-container")) {
        insertAnimationItems(container, rsp)
      }
      
      Maybe(rsp.querySelector('.works_display a[href*="mode"]')).apply((modeLink) => {
        let modeLinkUrl = modeLink.href
        let mediumSrc = modeLink.querySelector("img").src
        
        let mode = modeLinkUrl.match(/mode=(.+?)&/)[1]
        if(mode == "big") {
          insertBigItem(container, mediumSrc, modeLinkUrl, mediumLink)
        }
        
        if(mode == "manga"){
          insertMangaItems(container, modeLinkUrl)
        }
      })
     
    }
    req.responseType = "document"
    req.send()
    
  }
}