Reddit expand media and comments

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

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

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