Reddit expand media and comments

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

目前为 2019-04-21 提交的版本。查看 最新版本

// ==UserScript==
// @name           Reddit expand media and comments
// @description    Shows pictures and some videos right after the link, loads and expands comment threads.

// @version        0.2.3

// @author         wOxxOm
// @namespace      wOxxOm.scripts
// @license        MIT License

// @match          *://*.reddit.com/*

// @grant          GM_addStyle
// @grant          GM_xmlhttpRequest

// @connect        imgur.com
// @connect        gfycat.com
// @connect        streamable.com
// @connect        instagram.com
// @connect        ibb.co
// @connect        prntscr.com
// @connect        prnt.sc
// ==/UserScript==

const CLASS = 'reddit-inline-media';
const CLASS_ALBUM = CLASS + '-album';
const OVERFLOW_ATTR = 'data-overflow';
const MORE_SELECTOR = '[id^="moreComments-"] p, .morecomments a';
const REQUEST_THROTTLE_MS = location.hostname.startsWith('old.') ? 500 : 100;

const RULES = [{
  r: /^https?:\/\/(i\.)?imgur\.com\/(?:a|gallery)\/(\w+)$/i,
  s: 'https://imgur.com/ajaxalbums/getimages/$2/hit.json?all=true',
  q: json =>
    Array.from(((json || {}).data || {}).images || [])
      .map(img => img && `https://i.imgur.com/${img.hash}${img.ext}`),
}, {
  r: /^https?:\/\/(i\.)?imgur\.com\/\w+$/i,
  q: 'link[rel="image_src"], meta[name="twitter:player:stream"]',
}, {
  r: /^https?:\/\/streamable\.com\/.+/i,
  q: 'video',
}, {
  r: /^https?:\/\/gfycat\.com\/.+/i,
  q: 'source[src*=".webm"]',
}, {
  r: /^https?:\/\/(www\.)?instagram\.com\/p\/[^/]+\/?$/i,
  q: 'meta[property="og:image"]',
}, {
  r: /^https?:\/\/ibb\.co\/\w+$/i,
  q: 'meta[property="og:image"]',
}, {
  r: /^https?:\/\/prntscr\.com\/(\w+)$/i,
  s: 'https://prnt.sc/$1',
  q: 'meta[property="og:image"]',
  xhr: true,
}, {
  r: /^https?:\/\/prnt\.sc\/(\w+)$/i,
  q: 'meta[property="og:image"]',
  xhr: true,
}, {
  r: /\.gifv(\?.*)?$/i,
  s: '.mp4',
}, {
  // keep this one at the end of the list
  r: /\.(jpe?g|png|gif|webm|mp4)(\?.*)?$/i,
}];

// language=CSS
GM_addStyle(`
  .${CLASS} {
    max-width: 100%;
    display: block;
  }
  .${CLASS}[data-src] {
    padding-top: 400px;
  }
  .${CLASS}:hover {
    outline: 2px solid #3bbb62;
  }
  .${CLASS_ALBUM} {
    overflow-y: auto;
    max-height: calc(100vh - 100px);
    margin: .5em 0;
  }
  .${CLASS_ALBUM}[${OVERFLOW_ATTR}] {
    -webkit-mask-image: linear-gradient(white 75%, transparent);
    mask-image: linear-gradient(white 75%, transparent);
  }
  .${CLASS_ALBUM}[${OVERFLOW_ATTR}]:hover {
    -webkit-mask-image: none;
    mask-image: none;
  }
  .${CLASS_ALBUM} > :nth-child(n + 2) {
    margin-top: 1em;
  }
`);

const isChrome = navigator.userAgent.includes('Chrom');
const more = [];

let scrollObserver = lazyCreateObserver(onScroll, {rootMargin: '200% 0px'},
  obs => scrollObserver = obs);

let albumObserver = lazyCreateObserver(onScroll, {rootMargin: '200px 0px'},
  obs => albumObserver = obs);

new MutationObserver(onMutation)
  .observe(document.body, {subtree: true, childList: true});

onMutation([{
  addedNodes: [document.body]
}]);

