Reddit expand media and comments

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

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

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