Greasy Fork is available in English.
Adds infinite scroll and inline expansion on the search page and artists' works pages. For manga mode a two-step expansion is used.
// ==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/bookmark.php*// @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.7.5// @grant GM_xmlhttpRequest// @run-at document-start// @noframes// ==/UserScript==/*various test-cases; may be NSFWhttp://www.pixiv.net/member_illust.php?mode=medium&illust_id=48159751http://www.pixiv.net/member_illust.php?mode=medium&illust_id=46288162http://www.pixiv.net/member_illust.php?mode=medium&illust_id=43499240http://www.pixiv.net/member_illust.php?mode=medium&illust_id=47204793*/"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 versionsif(!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 = falsefunction xpathAt(path, element){var r###lt = document.evaluate(path, element || document.documentElement, null, XPathR###lt.FIRST_ORDERED_NODE_TYPE, null)return r###lt.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()}// scroll events fire at high rate, throttle themlet requested = false;function viewportChanged() {if(requested)return;requested = true;window.requestAnimationFrame(() => {requested = false;NextPageHandler.checkAll();})}window.addEventListener("scroll", viewportChanged)window.addEventListener("resize", viewportChanged)window.addEventListener("visibilitychange", viewportChanged)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 = paginatortrigger.navElements = Array.from(paginator.childNodes)trigger.currentURL = window.location.hreftrigger.nextURL = paginator.querySelector("a[rel=next]").href})})NextPageHandler.checkAll();mediumPageHandler();})const style = document.createElement("style");style.textContent = `/* 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; top: 0px;}/* 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;}section#illust-recommend {margin-right: 0px;}/* 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;}/* paginator */.column-order-menu {position: sticky; bottom:0px;background-color: white; min-height: 30px;border-top: 1px solid grey;z-index:2;}`;if(document.head)document.head.appendChild(style);let obs = new MutationObserver(function(records) {for(let r of records) {for(let e of r.addedNodes) {if(e.localName == "head") {e.appendChild(style);}if(e.localName == "body") {document.head.appendChild(style);obs.disconnect();}}}});obs.observe(document.documentElement, {childList: true});function apiGet(workId) {return new Promise((resolve, reject) => {let session = document.cookie.match(/PHPSESSID=([^;]+);/)[1]//let token = unsafeWindow.pixiv.context.tokenlet token = document.cookie.match(/PHPSESSID=\d+_([^;]+);/)[1]let url = "https://public-api.secure.pixiv.net/v1/works/"+workId+".json" + '?profile_image_sizes=px_170x170,px_50x50&image_sizes=px_128x128,small,medium,large,px_480mw&include_stats=true&show_r18=1' // ?PHPSESSID=" + sessionGM_xmlhttpRequest({method: "GET",url: url,headers: {"Referer": document.location.href,//"Authorization": "Bearer "+token"Authorization": "Bearer 8mMXXWT9iuwdJvsVIvQsFYDwuZpRCMePeyagSh30ZdU"},onerror: function() {reject("api request " + url + " failed. are you logged in?")},onload: function(response) {if(response.status != 200) {reject("api request " + url + " returned code "+ response.status+". are you logged in?")return;}if(response.responseText.trim() == "") {reject("api request returned empty response")return;}let data = JSON.parse(response.responseText)resolve(data)}})})}function mediumPageHandler() {var modeLink = document.querySelector('.works_display a[href*="mode"]')if(!modeLink)return;var modeLinkUrl = modeLink.hrefvar mode = modeLinkUrl.match(/mode=(.+?)&/)[1]var mediumSrc = modeLink.querySelector("img").srcvar 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())let first = [...NextPageHandler.paginationTriggers].find(t => inViewport(t.element));if(first) {first.updatePageHistory()}}NextPageHandler.prototype.updatePageHistory = function() {history.replaceState({}, "", this.currentURL)if(this.paginator && this.navElements) {while(this.paginator.hasChildNodes())this.paginator.firstChild.remove();this.navElements.forEach(e => this.paginator.appendChild(e))}}NextPageHandler.prototype.tryLoad = function(){if(this.loading || this.loaded || !this.nextURL || !inViewport(this.element))return;this.loading = truevar req = new XMLHttpRequest();req.open("get", this.nextURL)req.onabort = () => this.loading = falsereq.onerror = () => this.loading = falsereq.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)let r###lt = customizeImageItem(imageItem)if(r###lt) {container.insertBefore(imageItem, nextItem)return imageItem;}return null;}).filter(e => e != null).lastif(lastItem) {var nextHandler = new NextPageHandler(lastItem)nextHandler.paginator = this.paginatornextHandler.navElements = Array.from(newPaginator.childNodes).map(e => document.importNode(e, true));nextHandler.currentURL = this.nextURLMaybe(newPaginator.querySelector("a[rel=next]")).apply(e => nextHandler.nextURL = e.href)}this.loading = falsethis.loaded = trueNextPageHandler.checkAll();}req.responseType = "document"req.send()}NextPageHandler.prototype.destroy = function(){NextPageHandler.paginationTriggers.delete(this)}function inViewport (el) {if(("hidden" in document) && document.hidden)return false;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 extensionsthis.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 = mediumUrlimg.className = "manga-medium"img.addEventListener("click", () => this.expand())item.appendChild(img)container.insertBefore(item, insertBefore)}MangaItem.prototype = {fastExpand: function() {let newImg = document.createElement("img")newImg.className = "manga";newImg.addEventListener("load", () => this.insertExpanded(newImg))newImg.src = this.bigSrc;},expand: function() {if(this.bigSrc) {this.fastExpand()return;}let mediumSrc = this.img.srclet 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=46288162mediumSrc = mediumSrc.replace(/\/c\/\d+x\d+\/img-master\//, "/img-original/");// yet another new format// http://www.pixiv.net/member_illust.php?mode=medium&illust_id=61985638mediumSrc = mediumSrc.replace(/img-master\/img\//,"img-original/img/")// common replacementmediumSrc = mediumSrc.replace(/_master1200\./, ".");} else {// old image format// test with http://www.pixiv.net/member_illust.php?mode=medium&illust_id=43499240mediumSrc = mediumSrc.replace(/_p(\d+)\./, "_big_p$1.")}// mobile API image format// test with http://www.pixiv.net/member_illust.php?mode=medium&illust_id=47204793mediumSrc = mediumSrc.replace(/mobile\//, "")mediumSrc = mediumSrc.replace(/_480mw/, "")let exts = this.extensions.slice()// first extensionlet ext = "." + exts.shift()// match either end of path or start of query query paramlet withExtension = mediumSrc.replace(/\.jpg(?=$|\?)/, ext)newImg.addEventListener("load", () => this.insertExpanded(newImg))newImg.addEventListener("error", () => {if(exts.length == 0 && (/_big_p/.test(mediumSrc))) {// sometimes there is no _big_p image for old style urls, usually on small pagesmediumSrc = mediumSrc.replace(/_big_p/, "_p")exts = this.extensions.slice()}if(exts.length > 0) {let fallbackExt = "." + exts.shift()// match either end of path or start of query query paramnewImg.src = mediumSrc.replace(/\.jpg(?=$|\?)/, fallbackExt)} else {// todo: load big page as fallbackreportError("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.nextSiblinglet container = parentItem.parentNodeapiGet(id).then(apiData => {let pages = apiData.response[0].metadata.pages;for(let page of pages) {let item = new MangaItem(container, nextItem, page["image_urls"].medium )item.bigSrc = page["image_urls"].large}}).catch(ex => new Promise((resolve, reject) => {let req = new XMLHttpRequest()req.open("get", url)req.onload = function() {let rsp = this.responseXMLlet items = [];let mediumImages = rsp.querySelectorAll(".item-container");if(mediumImages.length > 0) {for(let e of mediumImages) {let item = new MangaItem(container, nextItem, e.querySelector(".image").dataset.src)item.bigUrl = e.querySelector(".full-size-container").href}resolve(null)} else {// TODO: right-to-left manga browsing browsing formatrsp.querySelectorAll("head script:not([src])")//resolve()}reject("no manga items found for " + url)}req.onerror = () => {reject("failed to load " + url)}req.responseType = "document"req.send()})).catch(ex => {reportError(ex)})}function AnimatedCanvas(frames) {this.frames = framesthis.currentFrame = 0this.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 = nullAnimatedCanvas.instances.add(this)}AnimatedCanvas.prototype.update = function(timestamp) {if(!inViewport(this.canvas))return;if(!this.timestamp){this.timestamp = timestampreturn;}if(timestamp - this.timestamp > this.frames[this.currentFrame].delay){this.timestamp = timestampthis.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.containervar 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 windowvar sandbox = document.createElement("iframe")//sandbox.src = window.location.hrefsandbox.seamless = truesandbox.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 scriptif(sandboxWindow.wrappedJSObject)sandboxWindow = sandboxWindow.wrappedJSObject// sanitize via json encode/decodevar pixivContext = JSON.parse(JSON.stringify(sandboxWindow.pixiv.context))let illustData = pixivContext.ugokuIllustFullscreenDatavar req = new XMLHttpRequestreq.open("get", illustData.src)req.responseType = "arraybuffer"req.onload = function () {var buffer = this.responsevar 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).delayimg.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 urlnewImg.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 uriGM_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();const greasedIds = new Set();function customizeImageItem(itemElement) {if(greasedImageItems.has(itemElement))return false;let wrapper;try {wrapper = new ImageItem(itemElement);} catch(e) {return false;}let id = wrapper.id;if(id && greasedIds.has(id)) {return false;}greasedImageItems.set(itemElement, wrapper);greasedIds.add(id);return true;}function ImageItem(item) {let workLink = this.workLink = item.querySelector("a.work")if(!workLink)throw new Error("no work link found")this.container = itemlet mainInfoContainer = document.createElement("div")this.mainPanel = mainInfoContainerlet expandedInfo = document.createElement("aside")// transplant everything as-is from the image item into the new wrapperwhile(item.hasChildNodes())mainInfoContainer.appendChild(item.firstChild)item.appendChild(mainInfoContainer)item.appendChild(expandedInfo)mainInfoContainer.className = "image-item-main"mainInfoContainer.classList.add("inline-expandable")let img = this.image;img.addEventListener("click", (e) => {this.listItemExpand()// img is wrapped in a link, don't follow the link when the user clicks on itif(e.button === 0) {e.preventDefault()e.stopPropagation()}})if(img.dataset.src) {img.src = img.dataset.src}expandedInfo.className = "extended-info"}ImageItem.prototype = {get id() {let match = this.workLink.href.match(/illust_id=(\d+)/);return match && (match[1] | 0) || 0},get image() {return this.workLink.querySelector("img")},listItemExpand: function() {if(this.expanded)return;this.expanded = truelet container = this.container;while(!container.classList.contains("image-item"))container = container.parentNode;let mediumLink = this.workLink.hreflet req = new XMLHttpRequest()req.open("get", mediumLink)req.onerror = () => {this.expanded = falsereportError("could not fetch medium page for item "+ this.workLink)}req.onload = lift((response) => {let rsp = response.responseXML;let success = falseinsertItemTags(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.hreflet mediumSrc = modeLink.querySelector("img").srclet 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 asyncsuccess = true})if(!success) {reportError("failed to find data to expand "+ this.workLink)}})req.responseType = "document"req.send()}}function reportError(msg){let body = document.bodylet div = document.createElement("div")div.textContent = msgdiv.className = "userscript-error"div.addEventListener("click", (e) => {if(e.target === div)div.remove()})body.insertBefore(div, body.firstChild)}