Google Infinite Scroll with Country Flags and Favicons

Infinite scroll with country flags and site favicons for Google search results.

// ==UserScript==
// @name         Google Infinite Scroll with Country Flags and Favicons
// @namespace    https://gist.github.com/narcolepticinsomniac
// @version      2.1
// @description  Infinite scroll with country flags and site favicons for Google search results.
// @author       narcolepticinsomniac
// @include      /https:\/\/www\.google\.(com?(\.[a-z]{2})?|[a-z]{2})\/.*/
// @run-at       document-start
// @grant        GM_addStyle
// @grant        GM_openInTab
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_addValueChangeListener
// @grant        GM_removeValueChangeListener
// ==/UserScript==

const docEl = document.documentElement;
const itemtype = docEl.getAttribute('itemtype');

if (!itemtype || (itemtype && !/searchresultspage/i.test(itemtype))) return;

let prevSib;
let mouseDown;
let defer;
let suppress;
let clickedEl;
let nextURL;
let iframeHeight;
let increment = 1;
const uniqueTabID = Date.now();
const storage = storageCheck();
const $ = document.querySelector.bind(document);
const $$ = document.querySelectorAll.bind(document);
const imageSearch = /tbm=isch/i.test(location.href);
const newsSearch = /tbm=nws/i.test(location.href);
const shopSearch = /tbm=shop/i.test(location.href);
const bookSearch = /tbm=bks/i.test(location.href);
const hasIcons = !/tbm=(isch|bks|nws|shop|fin)/.test(location.href);
const countryCodes =
      ['AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AN', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW',
       'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO',
       'BR', 'BS', 'BT', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL',
       'CM', 'CN', 'CO', 'CR', 'CT', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM',
       'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER', 'ES', 'ET', 'EU', 'FI', 'FJ', 'FK', 'FM', 'FO',
       'FR', 'GA', 'GB', 'GD', 'GE', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GQ', 'GR', 'GS', 'GT',
       'GU', 'GW', 'GY', 'HK', 'HN', 'HR', 'HT', 'HU', 'IC', 'ID', 'IE', 'IL', 'IM', 'IN', 'IQ',
       'IR', 'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR',
       'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA',
       'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS',
       'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO',
       'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PN', 'PR', 'PS',
       'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG',
       'SH', 'SI', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC',
       'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ',
       'UA', 'UG', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE',
       'YT', 'ZA', 'ZM', 'ZW'];

if (self === top) {
  const nextURLID = `nextURL_${uniqueTabID}`;
  const iframeHeightID = `iframeHeight_${uniqueTabID}`;
  history.scrollRestoration = 'manual';
  if (hasIcons) docEl.classList.add('has-icons');
  if (shopSearch) docEl.classList.add('shop');

  document.onreadystatechange = () => {
    if (document.readyState === 'interactive') {
      if (hasIcons) icons();
      const next = $('#pnnext');
      if (next) {
        nextURL = next.href;
        prevSib = $('#res');
        insertIframe();
      } else {
        docEl.classList.add('scroll-end');
      }

      const scrollBtn = document.createElement('div');
      scrollBtn.id = 'scrollBtn';
      document.body.appendChild(scrollBtn);
      scrollBtn.onclick = () => window.scroll({top: 0, behavior: 'smooth'});
      checkScroll();

      if (shopSearch) {
        const filter = $('.sh-vrd__container');
        const related = $('.sh-vrd__container + div:not([class])');
        if (filter) filter.classList.add('is-hidden');
        if (related) related.classList.add('is-hidden');
        const carousel = $$('g-scrolling-carousel');
        for (let i = 0; i < carousel.length; i++) {
          carousel[i].closest('div').classList.add('is-hidden');
        }
      }

      const scrollStop = stop => {
        if (!stop || typeof stop !== 'function') return;
        let isScrolling;
        window.onscroll = () => {
          checkScroll();
          window.clearTimeout(isScrolling);
          isScrolling = setTimeout(() => {
            stop();
          }, 66);
        };
      };

      scrollStop(() => {
        if (imageSearch || $('.iframe-loading') || $('.scroll-end')) return;
        const prevBottom = prevSib.getBoundingClientRect().bottom;
        if (prevBottom < window.innerHeight * 2) {
          if (!mouseDown) {
            docEl.classList.add('iframe-loading');
            insertIframe();
          } else {
            defer = true;
          }
        }
      });

      GM_addValueChangeListener(iframeHeightID, (name, oldValue, newValue) => {
        if (!newValue) return;
        if (/&/.test(newValue)) {
          const values = newValue.split('&');
          const height = values[0];
          const frameName = values[1];
          $(`iframe[name="${frameName}"]`).style.height = `${height}px`;
          GM_deleteValue(iframeHeightID);
          console.log('height changed by resize observer');
          return;
        }
        iframeHeight = newValue;
        GM_deleteValue(iframeHeightID);
      });

      GM_addValueChangeListener(nextURLID, (name, oldValue, newValue) => {
        if (!newValue) return;
        if (newValue === 'end-scroll') {
          docEl.classList.add('scroll-end');
          GM_deleteValue(nextURLID);
          GM_removeValueChangeListener(nextURLID);
          const scrollEnd = document.createElement('div');
          scrollEnd.id = 'scroll-end';
          scrollEnd.textContent = 'Edge of infinity';
          prevSib.parentNode.appendChild(scrollEnd);
          return;
        }
        nextURL = newValue;
        GM_deleteValue(nextURLID);
      });
    }
  };

  window.onblur = () => {
    const menu = $('.action-menu > a[aria-expanded="true"]');
    if (menu && !menu.closest('#res').matches(':hover')) menu.click();
  };
}

