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. (function() {
  18. 'use strict';
  19.  
  20. // The src prefix: scheme and domain
  21. let src_prefix = 'https://i.pximg.net';
  22. // The src suffix for thumbnails
  23. let thumb_suffix = '_master1200.jpg';
  24. // A regular expression that matches pixiv thumbnail urls
  25. // Has 3 captures:
  26. // $1: thumbnail width (optional)
  27. // $2: thumbnail height (optional)
  28. // $3: everything in the URL after the thumbnail size up to the image suffix
  29. let src_regexp = /https?:\/\/i\.pximg\.net(?:\/c\/(\d+)x(\d+)(?:_[^\/]*)?)?\/(?:custom-thumb|img-master)\/(.*)_(?:custom|master|square)1200.jpg/;
  30.  
  31. const image_sizes = [
  32. { size: 150, path: '/c/150x150' },
  33. { size: 240, path: '/c/240x240' },
  34. { size: 360, path: '/c/360x360_70' },
  35. { size: 600, path: '/c/600x600' },
  36. { size: 1200, path: '' }
  37. ];
  38.  
  39. function genImageSet(size, url_stuff) {
  40. let set = [];
  41. for (const image_size of image_sizes) {
  42. set.push({ src: `${src_prefix}${image_size.path}/img-master/${url_stuff}${thumb_suffix}`, scale: image_size.size / size });
  43. }
  44. let defaultSrc = null;
  45. for (const image of set) {
  46. if (image.scale >= window.devicePixelRatio) {
  47. defaultSrc = image.src;
  48. break;
  49. }
  50. }
  51. if (!defaultSrc) {
  52. defaultSrc = set[set.length - 1].src;
  53. }
  54. return { set, defaultSrc };
  55. }
  56.  
  57. // Create a srcset= attribute on the img, with appropriate dpi scaling values
  58. // Also update the src= attribute to a value appropriate for current dpi
  59. function imgSrcset(img, size, url_stuff) {
  60. let imageSet = genImageSet(size, url_stuff);
  61. img.srcset = imageSet.set.map(image => `${image.src} ${image.scale}x`).join(', ');
  62. img.src = imageSet.defaultSrc;
  63. if (!img.attributes.width && !img.style.width) { img.style.width = `${size}px`; }
  64. if (!img.attributes.height && !img.style.height) { img.style.height = `${size}px`; }
  65. }
  66.  
  67. // Set up a css background-image with image-set() where supported, falling back
  68. // to a single image
  69. function cssImageSet(node, size, url_stuff) {
  70. let imageSet = genImageSet(size, url_stuff);
  71. let cssImageList = imageSet.set.map(image => `url("${image.src}") ${image.scale}x`).join(', ');
  72. node.style.backgroundSize = 'contain';
  73. // The way the style properties work, if you try to assign an unsupported value, it does not
  74. // take effect, but a supported value replaces the old value. So assign in order of worst
  75. // to best
  76. // Fallback single image
  77. node.style.backgroundImage = `url("${imageSet.defaultSrc}")`;
  78. // webkit/blink prefixed image-set
  79. node.style.backgroundImage = `-webkit-image-set(${cssImageList})`;
  80. // CSS4 proposed standard image-set
  81. node.style.backgroundImage = `image-set(${cssImageList})`;
  82. }
  83.  
  84. function findParentSize(node) {
  85. let size = 0;
  86. let e = node;
  87. while (!size) {
  88. if (e.attributes.width && e.attributes.height) {
  89. let width = +e.attributes.width.value;
  90. let height = +e.attributes.height.value;
  91. if (width && width > size) { size = width; }
  92. if (height && height > size) { size = height; }
  93. return size;
  94. }
  95. if (!e.parentElement) { return 0; }
  96. e = e.parentElement;
  97. }
  98. }
  99.  
  100. // We're using some uncommon thumbnail dimensions, and pixiv might not have them pre-cached. The thumbnail
  101. // request sometimes errors out (404) on thumbnails that are slow to generate, so retry them until they
  102. // load
  103. function imgErrorHandler(event) {
  104. let self = this;
  105. if ((self.dataset.kepstinRetry|0) > 6) {
  106. console.log("gave up loading", self.src);
  107. return;
  108. }
  109. self.dataset.kepstinRetry = (self.dataset.kepstinRetry|0) + 1;
  110. let sleep = Math.min((self.dataset.kepstinRetry|0) * 2 + 1, 10);
  111. console.log("error loading", self.src, "try", self.dataset.kepstinRetry, "sleep", sleep);
  112. window.setTimeout(function() { console.log("reloading", self.src); self.src = self.src; }, sleep * 1000);
  113. event.stopImmediatePropagation();
  114. event.stopPropagation();
  115. return false;
  116. }
  117.  
  118. function handleImg(node) {
  119. if (node.dataset.kepstinThumbnail) { return; }
  120.  
  121. if (!node.src.startsWith(src_prefix)) { node.dataset.kepstinThumbnail = 'skip'; return; }
  122.  
  123. let m = node.src.match(src_regexp);
  124. if (!m) {
  125. node.dataset.kepstinThumbnail = 'bad';
  126. return;
  127. }
  128.  
  129. let size = findParentSize(node);
  130. if (size < 16) { size = Math.max(node.clientWidth, node.clientHeight); }
  131. if (size < 16) { size = Math.max(m[1], m[2]); }
  132. if (size == 0) {
  133. console.log('calculated size is 0 for', node)
  134. return;
  135. }
  136. imgSrcset(node, size, m[3]);
  137. node.style.objectFit = 'contain';
  138.  
  139. node.addEventListener('error', imgErrorHandler);
  140. node.dataset.kepstinThumbnail = 'ok';
  141. }
  142.  
  143. function handleLayoutThumbnail(node) {
  144. if (node.dataset.kepstinThumbnail) { return; }
  145.  
  146. // Check for lazy-loaded images, which have a temporary URL
  147. // They'll be updated later when the src is set
  148. if (node.src.startsWith('data:') || node.src.endsWith('transparent.gif')) { return; }
  149.  
  150. if (!node.src.startsWith(src_prefix)) { node.dataset.kepstinThumbnail = 'skip'; return; }
  151.  
  152. let m = node.src.match(src_regexp);
  153. if (!m) {
  154. node.dataset.kepstinThumbnail = 'bad';
  155. return;
  156. }
  157.  
  158. let width = m[1];
  159. let height = m[2];
  160. let size = Math.max(width, height);
  161. if (!size) { width = height = size = 1200 };
  162.  
  163. node.width = node.style.width = width;
  164. node.height = node.style.height = height;
  165.  
  166. imgSrcset(node, size, m[3]);
  167. node.style.objectFit = 'contain';
  168.  
  169. node.addEventListener('error', imgErrorHandler);
  170. node.dataset.kepstinThumbnail = 'ok';
  171. }
  172.  
  173. function handleDivBackground(node) {
  174. if (node.dataset.kepstinThumbnail) { return; }
  175.  
  176. // Check for lazy-loaded images
  177. // They'll be updated later when the background image (in style attribute) is set
  178. if (node.classList.contains('js-lazyload') || node.classList.contains('lazyloaded') || node.classList.contains('lazyloading')) { return; }
  179.  
  180. if (node.style.backgroundImage.indexOf(src_prefix) == -1) { node.dataset.kepstinThumbnail = 'skip'; return; }
  181. let m = node.style.backgroundImage.match(src_regexp);
  182. if (!m) {
  183. node.dataset.kepstinThumbnail = 'bad';
  184. return;
  185. }
  186. let size = Math.max(node.clientWidth, node.clientHeight);
  187. if (size == 0) { size = Math.max(m[1], m[2]); }
  188. if (size == 0) {
  189. console.log('calculated size is 0 for', node)
  190. return;
  191. }
  192. if (node.firstElementChild) {
  193. // There's other stuff inside the DIV, don't do image replacement
  194. cssImageSet(node, size, m[3]);
  195. node.dataset.kepstinThumbnail = 'ok';
  196. }
  197.  
  198. // Use IMG tags for images!
  199. let parentNode = node.parentElement;
  200. let img = document.createElement('IMG');
  201. imgSrcset(img, size, m[3]);
  202. img.class = node.class;
  203. img.alt = node.getAttribute('alt');
  204. img.style.width = node.style.width;
  205. img.style.height = node.style.height;
  206. img.style.objectFit = 'contain';
  207.  
  208. img.addEventListener('error', imgErrorHandler);
  209. img.dataset.kepstinThumbnail = 'ok';
  210.  
  211. node.replaceWith(img);
  212. }
  213.  
  214. function handleABackground(node) {
  215. if (node.dataset.kepstinThumbnail) { return; }
  216.  
  217. if (node.style.backgroundImage.indexOf(src_prefix) == -1) { node.dataset.kepstinThumbnail = 'skip'; return; }
  218. let m = node.style.backgroundImage.match(src_regexp);
  219. if (!m) {
  220. node.dataset.kepstinThumbnail = 'bad';
  221. return;
  222. }
  223. let size = Math.max(node.clientWidth, node.clientHeight);
  224. if (size == 0) { size = Math.max(m[1], m[2]); }
  225. if (size == 0) {
  226. console.log('calculated size is 0 for', node)
  227. return;
  228. }
  229.  
  230. if (node.firstElementChild) {
  231. // There's other stuff inside the A, don't do image replacement
  232. cssImageSet(node, size, m[3]);
  233. node.dataset.kepstinThumbnail = 'ok';
  234. return;
  235. }
  236.  
  237. // Use IMG tags for images!
  238. let img = document.createElement('IMG');
  239. imgSrcset(img, size, m[3]);
  240. img.alt = '';
  241. img.style.width = '100%';
  242. img.style.height = '100%';
  243. img.style.objectFit = 'contain';
  244. img.style.position = 'absolute';
  245.  
  246. img.addEventListener('error', imgErrorHandler);
  247. img.dataset.kepstinThumbnail = 'ok';
  248.  
  249. node.style.backgroundImage = '';
  250. node.prepend(img);
  251. }
  252.  
  253. function onetimeThumbnails(parentNode) {
  254. for (let node of parentNode.querySelectorAll('IMG')) {
  255. if (node.parentElement.classList.contains('_layout-thumbnail')) {
  256. handleLayoutThumbnail(node);
  257. } else {
  258. handleImg(node);
  259. }
  260. }
  261. for (let node of parentNode.querySelectorAll('DIV[style*=background-image]')) {
  262. handleDivBackground(node);
  263. }
  264. for (let node of parentNode.querySelectorAll('A[style*=background-image]')) {
  265. handleABackground(node);
  266. }
  267. }
  268.  
  269. function mutationObserverCallback(mutationList, observer) {
  270. for (let mutation of mutationList) {
  271. switch (mutation.type) {
  272. case 'childList':
  273. for (let node of mutation.addedNodes) {
  274. if (node.nodeName == 'IMG') {
  275. handleImg(node);
  276. } else if (node.nodeName == 'DIV') {
  277. if (node.style.backgroundImage) {
  278. handleDivBackground(node);
  279. } else {
  280. onetimeThumbnails(node);
  281. }
  282. } else if (node.nodeName == 'A') {
  283. if (node.style.backgroundImage) {
  284. handleABackground(node);
  285. }
  286. } else if (node.nodeName == 'SECTION' || node.nodeName == 'LI' || node.nodeName == 'FIGURE') {
  287. onetimeThumbnails(node);
  288. }
  289. }
  290. break;
  291. case 'attributes':
  292. if (mutation.target.nodeName == 'DIV') {
  293. if (mutation.target.style.backgroundImage) {
  294. handleDivBackground(mutation.target);
  295. }
  296. } else if (mutation.target.nodeName == 'IMG') {
  297. if (mutation.target.parentElement.classList.contains('_layout-thumbnail')) {
  298. handleLayoutThumbnail(mutation.target);
  299. }
  300. }
  301. break;
  302. }
  303. }
  304. }
  305.  
  306. if (!window.kepstinThumbnailObserver) {
  307. onetimeThumbnails(document.firstElementChild);
  308. window.kepstinThumbnailObserver = new MutationObserver(mutationObserverCallback);
  309. window.kepstinThumbnailObserver.observe(document.firstElementChild, { childList: true, subtree: true, attributes: true, attributeFilter: [ 'class', 'src' ] });
  310. }
  311. })();