Reddit expand media and comments

Shows pictures and some videos right after the link, loads and expands comment threads.

目前为 2020-01-23 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Reddit expand media and comments
  3. // @description Shows pictures and some videos right after the link, loads and expands comment threads.
  4.  
  5. // @version 0.3.6
  6.  
  7. // @author wOxxOm
  8. // @namespace wOxxOm.scripts
  9. // @license MIT License
  10.  
  11. // @match *://*.reddit.com/*
  12.  
  13. // @grant GM_addStyle
  14. // @grant GM_xmlhttpRequest
  15.  
  16. // @connect imgur.com
  17. // @connect gfycat.com
  18. // @connect gyazo.com
  19. // @connect streamable.com
  20. // @connect instagram.com
  21. // @connect ibb.co
  22. // @connect prntscr.com
  23. // @connect prnt.sc
  24. // @connect images.app.goo.gl
  25. // @connect www.google.com
  26. // @connect gstatic.com
  27. // ==/UserScript==
  28.  
  29. 'use strict';
  30.  
  31. const isOldReddit = !!unsafeWindow.reddit;
  32.  
  33. (!isOldReddit || /^\/(user|([^/]+\/){2}comments)\//.test(location.pathname)) && (() => {
  34. /* eslint indent: [error, 2, {outerIIFEBody:0, SwitchCase:1}] */
  35.  
  36. const CLASS = 'reddit-inline-media';
  37. const CLASS_ALBUM = CLASS + '-album';
  38. const OVERFLOW_ATTR = 'data-overflow';
  39. const MORE_SELECTOR = '[id^="moreComments-"] p, .morecomments a';
  40. const REQUEST_THROTTLE_MS = isOldReddit ? 500 : 100;
  41.  
  42. const RULES = [{
  43. u: '.gstatic.com/images?',
  44. }, {
  45. u: [
  46. 'imgur.com/a/',
  47. 'imgur.com/gallery/',
  48. ],
  49. r: /(a|gallery)\/(\w+)(#\w+)?$/,
  50. s: 'https://imgur.com/ajaxalbums/getimages/$2/hit.json?all=true',
  51. q: json =>
  52. json.data.images.map(img =>
  53. img && `https://i.imgur.com/${img.hash}${img.ext}`),
  54. }, {
  55. u: 'imgur.com/',
  56. r: /.com\/\w+$/,
  57. q: 'link[rel="image_src"], meta[name="twitter:player:stream"]',
  58. }, {
  59. u: '//streamable.com/',
  60. r: /\.com\/.+/,
  61. q: 'video',
  62. }, {
  63. u: '//gfycat.com/',
  64. r: /.com\/.+/,
  65. q: 'source[src*=".webm"]',
  66. }, {
  67. u: '//gyazo.com/',
  68. r: /\.com\/\w{32,}/,
  69. q: 'meta[name="twitter:image"]',
  70. xhr: true,
  71. }, {
  72. u: [
  73. 'instagram.com/p/',
  74. '//ibb.co/',
  75. '//images.app.goo.gl/',
  76. ],
  77. q: 'meta[property="og:image"]',
  78. }, {
  79. u: '//prntscr.com/',
  80. r: /\.com\/(\w+)$/i,
  81. s: 'https://prnt.sc/$1',
  82. q: 'meta[property="og:image"]',
  83. xhr: true,
  84. }, {
  85. u: '//prnt.sc/',
  86. r: /\.sc\/\w+$/i,
  87. q: 'meta[property="og:image"]',
  88. xhr: true,
  89. }, {
  90. u: [
  91. '//youtu.be/',
  92. '//youtube.com/',
  93. '//www.youtube.com/',
  94. ],
  95. r: /\/\/[^/]+?(?:\.be\/|\.com\/.*?[&?/]v[=/])([^&?/#]+)/,
  96. s: 'https://i.ytimg.com/vi/$1/default.jpg',
  97. }, {
  98. u: '//pbs.twimg.com/media/',
  99. r: /.+?\?format=\w+/,
  100. }, {
  101. u: '.gifv',
  102. r: /(.+?)\.gifv(\?.*)?$/i,
  103. s: '$1.mp4$2',
  104. }, {
  105. // keep this one at the end of the list
  106. r: /\.(jpe?g|png|gif|webm|mp4)(\?.*)?$/i,
  107. }];
  108.  
  109. // language=CSS
  110. GM_addStyle(`
  111. .${CLASS} {
  112. max-width: 100%;
  113. display: block;
  114. }
  115. .${CLASS}[data-src] {
  116. padding-top: 400px;
  117. }
  118. .${CLASS}:hover {
  119. outline: 2px solid #3bbb62;
  120. }
  121. .${CLASS_ALBUM} {
  122. overflow-y: auto;
  123. max-height: calc(100vh - 100px);
  124. margin: .5em 0;
  125. }
  126. .${CLASS_ALBUM}[${OVERFLOW_ATTR}] {
  127. -webkit-mask-image: linear-gradient(white 75%, transparent);
  128. mask-image: linear-gradient(white 75%, transparent);
  129. }
  130. .${CLASS_ALBUM}[${OVERFLOW_ATTR}]:hover {
  131. -webkit-mask-image: none;
  132. mask-image: none;
  133. }
  134. .${CLASS_ALBUM} > :nth-child(n + 2) {
  135. margin-top: 1em;
  136. }
  137. `);
  138.  
  139. const isChrome = navigator.userAgent.includes('Chrom');
  140. const more = [];
  141. const toStop = new Set();
  142.  
  143. let scrollObserver = lazyCreateObserver(onScroll, {rootMargin: '200% 0px'},
  144. obs => (scrollObserver = obs));
  145.  
  146. let albumObserver = lazyCreateObserver(onScroll, {rootMargin: '200px 0px'},
  147. obs => (albumObserver = obs));
  148.  
  149. new MutationObserver(onMutation)
  150. .observe(document.body, {subtree: true, childList: true});
  151.  
  152. onMutation([{
  153. addedNodes: [document.body],
  154. }]);
  155.  
  156. function onMutation(mutations) {
  157. var items = [];
  158. var someElementsAdded;
  159. for (var i = 0, m; (m = mutations[i++]);) {
  160. for (var j = 0, added = m.addedNodes, node; (node = added[j++]);) {
  161. if (!node.localName)
  162. continue;
  163. someElementsAdded = true;
  164. var aa = node.localName === 'a' ? [node] : node.getElementsByTagName('a');
  165. for (var k = 0, a; (a = aa[k++]);) {
  166. var data = findMatchingRule(a);
  167. if (data && !(isOldReddit && a.closest('.side')))
  168. items.push(data);
  169. }
  170. }
  171. }
  172. if (someElementsAdded && !observeShowMore.timer)
  173. observeShowMore.timer = setTimeout(observeShowMore, 500);
  174. if (items.length)
  175. setTimeout(maybeExpand, 0, items);
  176. }
  177.  
  178. function onScroll(entries, observer) {
  179. const stoppingScheduled = toStop.size > 0;
  180. for (const e of entries) {
  181. let el = e.target;
  182. if (el.localName === 'ins') {
  183. toggleAttribute(el.parentNode, OVERFLOW_ATTR, !e.isIntersecting);
  184. continue;
  185. }
  186. if (!e.isIntersecting) {
  187. const rect = e.boundingClientRect;
  188. if ((rect.bottom < -200 || rect.top > innerHeight + 200) &&
  189. el.src && !el.dataset.src && el[GM_info.script.name])
  190. toStop.add(el);
  191. continue;
  192. }
  193. if (stoppingScheduled)
  194. toStop.delete(el);
  195. const isImage = el.localName === 'img';
  196. if (isImage && el.dataset.src || el.localName === 'video') {
  197. el.src = el.dataset.src;
  198. el[GM_info.script.name] = {observer};
  199. el.addEventListener(isImage ? 'load' : 'loadedmetadata', unobserveOnLoad);
  200. delete el.dataset.src;
  201. continue;
  202. }
  203. if (el.localName === 'a') {
  204. // switch to an unfocusable element to prevent the link
  205. // from stealing focus and scrolling the view
  206. const el2 = document.createElement('span');
  207. el2.setAttribute('onclick', el.getAttribute('onclick'));
  208. el2.setAttribute('id', el.id);
  209. el.parentNode.replaceChild(el2, el);
  210. el = el2;
  211. }
  212. expandNextComment(el);
  213. }
  214. if (!stoppingScheduled && toStop.size)
  215. requestAnimationFrame(stopOffscreenImages);
  216. }
  217.  
  218. function stopOffscreenImages() {
  219. console.debug('stopOffscreenImages:', [...toStop]);
  220. for (const el of toStop) {
  221. if (el.naturalWidth || el.videoWidth)
  222. continue;
  223. delete el[GM_info.script.name];
  224. el.dataset.src = el.src;
  225. el.removeAttribute('src');
  226. el.removeEventListener('load', unobserveOnLoad);
  227. }
  228. toStop.clear();
  229. }
  230.  
  231. function findMatchingRule(a) {
  232. let url = a.href;
  233. for (const rule of RULES) {
  234. const {u} = rule;
  235. if (u && !(Array.isArray(u) ? u.find(includedInThis, url) : url.includes(u)))
  236. continue;
  237. const {r} = rule;
  238. const m = !r || url.match(r);
  239. if (!m)
  240. continue;
  241. if (r && rule.s)
  242. url = url.slice(0, m.index + m[0].length).replace(r, rule.s).slice(m.index);
  243. return {
  244. a,
  245. url,
  246. rule,
  247. };
  248. }
  249. }
  250.  
  251. function maybeExpand(items) {
  252. for (const item of items) {
  253. const {a, rule} = item;
  254. const {href} = a;
  255. const text = a.textContent.trim();
  256. if (
  257. text &&
  258. !a.getElementsByTagName('img')[0] &&
  259. (
  260. !text.startsWith('http') ||
  261. !text.endsWith('...') ||
  262. !/^https?:\/\/\S+?\.{3}$/.test(text)
  263. ) &&
  264. !a.closest(
  265. '.scrollerItem,' +
  266. '[contenteditable="true"],' +
  267. `a[href="${href}"] + * a[href="${href}"],` +
  268. `img[src="${href}"] + * a[href="${href}"]`) &&
  269. (
  270. // don't process insides of a post except for its text
  271. !a.closest('[data-test-id="post-content"]') ||
  272. a.closest('[data-click-id="text"]')
  273. )
  274. ) {
  275. try {
  276. (rule.q ? expandRemote : expand)(item);
  277. } catch (e) {
  278. // console.debug(e, item);
  279. }
  280. }
  281. }
  282. }
  283.  
  284. function expand({a, url = a.href, isAlbum}, observer = scrollObserver) {
  285. let el = isAlbum ? a.lastElementChild : a.nextElementSibling;
  286. if (!el || el.src !== url && el.dataset.src !== url) {
  287. const isVideo = /(webm|gifv|mp4)(\?.*)?$/i.test(url);
  288. el = document.createElement(isVideo ? 'video' : 'img');
  289. el.dataset.src = url;
  290. el.className = CLASS;
  291. a.insertAdjacentElement(isAlbum ? 'beforeEnd' : 'afterEnd', el);
  292. if (isVideo) {
  293. el.controls = true;
  294. el.preload = 'metadata';
  295. if (isChrome)
  296. el.addEventListener('click', playOnClick);
  297. }
  298. console.debug('expand:', el);
  299. observer.observe(el);
  300. }
  301. return !isAlbum && el;
  302. }
  303.  
  304. async function expandRemote(item) {
  305. const {url, rule} = item;
  306. const r = await download(url);
  307. const isJSON = /^content-type:.*?json\s*$/mi.test(r.responseHeaders);
  308. const doc = isJSON ?
  309. tryJSONparse(r.response) :
  310. new DOMParser().parseFromString(r.response, 'text/html');
  311. switch (typeof rule.q) {
  312. case 'string': {
  313. if (!isJSON)
  314. expandRemoteFromSelector(doc, item);
  315. return;
  316. }
  317. case 'function': {
  318. let urls;
  319. try {
  320. urls = await rule.q(doc, r.response, item);
  321. } catch (e) {}
  322. if (urls && urls.length) {
  323. urls = Array.isArray(urls) ? urls : [urls];
  324. expandFromUrls(urls, item);
  325. }
  326. return;
  327. }
  328. }
  329. }
  330.  
  331. async function expandRemoteFromSelector(doc, {rule, url, a}) {
  332. if (!doc)
  333. return;
  334. const el = doc.querySelector(rule.q);
  335. if (!el)
  336. return;
  337. let imageUrl = el.href || el.src || el.content;
  338. if (!imageUrl)
  339. return;
  340. if (rule.xhr)
  341. imageUrl = await downloadAsBase64({imageUrl, url});
  342. if (imageUrl)
  343. expand({a, url: imageUrl});
  344. }
  345.  
  346. function expandFromUrls(urls, {a}) {
  347. let observer;
  348. const isAlbum = urls.length > 1;
  349. if (isAlbum) {
  350. observer = albumObserver;
  351. a = a.insertAdjacentElement('afterEnd', document.createElement('div'));
  352. a.className = CLASS_ALBUM;
  353. }
  354. for (const url of urls) {
  355. if (url)
  356. a = expand({a, url, isAlbum}, observer) || a;
  357. }
  358. if (isAlbum) {
  359. new IntersectionObserver(onScroll, {root: a})
  360. .observe(a.appendChild(document.createElement('ins')));
  361. }
  362. }
  363.  
  364. function expandNextComment(el) {
  365. if (el)
  366. more.push(el);
  367. else
  368. more.shift();
  369. if (more.length === 1 || !el && more.length) {
  370. more[0].dispatchEvent(new MouseEvent('click', {bubbles: true}));
  371. setTimeout(expandNextComment, REQUEST_THROTTLE_MS);
  372. }
  373. }
  374.  
  375. function observeShowMore() {
  376. observeShowMore.timer = 0;
  377. if (document.querySelector(MORE_SELECTOR)) {
  378. for (const el of document.querySelectorAll(MORE_SELECTOR)) {
  379. scrollObserver.observe(el);
  380. }
  381. }
  382. }
  383.  
  384. function playOnClick(event, el, wasPaused) {
  385. if (!el) {
  386. setTimeout(playOnClick, 0, event, this, this.paused);
  387. } else if (el.paused === wasPaused) {
  388. wasPaused ? el.play() : el.pause();
  389. }
  390. }
  391.  
  392. function tryJSONparse(str) {
  393. try {
  394. return JSON.parse(str);
  395. } catch (e) {
  396. return undefined;
  397. }
  398. }
  399.  
  400. function download(options) {
  401. if (typeof options === 'string')
  402. options = {url: options};
  403. return new Promise((resolve, reject) => {
  404. GM_xmlhttpRequest(Object.assign({
  405. method: 'GET',
  406. onload: resolve,
  407. onerror: reject,
  408. }, options));
  409. });
  410. }
  411.  
  412. async function downloadAsBase64({imageUrl, url}) {
  413. let blob = (await download({
  414. url: imageUrl,
  415. headers: {
  416. 'Referer': url,
  417. },
  418. responseType: 'blob',
  419. })).response;
  420.  
  421. if (blob.type !== getMimeType(imageUrl))
  422. blob = blob.slice(0, blob.size, getMimeType(imageUrl));
  423.  
  424. return new Promise(resolve => {
  425. Object.assign(new FileReader(), {
  426. onload: e => resolve(e.target.result),
  427. }).readAsDataURL(blob);
  428. });
  429. }
  430.  
  431. function getMimeType(url) {
  432. const ext = (url.match(/\.(\w+)(\?.*)?$|$/)[1] || '').toLowerCase();
  433. return 'image/' + (ext === 'jpg' ? 'jpeg' : ext);
  434. }
  435.  
  436. function toggleAttribute(el, name, state) {
  437. if (state && !el.hasAttribute(name))
  438. el.setAttribute(name, '');
  439. else
  440. el.removeAttribute(name);
  441. }
  442.  
  443. function lazyCreateObserver(onIntersect, options, onCreate) {
  444. return new Proxy({}, {
  445. get(_target, k) {
  446. const observer = new IntersectionObserver(onIntersect, options);
  447. onCreate(observer);
  448. const v = observer[k];
  449. return typeof v === 'function' ? v.bind(observer) : v;
  450. },
  451. });
  452. }
  453.  
  454. function unobserveOnLoad() {
  455. this.removeEventListener('load', unobserveOnLoad);
  456. const {observer} = this[GM_info.script.name] || {};
  457. if (observer)
  458. observer.unobserve(this);
  459. delete this[GM_info.script.name];
  460. }
  461.  
  462. function includedInThis(needle) {
  463. return this.includes(needle);
  464. }
  465.  
  466. })();