if (self !== top && /infinite-scroll-iframe/.test(name)) {
  const values = name.split('_');
  const pageNumber = +values[1] + 1;
  const id = values[2];
  const nextURLID = `nextURL_${id}`;
  const iframeHeightID = `iframeHeight_${id}`;
  docEl.classList.add('infinite-scroll-iframe');
  if (newsSearch) docEl.classList.add('news-iframe');
  if (shopSearch) docEl.classList.add('shop-iframe');
  if (hasIcons) docEl.classList.add('has-icons-iframe');

  document.onreadystatechange = () => {
    if (document.readyState === 'interactive') {
      const next = $('#pnnext');
      nextURL = next ? next.href : 'end-scroll';
      GM_setValue(nextURLID, nextURL);
      const divider = document.createElement('div');
      divider.id = 'page-count-header';
      divider.textContent = `Page ${pageNumber}`;
      document.body.insertBefore(divider, document.body.firstElementChild);
      if (hasIcons) icons();

      let reveal;
      if (shopSearch) reveal = $('#res div[class$="result"][data-gpcid]');
      else if (newsSearch || bookSearch) reveal = $('#rso > div');
      else reveal = $('#res .g');
      reveal = reveal.parentNode;
      function revealResults() {
        if (reveal) reveal.classList.add('search-results-wrapper');
        reveal = reveal.parentNode;
        if (reveal !== document.body) revealResults.call(this);
      }

      revealResults();

      if (newsSearch || bookSearch) {
        const results = $$('#rso > div');
        for (let i = 0; i < results.length; i++) {
          results[i].classList.add('search-result');
        }
      }

      // if (shopSearch) {
      //   const filter = $('.sh-vrd__container');
      //   const related = $('.sh-vrd__container + div:not([class])');
      //   if (filter) filter.classList.add('is-hidden');
      //   if (related) related.classList.add('is-hidden');
      //   const carousel = $$('g-scrolling-carousel');
      //   for (let i = 0; i < carousel.length; i++) {
      //     carousel[i].closest('div').classList.add('is-hidden');
      //   }
      // }

      iframeHeight = $('body').scrollHeight;
      GM_setValue(iframeHeightID, iframeHeight);

      new ResizeObserver(() => {
        const newHeight = $('body').scrollHeight;
        if (newHeight === iframeHeight) return;
        iframeHeight = newHeight;
        GM_setValue(iframeHeightID, `${newHeight}&${name}`);
      }).observe($('body'));
    }
  };

  window.onblur = () => {
    const menu = $('.action-menu > a[aria-expanded="true"]');
    if (menu && !menu.closest('#res').matches(':hover')) menu.click();
  };
}