function onMutation(mutations) {
  var items = [];
  var someElementsAdded = false;
  for (var i = 0, m; (m = mutations[i++]);) {
    for (var j = 0, added = m.addedNodes, node; (node = added[j++]);) {
      if (!node.localName)
        continue;
      someElementsAdded = true;
      if (node.localName === 'a') {
        var rule = findMatchingRule(node);
        if (rule)
          items.push(rule);
        continue;
      }
      if (!node.firstElementChild)
        continue;
      var aa = node.getElementsByTagName('a');
      for (var k = 0, a; (a = aa[k++]);) {
        const data = findMatchingRule(a);
        if (data)
          items.push(data);
      }
    }
  }
  if (someElementsAdded)
    debounce(observeShowMore);
  if (items.length)
    setTimeout(maybeExpand, 0, items);
}

function onScroll(entries, observer) {
  for (const e of entries) {
    let el = e.target;
    if (el.localName === 'ins') {
      toggleAttribute(el.parentNode, OVERFLOW_ATTR, !e.isIntersecting);
      continue;
    }
    if (!e.isIntersecting) {
      const rect = e.boundingClientRect;
      if ((rect.bottom < -200 || rect.top > innerHeight + 200) &&
          el.src && !el.dataset.src && el[GM_info.script.name]) {
        delete el[GM_info.script.name];
        el.dataset.src = el.src;
        el.removeAttribute('src');
        el.removeEventListener('load', unobserveOnLoad);
      }
      continue;
    }
    const isImage = el.localName === 'img';
    if (isImage && el.dataset.src || el.localName === 'video') {
      el.src = el.dataset.src;
      el[GM_info.script.name] = {observer};
      el.addEventListener(isImage ? 'load' : 'loadedmetadata', unobserveOnLoad);
      delete el.dataset.src;
      continue;
    }
    if (el.localName === 'a') {
      // switch to an unfocusable element to prevent the link
      // from stealing focus and scrolling the view
      const el2 = document.createElement('span');
      el2.setAttribute('onclick', el.getAttribute('onclick'));
      el2.setAttribute('id', el.id);
      el.parentNode.replaceChild(el2, el);
      el = el2;
    }
    expandNextComment(el);
  }
}

function findMatchingRule(a) {
  var url = a.href;
  for (var i = 0; i < RULES.length; i++) {
    var rule = RULES[i];
    var r = rule.r;
    if (typeof r === 'string') {
      if (!url.includes(r))
        continue;
    } else {
      if (!r.test(url))
        continue;
      var s = rule.s;
      if (s)
        url = url.replace(r, s);
    }
    return {
      a,
      url,
      q: rule.q,
      xhr: rule.xhr,
    };
  }
}

function maybeExpand(items) {
  for (const item of items) {
    const {a, q} = item;
    const {href} = a;
    const text = a.textContent.trim();
    if (
      text &&
      !a.getElementsByTagName('img')[0] &&
      (
        !text.startsWith('http') ||
        !text.endsWith('...') ||
        !/^https?:\/\/\S+?\.{3}$/.test(text)
      ) &&
      !a.closest(
        '.scrollerItem,' +
        '[contenteditable="true"],' +
        `a[href="${href}"] + * a[href="${href}"],` +
        `img[src="${href}"] + * a[href="${href}"]`) &&
      (
        // don't process insides of a post except for its text
        !a.closest('[data-test-id="post-content"]') ||
        a.closest('[data-click-id="text"]')
      )
    ) {
      try {
        (q ? expandRemote : expand)(item);
      } catch (e) {
        // console.debug(e, item);
      }
    }
  }
}

function expand({a, url = a.href, isAlbum}, observer = scrollObserver) {
  const isVideo = /(webm|gifv|mp4)(\?.*)?$/i.test(url);
  const el = document.createElement(isVideo ? 'video' : 'img');
  el.dataset.src = url;
  el.className = CLASS;
  a.insertAdjacentElement(isAlbum ? 'beforeEnd' : 'afterEnd', el);
  if (isVideo) {
    el.controls = true;
    el.preload = 'metadata';
    if (isChrome)
      el.addEventListener('click', playOnClick);
  }
  observer.observe(el);
  return !isAlbum && el;
}

