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.

Pada tanggal 12 Januari 2015. Lihat %(latest_version_link).

// ==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.6.1
// @grant       GM_xmlhttpRequest
// @run-at      document-start
// ==/UserScript==

"use strict";

function lift(f) {
  return function(...args) {
    f(this, ...args)
  }
}

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(){let f= this; return function(){f.apply(null, [this].concat(arguments))}}})

var styleAdded = false


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()}
  
  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 insertStyle() {
  if(styleAdded)
    return;

  // wait until the parser is done with <head>
  if(!(document.querySelector("body"))) {
    window.requestAnimationFrame(insertStyle);
    return;
  }

  Maybe(document.querySelector("head")).map(head => head.appendChild(document.createElement("style")).sheet).apply(sheet => {

    Array(
      // global
      "#wrapper {width: unset;}",
      ".userscript-error {background-color: rgb(200,0,0); color: black;position: sticky;z-index: 2;width: 100%;text-align:center; padding: 2px; color: white; font-weight: bold;}",
      // 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 img.manga, .image-item.expanded canvas {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))

    styleAdded = true
  });
}

// attempt to insert immediately
insertStyle()



function apiGet(urlSuffix) {
  return new Promise((resolve, reject) => {
    let session = document.cookie.match(/PHPSESSID=([^;]+);/)[1]
    let url = "http://spapi.pixiv.net/iphone/" + urlSuffix + "&PHPSESSID=" + session

    GM_xmlhttpRequest({
      method: "GET",
      url: url,
      headers: {
        "Referer": document.location.href
      },
      onerror: function() {
        reject("api request " + url + " failed. are you logged in?")
      },
      onload: function(response) {
        let rawLines = response.responseText.replace(/\n$/,"").split("\n");



        let lines = []     

        for(let j=0;j<rawLines.length;j++) {
          let raw = rawLines[j]
          let data = []

          let stringStart = -1
          let quotedString = false

          // tokenizer loop
          for(let i=0;i<raw.length;i++) {
            if(quotedString === false) {
              if(raw[i] === ",") {
                if(stringStart !== -1)
                  data.push(raw.substring(stringStart,i-1))
                else
                  data.push(null)
                stringStart = -1
              }

              if(raw[i] === '"') {
                quotedString = true
                stringStart = i+1
              }


            } else {
              // assume that ", ends a quoted string
              // TODO: figure out proper escaping of quotes.
              // quotes are escaped as two consequtive quotes, i.e. ""
              // e.g. query info for http://www.pixiv.net/member_illust.php?mode=medium&illust_id=43499240
              if(raw[i] === '"' && (i+1 === raw.length || raw[i+1] === ","))
                quotedString = false
            }
          }

          lines.push(data)
        }

        resolve(lines)
      }
    })
  })
}


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, mediumUrl) {
  
  this.thumbSrc = mediumUrl
  // unless the API resolves the file type for us we have to try all possible extensions
  this.extensions = ["jpg", "png", "gif"]

  let item = this.item = document.createElement("li")
  item.className = "image-item manga-item"
  let img = this.img = document.createElement("img")
  img.src = mediumUrl
  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"

    if(/\/img-master\//.test(mediumSrc)) {
      // new image format
      // test with http://www.pixiv.net/member_illust.php?mode=medium&illust_id=46288162
      mediumSrc = mediumSrc.replace(/\/c\/\d+x\d+\/img-master\//, "/img-original/");
      mediumSrc = mediumSrc.replace(/_master1200\./, ".");
    } else {
      // 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.")
    }

    // mobile API image format
    // test with http://www.pixiv.net/member_illust.php?mode=medium&illust_id=47204793
    mediumSrc = mediumSrc.replace(/mobile\//, "")
    mediumSrc = mediumSrc.replace(/_480mw/, "")

    // first extension
    let ext = "." + this.extensions.shift()
    let withExtension = mediumSrc.replace(/\.jpg$/, ext)

    newImg.addEventListener("load", () => this.insertExpanded(newImg))
    newImg.addEventListener("error", () => {
      if(this.extensions.length > 0) {
        let fallbackExt = "." + this.extensions.shift()
        newImg.src = mediumSrc.replace(/\.jpg$/, fallbackExt)
      } else {
        // todo: load big page as fallback
        reportError("couldn't find big image based on manga thumbnail "+ this.thumbSrc + " tried " + withExtension)
      }
    })
    newImg.src = withExtension;

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


function insertMangaItems(parentItem,url) {

  let id = url.match(/illust_id=(\d+)/)[1]
  let nextItem = parentItem.nextSibling
  let container = parentItem.parentNode


  apiGet("manga.php?illust_id="+id)
    .then(apiData => {
      for(let entry of apiData) {
        let thumbUrl = entry[9]
        let extension = entry[2]

        let item = new MangaItem(container, nextItem, thumbUrl)
        item.extensions = [extension]
      }
    }).catch(ex => new Promise((resolve, reject) => {
      let req = new XMLHttpRequest()
      req.open("get", url)
      req.onload = function() {
        let rsp = this.responseXML

        let items = rsp.querySelectorAll(".item-container")

        if(items.length < 1)
          reject("no manga items found for " + url)
        else
          resolve(null)
        
        for(let e of items) {
          let mediumImg = e.querySelector(".image")
          let item = new MangaItem(container, nextItem, mediumImg.dataset.src)

          item.bigUrl = e.querySelector(".full-size-container").href
          
        }
      }
      req.onerror = () => {
        reject("failed to load " + url)
      }
      req.responseType = "document"
      req.send()

    })).catch(ex => {
      reportError(ex)
    })


 
  
}

function AnimatedCanvas(frames) {
  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)
  
  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(imageItem, mediumDoc) {
  let container = imageItem.container


  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";
      
      Array(
        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 = () => {
        let animation = new AnimatedCanvas(frames)
        imageItem.mainPanel.insertBefore(animation.canvas, imageItem.mainPanel.firstChild)
        imageItem.image.remove()
      }
      
      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", () => {
    reportError("failed to load big image for " + mediumSrc)
  })


  
}


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 mainInfoContainer = document.createElement("div")
  this.mainPanel = mainInfoContainer
  let expandedInfo = document.createElement("aside") 

  // transplant everything as-is from the image item into the new wrapper
  while(item.hasChildNodes())
    mainInfoContainer.appendChild(item.firstChild)

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

  mainInfoContainer.className = "image-item-main"

  mainInfoContainer.classList.add("inline-expandable")

  this.image.addEventListener("click", (e) => {
    this.listItemExpand()
    // img is wrapped in a link, don't follow the link when the user clicks on it
    if(e.button === 0) {
      e.preventDefault()
      e.stopPropagation()
    }
  })

  expandedInfo.className = "extended-info"
}

ImageItem.prototype = {
  get image() {
    return this.workLink.querySelector("img")
  },
  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
      reportError("could not fetch medium page for item "+ this.workLink)
    }
    req.onload = lift((response) => {
      let rsp = response.responseXML;
      let success = false
      
      insertItemTags(container, rsp)
      
      if(rsp.querySelector("._ugoku-illust-player-container")) {
        insertAnimationItems(this, rsp)
        success = true
      }
      
      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)
          success = true
        }
        
        if(mode === "manga"){
          insertMangaItems(container, modeLinkUrl)
          success = true
        }
      })

      Maybe(rsp.querySelector(".works_display .big, .original-image")).apply(big => {
        let newImg = document.createElement("img")
        newImg.addEventListener("load", () => {
          let oldImg = container.querySelector("img")
          oldImg.remove()

          this.mainPanel.insertBefore(newImg, this.mainPanel.firstChild)
          container.classList.add("expanded")
        })
        newImg.addEventListener("error", () => {
          reportError("could not load image: " + newImg.src)
        })
        newImg.src = big.dataset.src
        // assume success, report other errors async
        success = true
        
      })

      if(!success) {
        reportError("failed to find data to expand "+ this.workLink)
      }
     
    })
    req.responseType = "document"
    req.send()
    
  }
}

function reportError(msg){
  let body = document.body
  let div = document.createElement("div")

  div.textContent = msg
  div.className = "userscript-error"
  div.addEventListener("click", (e) => {
    if(e.target === div)
      div.remove()
  })

  body.insertBefore(div, body.firstChild)
}
长期地址
遇到问题?请前往 GitHub 提 Issues,或加Q群1031348184

赞助商

Fishcpy

广告

Rainyun

注册一下就行

Rainyun

一年攒够 12 元