if (!imageSearch) {
  window.addEventListener('mousedown', e => {
    if (e.button > 0) return;
    mouseDown = true;
    clickedEl = e.target;
  }, true);

  window.addEventListener('mouseup', e => {
    if (e.button > 0) return;
    mouseDown = false;
    if (defer) {
      defer = false;
      const prevBottom = prevSib.getBoundingClientRect().bottom;
      if (prevBottom < window.innerHeight * 2) {
        docEl.classList.add('iframe-loading');
        insertIframe();
      }
      return;
    }
    if (!e.target.closest || e.target !== clickedEl) return;
    const link = (e.target.closest('#res a, #rhs a'));
    if (!link ||
    (link.getAttribute('href') || '').match(/^(javascript|#|$)/) ||
    link.href.replace(/#.*/, '') === location.href.replace(/#.*/, '')) return;

    GM_openInTab(link.href, {setParent: true, active: true});
    suppress = true;
    prevent(e);
  }, true);

  window.addEventListener('click', prevent, true);
}

function checkScroll() {
  window.scrollY > 0 ? docEl.classList.add('scrolled') :
    docEl.classList.remove('scrolled');
}

function storageCheck() {
  try {
    localStorage.setItem('check-storage', 'test');
    localStorage.removeItem('check-storage', 'test');
  } catch (e) {
    return false;
  }
  return true;
}

function prevent(e) {
  if (!suppress) return;
  e.preventDefault();
  e.stopPropagation();
  e.stopImmediatePropagation();
  setTimeout(() => {
    suppress = false;
  }, 100);
}

function insertIframe() {
  const lastIframe = $('#res ~ .infinite-scroll-iframe:last-of-type');
  if (lastIframe) lastIframe.style.height = `${iframeHeight}px`;
  const iframe = document.createElement('iframe');
  iframe.frameBorder = '0';
  iframe.height = '0';
  iframe.width = '100%';
  iframe.src = nextURL;
  iframe.name = `infinite-scroll-iframe_${increment}_${uniqueTabID}`;
  iframe.id = `infinite-scroll-iframe-${increment}`;
  iframe.className = 'infinite-scroll-iframe';
  if (shopSearch) iframe.classList.add('shop-iframe');
  prevSib.parentNode.insertBefore(iframe, prevSib.nextSibling);
  iframe.onload = () => {
    docEl.classList.remove('iframe-loading');
  };
  prevSib = iframe;
  increment++;
}

function icons() {
  const g = $$('.g');
  for (let i = 0; i < g.length; i++) {
    if (g[i].querySelector('.g')) continue;
    const site = g[i].querySelector('cite');
    if (!site) continue;
    g[i].classList.add('has-icons');
    const domain = /^youtube/i.test(site.textContent) ? 'youtube.com' :
    site.textContent.replace(/https?:\/\//, '').split(/ |\//)[0];
    const icon = document.createElement('img');
    icon.height = '16';
    icon.width = '16';
    icon.title = domain;
    icon.className = 'site-favicon';
    icon.src = `https://icons.duckduckgo.com/ip2/${domain}.ico`;
    g[i].appendChild(icon);
    if (g[i] === g[g.length - 1]) checkFavicons();
    const flag = document.createElement('div');
    const storedDomain = storage ? localStorage.getItem(domain) : null;
    if (storedDomain) {
      const code = storedDomain.slice(0, 2);
      const title = storedDomain.slice(3 - storedDomain.length);
      flag.title = title;
      if (code === 'FU') {
        flag.classList.add('no-geo-info');
      } else {
        const codeToIndex = c => c === code;
        const posX = countryCodes.findIndex(codeToIndex) * -16;
        flag.style.backgroundPosition = `${posX}px 0`;
      }
      flag.className = 'country-flag';
      g[i].appendChild(flag);
    } else {
      fetch(`https://dns.google/resolve?name=${domain}`).then(ip => ip.json())
        .then(ipJSON => {
          const ip = ipJSON.Answer && /\.[a-z]/.test(domain) ?
            ipJSON.Answer[ipJSON.Answer.length - 1].data : domain;
          // fallback API: 'https://api.ip2country.info/ip?', countryCode, countryName
          // preferred API claims good policies regarding limits and privacy
          // https://github.com/apilayer/freegeoip/issues/4#issuecomment-394810848
          // AFAICT, fallback has none besides 'free for open source'
          fetch(`https://freegeoip.app/json/${ip}`).then(geo => geo.json())
          .then(geoJSON => {
            let code = geoJSON.country_code;
            const name = geoJSON.country_name;
            const title = name ? `${name}\nIP: ${ip}` : `No geo info\nIP: ${ip}`;
            flag.title = title;
            if (!code) {
              code = 'FU';
              flag.classList.add('no-geo-info');
            } else {
              const codeToIndex = c => c === code;
              const posX = countryCodes.findIndex(codeToIndex) * -16;
              flag.style.backgroundPosition = `${posX}px 0`;
            }
            flag.className = 'country-flag';
            g[i].appendChild(flag);
            if (storage) localStorage.setItem(domain, `${code} ${title}`);
          }).catch(() => {
            flag.title = 'No geo info\nNo IP response';
            flag.className = 'country-flag no-info';
            g[i].appendChild(flag);
          });
        }).catch(() => {
          flag.title = 'No geo info\nNo IP response';
          flag.className = 'country-flag no-info';
          g[i].appendChild(flag);
        });
    }
  }
}

function checkFavicons() {
  setTimeout(() => {
    const favicons = $$('.site-favicon');
    for (let i = 0; i < favicons.length; i++) {
      const favicon = favicons[i];
      if (!favicon.complete) {
        favicon.classList.add('failed');
        favicon.src = 'https://i.imgur.com/avouDbZ.png';
      }
    }
  }, 3000);
}

GM_addStyle(`html.infinite-scroll-iframe,
html.infinite-scroll-iframe body {
  height: min-content!important;
}
html.infinite-scroll-iframe,
html.infinite-scroll-iframe body {
  overflow-x: hidden!important;
}
html.infinite-scroll-iframe #cnt {
  padding: 0!important;
  min-height: unset!important;
}
iframe.infinite-scroll-iframe.shop-iframe {
  margin-left: -1px!important;
}
html.infinite-scroll-iframe.shop-iframe body {
  padding-left: 1px!important;
}
html.infinite-scroll-iframe #center_col {
  width: 100%!important;
  margin: 0!important;
  padding: 0!important;
  border: 0!important;
}
html.infinite-scroll-iframe .search-results-wrapper {
  width: unset!important;
  min-width: unset!important;
}
html.infinite-scroll-iframe #cnt > *:not(.search-results-wrapper),
html.infinite-scroll-iframe #searchform,
html.infinite-scroll-iframe #center_col > *:not(#res),
html.infinite-scroll-iframe.shop-iframe #leftnavc,
html.infinite-scroll-iframe.shop-iframe #bcenter,
html:not(.scroll-end) #res ~ *:not(.infinite-scroll-iframe),
html.infinite-scroll-iframe .search-results-wrapper > *:not(.search-results-wrapper):not(.g):not([data-gpcid]):not(.search-result),
html.infinite-scroll-iframe body > *:not(#page-count-header):not(.search-results-wrapper),
.is-hidden,
.sh-sp__btn,
#foot,
#footcnt,
#bottomads {
  display: none!important;
}
html.infinite-scroll-iframe #center_col {
  margin-left: 0!important;
}
iframe.infinite-scroll-iframe {
  width: calc(100% + 2px)!important;
}
html.infinite-scroll-iframe > body > #page-count-header {
  border: 0!important;
  text-align: center!important;
  padding: 2px 0!important;
  margin: 0 0 28px!important;
  background: linear-gradient(90deg, transparent 0%, hsla(0, 0%, 50%, .4) 50%, transparent 100%);
  color: currentColor!important;
  letter-spacing: .5px!important;
}
html.infinite-scroll-iframe.shop-iframe > body > #page-count-header {
  margin: 0!important;
}
html.infinite-scroll-iframe.news-iframe > body > #page-count-header {
  margin: 6px 0 16px!important;
}
.has-icons iframe.infinite-scroll-iframe {
  margin-left: -60px!important;
  width: calc(100% + 80px)!important;
}
.has-icons-iframe #page-count-header {
  width: calc(100% - 80px)!important;
}
.has-icons-iframe body {
  padding-left: 60px!important;
}
#fbar {
  padding-top: 12px!important;
}
.site-favicon {
  position: absolute!important;
  top: 0!important;
  left: -30px!important;
}
.country-flag {
  background-image: url(https://i.imgur.com/ubidBJK.png)!important;
  background-repeat: no-repeat!important;
  background-size: 3904px 16px!important;
  height: 16px!important;
  width: 16px!important;
  position: absolute!important;
  top: 0!important;
  left: -60px!important;
  display: inline-block!important;
}
.country-flag.no-geo-info {
  background-image: url(https://i.imgur.com/Q0Rpf0z.png)!important;
  background-size: 14px 14px!important;
  background-position: center!important;
}
.country-flag.no-info {
  background-image: url(https://i.imgur.com/HOMEzUw.png)!important;
  background-size: 14px 14px!important;
  background-position: center!important;
}
.mod + .g {
  padding-left: 36px!important;
}
.mod + .g.has-icons .site-favicon,
.mod .g.has-icons .site-favicon {
  top: 0!important;
  left: 10px!important;
}
.mod + .g.has-icons .country-flag,
.mod .g.has-icons .country-flag {
  top: 30px!important;
  left: 10px!important;
}
.g.has-icons {
  position: relative!important;
  max-width:calc(100vw - 60px)!important;
}
.r img[width="16"] {
  display: none!important;
}
#scroll-end {
  font-size: 16px!important;
  font-style: italic!important;
  color: currentColor!important;
  text-align: center!important;
  padding: 30px!important;
  width: 100%!important;
}
#scrollBtn {
  background: url(https://i.imgur.com/iceQLc3.png)no-repeat!important;
  height: 20px!important;
  width: 20px!important;
  position: fixed!important;
  bottom: 0!important;
  right: 2px!important;
  text-decoration: none!important;
  transition: opacity .2s ease-in-out!important;
  will-change: opacity!important;
  opacity: 0!important;
  pointer-events: none!important;
  z-index: 2147483647!important;
  cursor: pointer!important;
}
.scrolled #scrollBtn {
  opacity: .85!important;
  pointer-events: all!important;
}
.scrolled #scrollBtn:hover {
  opacity: 1!important;
}`);