Improve pixiv thumbnails

Stop pixiv from cropping thumbnails to a square. Use higher resolution thumbnails on Retina displays.

当前为 2020-01-26 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Improve pixiv thumbnails
  3. // @name:ja pixivサムネイルを改善する
  4. // @namespace https://www.kepstin.ca/userscript/
  5. // @license MIT; https://spdx.org/licenses/MIT.html
  6. // @version 20200126.0
  7. // @description Stop pixiv from cropping thumbnails to a square. Use higher resolution thumbnails on Retina displays.
  8. // @description:ja 正方形にトリミングされて表示されるのを防止します。Retinaディスプレイで高解像度のサムネイルを使用します。
  9. // @author Calvin Walton
  10. // @match https://www.pixiv.net/*
  11. // @match https://dic.pixiv.net/*
  12. // @match https://en-dic.pixiv.net/*
  13. // @exclude https://www.pixiv.net/fanbox*
  14. // @grant none
  15. // ==/UserScript==
  16.  
  17. // Copyright © 2020 Calvin Walton <calvin.walton@kepstin.ca>
  18. //
  19. // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files
  20. // (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify,
  21. // merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
  22. // furnished to do so, subject to the following conditions:
  23. //
  24. // The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or
  25. // substantial portions of the Software.
  26. //
  27. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
  28. // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
  29. // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
  30. // IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  31.  
  32. (function() {
  33. 'use strict';
  34.  
  35. // The src prefix: scheme and domain
  36. let src_prefix = 'https://i.pximg.net';
  37. // The src suffix for thumbnails
  38. let thumb_suffix = '_master1200.jpg';
  39. // A regular expression that matches pixiv thumbnail urls
  40. // Has 3 captures:
  41. // $1: thumbnail width (optional)
  42. // $2: thumbnail height (optional)
  43. // $3: everything in the URL after the thumbnail size up to the image suffix
  44. let src_regexp = /https?:\/\/i\.pximg\.net(?:\/c\/(\d+)x(\d+)(?:_[^\/]*)?)?\/(?:custom-thumb|img-master)\/(.*)_(?:custom|master|square)1200.jpg/;
  45.  
  46. const image_sizes = [
  47. { size: 150, path: '/c/150x150' },
  48. { size: 240, path: '/c/240x240' },
  49. { size: 360, path: '/c/360x360_70' },
  50. { size: 600, path: '/c/600x600' },
  51. { size: 1200, path: '' }
  52. ];
  53.  
  54. function genImageSet(size, url_stuff) {
  55. let set = [];
  56. for (const image_size of image_sizes) {
  57. set.push({
  58. src: `${src_prefix}${image_size.path}/img-master/${url_stuff}${thumb_suffix}`,
  59. scale: image_size.size / size
  60. });
  61. }
  62. let defaultSrc = null;
  63. for (const image of set) {
  64. if (image.scale >= window.devicePixelRatio) {
  65. defaultSrc = image.src;
  66. break;
  67. }
  68. }
  69. if (!defaultSrc) {
  70. defaultSrc = set[set.length - 1].src;
  71. }
  72. return { set, defaultSrc };
  73. }
  74.  
  75. // Create a srcset= attribute on the img, with appropriate dpi scaling values
  76. // Also update the src= attribute to a value appropriate for current dpi
  77. function imgSrcset(img, size, url_stuff) {
  78. let imageSet = genImageSet(size, url_stuff);
  79. img.srcset = imageSet.set.map(image => `${image.src} ${image.scale}x`).join(', ');
  80. img.src = imageSet.defaultSrc;
  81. if (!img.attributes.width && !img.style.width) { img.style.width = `${size}px`; }
  82. if (!img.attributes.height && !img.style.height) { img.style.height = `${size}px`; }
  83. }
  84.  
  85. // Set up a css background-image with image-set() where supported, falling back
  86. // to a single image
  87. function cssImageSet(node, size, url_stuff) {
  88. let imageSet = genImageSet(size, url_stuff);
  89. let cssImageList = imageSet.set.map(image => `url("${image.src}") ${image.scale}x`).join(', ');
  90. node.style.backgroundSize = 'contain';
  91. // The way the style properties work, if you try to assign an unsupported value, it does not
  92. // take effect, but a supported value replaces the old value. So assign in order of worst
  93. // to best
  94. // Fallback single image
  95. node.style.backgroundImage = `url("${imageSet.defaultSrc}")`;
  96. // webkit/blink prefixed image-set
  97. node.style.backgroundImage = `-webkit-image-set(${cssImageList})`;
  98. // CSS4 proposed standard image-set
  99. node.style.backgroundImage = `image-set(${cssImageList})`;
  100. }
  101.  
  102. function findParentSize(node) {
  103. let size = 0;
  104. let e = node;
  105. while (!size) {
  106. if (e.attributes.width && e.attributes.height) {
  107. let width = +e.attributes.width.value;
  108. let height = +e.attributes.height.value;
  109. if (width && width > size) { size = width; }
  110. if (height && height > size) { size = height; }
  111. return size;
  112. }
  113. if (!e.parentElement) { return 0; }
  114. e = e.parentElement;
  115. }
  116. }
  117.  
  118. // We're using some uncommon thumbnail dimensions, and pixiv might not have them pre-cached. The thumbnail
  119. // request sometimes errors out (404) on thumbnails that are slow to generate, so retry them until they
  120. // load
  121. function imgErrorHandler(event) {
  122. let self = this;
  123. if ((self.dataset.kepstinRetry|0) > 6) {
  124. console.log("gave up loading", self.src);
  125. return;
  126. }
  127. self.dataset.kepstinRetry = (self.dataset.kepstinRetry|0) + 1;
  128. let sleep = Math.min((self.dataset.kepstinRetry|0) * 2 + 1, 10);
  129. console.log("error loading", self.src, "try", self.dataset.kepstinRetry, "sleep", sleep);
  130. window.setTimeout(function() { console.log("reloading", self.src); self.src = self.src; }, sleep * 1000);
  131. event.stopImmediatePropagation();
  132. event.stopPropagation();
  133. return false;
  134. }
  135.  
  136. function handleImg(node) {
  137. if (node.dataset.kepstinThumbnail) { return; }
  138.  
  139. if (!node.src.startsWith(src_prefix)) { node.dataset.kepstinThumbnail = 'skip'; return; }
  140.  
  141. let m = node.src.match(src_regexp);
  142. if (!m) { node.dataset.kepstinThumbnail = 'bad'; return; }
  143.  
  144. let size = findParentSize(node);
  145. if (size < 16) { size = Math.max(node.clientWidth, node.clientHeight); }
  146. if (size < 16) { size = Math.max(m[1], m[2]); }
  147. if (size == 0) {
  148. console.log('calculated size is 0 for', node)
  149. return;
  150. }
  151. imgSrcset(node, size, m[3]);
  152. node.style.objectFit = 'contain';
  153.  
  154. node.addEventListener('error', imgErrorHandler);
  155. node.dataset.kepstinThumbnail = 'ok';
  156. }
  157.  
  158. function handleLayoutThumbnail(node) {
  159. if (node.dataset.kepstinThumbnail) { return; }
  160. // Check for lazy-loaded images, which have a temporary URL
  161. // They'll be updated later when the src is set
  162. if (node.src.startsWith('data:') || node.src.endsWith('transparent.gif')) { return; }
  163. if (!node.src.startsWith(src_prefix)) { node.dataset.kepstinThumbnail = 'skip'; return; }
  164.  
  165. let m = node.src.match(src_regexp);
  166. if (!m) { node.dataset.kepstinThumbnail = 'bad'; return; }
  167.  
  168. let width = m[1];
  169. let height = m[2];
  170. let size = Math.max(width, height);
  171. if (!size) { width = height = size = 1200 };
  172.  
  173. node.width = node.style.width = width;
  174. node.height = node.style.height = height;
  175.  
  176. imgSrcset(node, size, m[3]);
  177. node.style.objectFit = 'contain';
  178.  
  179. node.addEventListener('error', imgErrorHandler);
  180. node.dataset.kepstinThumbnail = 'ok';
  181. }
  182.  
  183. function handleDivBackground(node) {
  184. if (node.dataset.kepstinThumbnail) { return; }
  185. // Check for lazy-loaded images
  186. // They'll be updated later when the background image (in style attribute) is set
  187. if (node.classList.contains('js-lazyload') || node.classList.contains('lazyloaded') || node.classList.contains('lazyloading')) { return; }
  188. if (node.style.backgroundImage.indexOf(src_prefix) == -1) { node.dataset.kepstinThumbnail = 'skip'; return; }
  189.  
  190. let m = node.style.backgroundImage.match(src_regexp);
  191. if (!m) { node.dataset.kepstinThumbnail = 'bad'; return; }
  192.  
  193. let size = Math.max(node.clientWidth, node.clientHeight);
  194. if (size == 0) { size = Math.max(m[1], m[2]); }
  195. if (size == 0) {
  196. console.log('calculated size is 0 for', node)
  197. return;
  198. }
  199. if (node.firstElementChild) {
  200. // There's other stuff inside the DIV, don't do image replacement
  201. cssImageSet(node, size, m[3]);
  202. node.dataset.kepstinThumbnail = 'ok';
  203. }
  204.  
  205. // Use IMG tags for images!
  206. let parentNode = node.parentElement;
  207. let img = document.createElement('IMG');
  208. imgSrcset(img, size, m[3]);
  209. img.class = node.class;
  210. img.alt = node.getAttribute('alt');
  211. img.style.width = node.style.width;
  212. img.style.height = node.style.height;
  213. img.style.objectFit = 'contain';
  214.  
  215. img.addEventListener('error', imgErrorHandler);
  216. img.dataset.kepstinThumbnail = 'ok';
  217.  
  218. node.replaceWith(img);
  219. }
  220.  
  221. function handleABackground(node) {
  222. if (node.dataset.kepstinThumbnail) { return; }
  223. if (node.style.backgroundImage.indexOf(src_prefix) == -1) { node.dataset.kepstinThumbnail = 'skip'; return; }
  224.  
  225. let m = node.style.backgroundImage.match(src_regexp);
  226. if (!m) { node.dataset.kepstinThumbnail = 'bad'; return; }
  227.  
  228. let size = Math.max(node.clientWidth, node.clientHeight);
  229. if (size == 0) { size = Math.max(m[1], m[2]); }
  230. if (size == 0) {
  231. console.log('calculated size is 0 for', node)
  232. return;
  233. }
  234.  
  235. if (node.firstElementChild) {
  236. // There's other stuff inside the A, don't do image replacement
  237. cssImageSet(node, size, m[3]);
  238. node.dataset.kepstinThumbnail = 'ok';
  239. return;
  240. }
  241.  
  242. // Use IMG tags for images!
  243. let img = document.createElement('IMG');
  244. imgSrcset(img, size, m[3]);
  245. img.alt = '';
  246. img.style.width = '100%';
  247. img.style.height = '100%';
  248. img.style.objectFit = 'contain';
  249. img.style.position = 'absolute';
  250.  
  251. img.addEventListener('error', imgErrorHandler);
  252. img.dataset.kepstinThumbnail = 'ok';
  253.  
  254. node.style.backgroundImage = '';
  255. node.prepend(img);
  256. }
  257.  
  258. function onetimeThumbnails(parentNode) {
  259. for (let node of parentNode.querySelectorAll('IMG')) {
  260. if (node.parentElement.classList.contains('_layout-thumbnail')) {
  261. handleLayoutThumbnail(node);
  262. } else {
  263. handleImg(node);
  264. }
  265. }
  266. for (let node of parentNode.querySelectorAll('DIV[style*=background-image]')) {
  267. handleDivBackground(node);
  268. }
  269. for (let node of parentNode.querySelectorAll('A[style*=background-image]')) {
  270. handleABackground(node);
  271. }
  272. }
  273.  
  274. function mutationObserverCallback(mutationList, observer) {
  275. for (let mutation of mutationList) {
  276. switch (mutation.type) {
  277. case 'childList':
  278. for (let node of mutation.addedNodes) {
  279. if (node.nodeName == 'IMG') {
  280. handleImg(node);
  281. } else if (node.nodeName == 'DIV') {
  282. if (node.style.backgroundImage) {
  283. handleDivBackground(node);
  284. } else {
  285. onetimeThumbnails(node);
  286. }
  287. } else if (node.nodeName == 'A') {
  288. if (node.style.backgroundImage) {
  289. handleABackground(node);
  290. }
  291. } else if (node.nodeName == 'SECTION' || node.nodeName == 'LI' || node.nodeName == 'FIGURE') {
  292. onetimeThumbnails(node);
  293. }
  294. }
  295. break;
  296. case 'attributes':
  297. if (mutation.target.nodeName == 'DIV') {
  298. if (mutation.target.style.backgroundImage) {
  299. handleDivBackground(mutation.target);
  300. }
  301. } else if (mutation.target.nodeName == 'IMG') {
  302. if (mutation.target.parentElement.classList.contains('_layout-thumbnail')) {
  303. handleLayoutThumbnail(mutation.target);
  304. }
  305. }
  306. break;
  307. }
  308. }
  309. }
  310.  
  311. if (!window.kepstinThumbnailObserver) {
  312. onetimeThumbnails(document.firstElementChild);
  313. window.kepstinThumbnailObserver = new MutationObserver(mutationObserverCallback);
  314. window.kepstinThumbnailObserver.observe(document.firstElementChild, {
  315. childList: true,
  316. subtree: true,
  317. attributes: true,
  318. attributeFilter: [ 'class', 'src' ]
  319. });
  320. }
  321. })();