您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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/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.2 // @grant GM_xmlhttpRequest // @run-at document-start // ==/UserScript== /* various test-cases; may be NSFW http://www.pixiv.net/member_illust.php?mode=medium&illust_id=48159751 http://www.pixiv.net/member_illust.php?mode=medium&illust_id=46288162 http://www.pixiv.net/member_illust.php?mode=medium&illust_id=43499240 http://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 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; 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}", // 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/, "") let exts = this.extensions.slice() // first extension let ext = "." + exts.shift() // match either end of path or start of query query param let 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 pages mediumSrc = 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 param 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) }