Reddit expand media and comments

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

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