async function expandRemote(item) {
  const {url, q} = item;
  const r = await download(url);
  const isJSON = /^content-type:.*?json\s*$/mi.test(r.responseHeaders);
  const doc = isJSON ?
    tryJSONparse(r.response) :
    new DOMParser().parseFromString(r.response, 'text/html');
  switch (typeof q) {
    case 'string': {
      if (!isJSON)
        expandRemoteFromSelector(doc, item);
      return;
    }
    case 'function': {
      let urls = await q(doc, r.response);
      if (urls && urls.length) {
        urls = Array.isArray(urls) ? urls : [urls];
        expandFromUrls(urls, item);
      }
      return;
    }
  }
}

async function expandRemoteFromSelector(doc, {q, xhr, url, a}) {
  if (!doc)
    return;
  const el = doc.querySelector(q);
  if (!el)
    return;
  let imageUrl = el.href || el.src || el.content;
  if (!imageUrl)
    return;
  if (xhr)
    imageUrl = await downloadAsBase64({imageUrl, url});
  if (imageUrl)
    expand({a, url: imageUrl});
}

function expandFromUrls(urls, {a, url}) {
  let observer;
  const isAlbum = urls.length > 1;
  if (isAlbum) {
    observer = albumObserver;
    a = a.insertAdjacentElement('afterEnd', document.createElement('div'));
    a.className = CLASS_ALBUM;
  }
  for (const url of urls) {
    if (url)
      a = expand({a, url, isAlbum}, observer) || a;
  }
  if (isAlbum) {
    new IntersectionObserver(onScroll, {root: a})
      .observe(a.appendChild(document.createElement('ins')));
  }
}

function expandNextComment(el) {
  if (el)
    more.push(el);
  else
    more.shift();
  if (more.length === 1 || !el && more.length) {
    more[0].dispatchEvent(new MouseEvent('click', {bubbles: true}));
    setTimeout(expandNextComment, REQUEST_THROTTLE_MS);
  }
}

function observeShowMore() {
  if (document.querySelector(MORE_SELECTOR)) {
    for (const el of document.querySelectorAll(MORE_SELECTOR)) {
      scrollObserver.observe(el);
    }
  }
}

function playOnClick(event, el, wasPaused) {
  if (!el) {
    setTimeout(playOnClick, 0, event, this, this.paused);
  } else if (el.paused === wasPaused) {
    wasPaused ? el.play() : el.pause();
  }
}

function debounce(fn, timeout = 0, ...args) {
  clearTimeout(fn.__timeout);
  fn.__timeout = setTimeout(fn, timeout, ...args);
}

function tryJSONparse(str) {
  try {
    return JSON.parse(str);
  } catch (e) {
    return undefined;
  }
}

function download(options) {
  if (typeof options === 'string')
    options = {url: options};
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest(Object.assign({
      method: 'GET',
      onload: resolve,
      onerror: reject,
    }, options));
  });
}

async function downloadAsBase64({imageUrl, url}) {
  let blob = (await download({
    url: imageUrl,
    headers: {
      'Referer': url,
    },
    responseType: 'blob',
  })).response;

  if (blob.type !== getMimeType(imageUrl))
    blob = blob.slice(0, blob.size, getMimeType(imageUrl));

  return new Promise(resolve => {
    Object.assign(new FileReader(), {
      onload: e => resolve(e.target.result)
    }).readAsDataURL(blob);
  });
}

function getMimeType(url) {
  const ext = (url.match(/\.(\w+)(\?.*)?$|$/)[1] || '').toLowerCase();
  return 'image/' + (ext === 'jpg' ? 'jpeg' : ext);
}

function toggleAttribute(el, name, state) {
  if (state && !el.hasAttribute(name))
    el.setAttribute(name, '');
  else
    el.removeAttribute(name);
}

function lazyCreateObserver(onIntersect, options, onCreate) {
  return new Proxy({}, {
    get(_target, k) {
      const observer = new IntersectionObserver(onIntersect, options);
      onCreate(observer);
      const v = observer[k];
      return typeof v === 'function' ? v.bind(observer) : v;
    },
  });
}

function unobserveOnLoad() {
  this.removeEventListener('load', unobserveOnLoad);
  const {observer} = this[GM_info.script.name] || {};
  if (observer)
    observer.unobserve(this);
  delete this[GM_info.script.name];
}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址