Reddit expand media and comments

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

当前为 2019-09-21 提交的版本,查看 最新版本

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