yande.re refine

Refining yande.re

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name           yande.re refine
// @namespace      https://greasyfork.org/scripts/397612-yande-re-refine
// @description    Refining yande.re
// @include        *://behoimi.org/*
// @include        *://www.behoimi.org/*
// @include        *://*.donmai.us/*
// @include        *://konachan.tld/*2
// @include        *://yande.re/*
// @include        *://chan.sankakucomplex.com/*
// @version        2021.08.28.a
// @grant          none
// ==/UserScript==

// You can found alternatives in https://gist.github.com/jimmywarting/ac1be6ea0297c16c477e17f8fbe51347
const CORS_PROXY = 'https://cors-anywhere.herokuapp.com/'
const CORS_ENABLED = false
const CACHE_ENABLED = true
const CACHE_ONLY_LIKE = true

const STORAGE_KEY = 'yandere-refine-liked'
const INDEXED_DB_NAME = 'yandere-refine-cache'
const SUGGEST_WIDTH = 300
// Minimum amount of window left to scroll, maintained by loading more pages.
const scrollBuffer = 600
// Time (in ms) the script will wait for a response from the next page before attempting to fetch the page again.  If the script gets trapped in a loop trying to load the next page, increase this value.
const timeToFailure = 15000

//= ===========================================================================
//= ========================Script initialization==============================
//= ===========================================================================

let nextPage, mainTable, mainParent, timeout, iframe
let previewIframe, previewImage, previewImageDiv, previewDialog
const imagesList = []
let currentImage = 0
const pending = ref(false, v =>
  document.getElementById('loader').classList.toggle('hidden', !v),
)
const likedList = readLikeList()
const viewingFavorites = isViewingFavorites()
let db
const memcache = {}

injectGlobalStyle()
initialize()

function initialize() {
  // Stop if inside an iframe
  if (window !== window.top || scrollBuffer === 0) return

  // Stop if no "table"
  mainTable = getMainTable(document)
  if (!mainTable) return

  injectStyle()
  initDOM()
  addImages(getImages())

  // Stop if no more pages
  nextPage = getNextPage(document)
  if (!nextPage) return

  // Hide the blacklist sidebar, since this script breaks the tag totals and post unhiding.
  const sidebar = document.getElementById('blacklisted-sidebar')
  if (sidebar) sidebar.style.display = 'none'

  // Other important variables:
  mainParent = mainTable.parentNode
  pending.value = false

  iframe = document.createElement('iframe')
  iframe.width = iframe.height = 0
  iframe.style.visibility = 'hidden'
  document.body.appendChild(iframe)

  // Slight delay so that Danbooru's initialize_edit_links() has time to hide all the edit boxes on the Comment index
  iframe.addEventListener(
    'load',
    (e) => {
      setTimeout(appendNewContent, 100)
    },
    false,
  )

  window.addEventListener('scroll', testScrollPosition, false)
  testScrollPosition()

  if (CACHE_ENABLED) {
    const script = document.createElement('script')
    script.src = 'https://unpkg.com/dexie@latest/dist/dexie.js'
    script.onload = () => {
      // eslint-disable-next-line no-undef
      db = new Dexie(INDEXED_DB_NAME)

      db.version(1).stores({
        images: 'id, blob',
      })
    }
    document.head.appendChild(script)
  }
}

//= ===========================================================================
//= ===========================Script functions================================
//= ===========================================================================

function awaitImage(img) {
  if (img.completed)
    return
  return new Promise((resolve) => {
    img.onload = () => {
      resolve(img)
      img.onload = undefined
    }
  })
}

async function setCache(id, img) {
  if (!db)
    return false

  console.log(`Caching ${id}`)
  const canvas = document.createElement('canvas')
  canvas.width = img.naturalWidth
  canvas.height = img.naturalHeight

  const ctx = canvas.getContext('2d')
  ctx.drawImage(img, 0, 0)

  const blob = await new Promise((resolve) => {
    canvas.toBlob(resolve)
  })

  await db.images.put({ id, blob })
  return true
}

async function getCache(id) {
  if (memcache[id])
    return URL.createObjectURL(memcache[id])
  if (!db)
    return
  const data = await db.images.get(id)
  if (data && data.blob) {
    console.log(`Loading cache of ${id}`)
    memcache[id] = data.blob
    return URL.createObjectURL(data.blob)
  }
}

async function removeCache(id) {
  if (!db)
    return
  await db.images.delete(id)
}

// Some pages match multiple "tables", so order is important.
function getMainTable(source) {
  // Special case: Sankaku post index with Auto Paging enabled
  if (
    /sankaku/.test(location.host)
    && /auto_page=1/.test(document.cookie)
    && /^(post(\/|\/index\/?)?|\/)$/.test(location.pathname)
  )
    return null

  const xpath = [
    './/div[@id=\'c-favorites\']//div[@id=\'posts\']', // Danbooru (/favorites)
    './/div[@id=\'posts\']/div', // Danbooru; don't want to fall through to the wrong xpath if no posts ("<article>") on first page.
    './/div[@id=\'c-pools\']//section/article/..', // Danbooru (/pools/####)

    './/div[@id=\'a-index\']/table[not(contains(@class,\'search\'))]', // Danbooru (/forum_topics, ...), take care that this doesn't catch comments containing tables
    './/div[@id=\'a-index\']', // Danbooru (/comments, ...)

    './/table[contains(@class,\'highlight\')]', // large number of pages
    './/div[@id=\'content\']/div/div/div/div/span[@class=\'author\']/../../../..', // Sankaku: note search
    './/div[contains(@id,\'comment-list\')]/div/..', // comment index
    './/*[not(contains(@id,\'popular\'))]/span[contains(@class,\'thumb\')]/a/../..', // post/index, pool/show, note/index
    './/li/div/a[contains(@class,\'thumb\')]/../../..', // post/index, note/index
    './/div[@id=\'content\']//table/tbody/tr[@class=\'even\']/../..', // user/index, wiki/history
    './/div[@id=\'content\']/div/table', // 3dbooru user records
    './/div[@id=\'forum\']', // forum/show
  ]

  for (let i = 0; i < xpath.length; i++) {
    // eslint-disable-next-line no-func-assign
    getMainTable = (function(query) {
      return function(source) {
        const mTable = new XPathEvaluator().evaluate(
          query,
          source,
          null,
          XPathResult.FIRST_ORDERED_NODE_TYPE,
          null,
        ).singleNodeValue
        if (!mTable) return mTable

        // Special case: Danbooru's /favorites lacks the extra DIV that /posts has, which causes issues with the paginator/page break.
        const xDiv = document.createElement('div')
        xDiv.style.overflow = 'hidden'
        mTable.parentNode.insertBefore(xDiv, mTable)
        xDiv.appendChild(mTable)
        return xDiv
      }
    })(xpath[i])

    const result = getMainTable(source)
    if (result) {
      // alert("UPW main table query: "+xpath[i]+"\n\n"+location.pathname);
      return result
    }
  }

  return null
}

function getNextPage(doc = document) {
  return (doc.querySelector('a.next_page') || {}).href
}

function testScrollPosition() {
  if (!nextPage) return

  // Take the max of the two heights for browser compatibility
  if (
    !pending.value
    && window.pageYOffset + window.innerHeight + scrollBuffer
      > Math.max(
        document.documentElement.scrollHeight,
        document.documentElement.offsetHeight,
      )
  ) {
    console.log(`loading ${nextPage}`)
    pending.value = true
    timeout = setTimeout(() => {
      pending.value = false
      testScrollPosition()
    }, timeToFailure)
    iframe.contentDocument.location.replace(nextPage)
  }
}

function appendNewContent() {
  // Make sure page is correct.  Using 'indexOf' instead of '!=' because links like "https://danbooru.donmai.us/pools?page=2&search%5Border%5D=" become "https://danbooru.donmai.us/pools?page=2" in the iframe href.
  clearTimeout(timeout)
  if (!nextPage.includes(iframe.contentDocument.location.href)) {
    setTimeout(() => {
      pending.value = false
    }, 1000)
    return
  }

  const images = getImages(iframe.contentDocument)
  addImages(images)

  if (!images.length) nextPage = null
  else
    nextPage = getNextPage(iframe.contentDocument)

  if (nextPage) {
    history.pushState({}, iframe.contentDocument, nextPage)
  }
  else {
    // TODO: end of pages
    console.log('End of pages')
  }

  pending.value = false
  testScrollPosition()
}

function injectGlobalStyle() {
  const s = document.createElement('style')
  s.innerHTML = `
body { padding: 0; }
#header { margin: 0 !important; text-align: center; }
#header ul { float: none !important; display: inline-block;}
#content > div:first-child > div.sidebar { position: fixed; left: 0; top: 0; bottom: 0; overflow: auto !important; z-index: 2; width: 250px !important; transform: translate(-246px, 0); background: #171717dd; transition: all .2s ease-out; float: none !important; padding: 15px; }
#content > div:first-child > div.sidebar:hover { transform: translateX(0); }
div.content { width: 100vw; text-align: center; float: none }
div.footer { clear: both !important; }
div#paginator a { border: none; }
#comments { max-width: unset !important; width: unset !important; padding: 20px; }
.avatar { border-radius: 1000px; }
form textarea { color: white; background: inherit; padding: 10px 5px; }
.comment .content { text-align: left; }
`
  document.body.appendChild(s)
}

function injectStyle() {
  const s = document.createElement('style')
  s.innerHTML = `
#gallery .row { width: 100vw; white-space: nowrap; height: var(--image-height); --image-height: 300px; }
#gallery .row .thumb { position: relative; display: inline-block; transition: .2s ease-out; overflow: hidden; }
#gallery .row .thumb.liked::after { position: absolute; top: 3px; right: 3px; content: url('https://api.iconify.design/mdi:cards-heart.svg?color=%23f37e92&height=20'); vertical-align: -0.125em; }
#gallery .row .thumb:first-child { transform-origin: left; }
#gallery .row .thumb:last-child { transform-origin: right; }
#gallery .row .thumb img { height: var(--image-height); }
#gallery .row:hover { z-index: 1; }
#gallery .row .thumb:hover { transform: scale(1.3); z-index: 1; opacity: 1; box-shadow: 8px 8px 100px 10px rgba(0, 0, 0, 0.8); border-radius: 5px;  }

#loader { padding: 10px; text-align: center; }

.hidden { display: none !important; }
.preview-dialog { position: fixed; top: 0; left: 0; height: 100vh; width: 100vw; background: rgba(0,0,0,0.7); z-index: 100; }
.preview-dialog iframe { position: absolute; height: 90vh; width: 80vw; top: 50%; left: 50%; transform: translate(-50%, -50%); background: grey; border: none; border-radius: 5px; overflow: hidden; }
.preview-dialog .image-host { position: fixed; top: 0; left: 0; height: 100vh; width: 100vw; overflow: auto; text-align: center; }
.preview-dialog .image-host img { margin: auto; }
.preview-dialog .image-host img.loading { filter: blur(3px); height: 100vh; }
.preview-dialog .image-host.full { overflow: hidden }
.preview-dialog .image-host.full img { max-width: 100vw; max-height: 100vh; }
`
  document.body.appendChild(s)
}

function initPreviewIframe() {
  previewDialog = document.createElement('div')
  previewDialog.addClassName('preview-dialog hidden')
  previewDialog.onclick = (e) => {
    if (e.target === previewDialog)
      previewDialog.classList.toggle('hidden', true)
  }
  window.onkeydown = (e) => {
    if (!previewDialog.classList.contains('hidden')) {
      if (e.key === 'ArrowLeft') {
        currentImage = Math.max(0, currentImage - 1)
        openImage(currentImage)
        e.preventDefault()
      }
      if (e.key === 'ArrowRight') {
        currentImage = Math.min(imagesList.length - 1, currentImage + 1)
        openImage(currentImage)
        e.preventDefault()
      }
      if (e.key === 'Escape') {
        previewDialog.classList.toggle('hidden', true)
        e.preventDefault()
      }
      if (e.key === 'Tab') {
        openImage(currentImage, 'page')
        e.preventDefault()
      }
      if (e.code === 'Space') {
        previewImageDiv.classList.toggle('full')
        e.preventDefault()
      }
      if (e.code === 'KeyL') {
        like(currentImage, 3)
        e.preventDefault()
      }
      if (e.code === 'KeyU') {
        unlike(currentImage, 2)
        e.preventDefault()
      }
    }
  }

  previewIframe = document.createElement('iframe')
  previewImageDiv = document.createElement('div')
  previewImageDiv.className = 'image-host full'
  previewImage = document.createElement('img')

  previewImageDiv.onclick = (e) => {
    previewDialog.classList.toggle('hidden', true)
  }

  previewDialog.appendChild(previewIframe)
  previewImageDiv.appendChild(previewImage)
  previewDialog.appendChild(previewImageDiv)
  document.body.appendChild(previewDialog)
}

function ref(v, handler) {
  let value = v
  return new Proxy(
    {},
    {
      get(obj, prop) {
        return value
      },
      set(obj, prop, v) {
        if (value !== v) {
          value = v
          handler(value)
        }
      },
    },
  )
}

function getImages(doc = document) {
  const result = Array.from(
    doc.querySelectorAll('ul#post-list-posts > li'),
  ).map((li) => {
    const page = (li.querySelector('a.thumb') || {}).href
    const thumb = (li.querySelector('a.thumb img') || {}).src
    const large = (li.querySelector('a.largeimg') || {}).href || (li.querySelector('a.smallimg') || {}).href
    const id = page.split('/').slice(-1)[0]
    const resText = (li.querySelector('.directlink-res') || {}).textContent
    let res
    if (resText && resText.includes('x')) {
      const [height, width] = resText.split(' x ').map(i => +i)
      res = { height, width, radio: width / height }
    }
    if (viewingFavorites) setLiked(id, true)
    const liked = isLiked(id)

    return { page, thumb, large, id, res, liked }
  })
  doc.getElementById('post-list-posts').remove()
  return result
}

function initDOM() {
  const list = document.getElementById('post-list')
  const gallery = document.createElement('div')
  gallery.id = 'gallery'
  list.appendChild(gallery)
  const loader = document.createElement('div')
  loader.id = 'loader'
  loader.textContent = 'Loading...'
  list.appendChild(loader)
}

function addImages(images) {
  const gallery = document.getElementById('gallery')
  const RADIO = Math.round(window.innerWidth / SUGGEST_WIDTH)

  images.forEach((info, i) => {
    const idx = imagesList.length + i
    let row = gallery.querySelector('.row:last-child:not(.full)')
    if (!row) {
      row = document.createElement('div')
      row.className = 'row'
      gallery.appendChild(row)
    }
    row.dataset.width = +(row.dataset.width || 0) + 1 / info.res.radio

    if (+row.dataset.width >= RADIO) {
      row.classList.toggle('full', true)
      row.style = `--image-height: calc(100vw / ${row.dataset.width})`
    }

    const thumb = document.createElement('div')
    thumb.className = 'thumb'
    row.appendChild(thumb)

    const img = document.createElement('img')
    img.src = info.thumb
    thumb.appendChild(img)
    info.dom = thumb
    thumb.classList.toggle('liked', info.liked)

    let lastClicked = -Infinity
    let timer = null
    img.onclick = (e) => {
      e.preventDefault()
      // double click
      if (Date.now() - lastClicked < 300) {
        if (info.liked) unlike(idx)
        else like(idx)

        clearTimeout(timer)
        // click
      }
      else {
        lastClicked = +Date.now()
        timer = setTimeout(() => openImage(idx), 400)
      }
      return false
    }
  })
  imagesList.push(...images)
}

function getProxiedUrl(url) {
  if (!CORS_ENABLED)
    return url
  return CORS_PROXY + url
}

async function openImage(idx, type = 'image') {
  currentImage = idx
  const img = imagesList[idx]
  const { page, large, id, thumb } = img
  if (!previewIframe) initPreviewIframe()

  if (!large) type = 'page'

  previewDialog.classList.toggle('hidden', false)
  previewImage.dataset.id = id

  if (type === 'image') {
    previewImage.crossOrigin = null
    const cache = await getCache(id)
    if (cache) {
      previewImage.src = cache
      // show image
      previewIframe.classList.toggle('hidden', true)
      previewImageDiv.classList.toggle('hidden', false)
    }
    else {
      // show image
      previewIframe.classList.toggle('hidden', true)
      previewImageDiv.classList.toggle('hidden', false)

      // thumbnail
      previewImage.classList.toggle('loading', true)
      previewImage.src = thumb
      await awaitImage(previewImage)

      // full image
      if (CORS_ENABLED)
        previewImage.crossOrigin = 'Anonymous'
      const url = getProxiedUrl(large)
      previewImage.src = url
      await awaitImage(previewImage)
      previewImage.classList.toggle('loading', false)

      // image changed
      if (previewImage.dataset.id !== id)
        return

      // cache
      if (!CACHE_ONLY_LIKE || isLiked(id))
        await setCache(id, previewImage)
    }
  }
  else {
    previewIframe.src = page
    previewIframe.classList.toggle('hidden', false)
    previewImageDiv.classList.toggle('hidden', true)
    await awaitImage(previewIframe)
    if (
      previewIframe.contentWindow.location.href !== page
        && !previewIframe.contentWindow.location.pathname.startsWith('/post/show/')
    ) {
      location.href = previewIframe.contentWindow.location.href
      previewDialog.classList.toggle('hidden', true)
      previewIframe.onload = null
    }
  }
}

function getFavoritesLike() {
  return document.querySelector('.user .submenu li:nth-child(3) a').href
}

function isViewingFavorites() {
  const fav = getFavoritesLike()
  if (!fav)
    return false

  const a = (new URL(fav).searchParams.get('tags') || '')
    .toLowerCase()
    .split(' ')
    .sort()
  const b = (new URL(location.href).searchParams.get('tags') || '')
    .toLowerCase()
    .split(' ')
    .sort()

  return a[0] && a[0] === b[0] && a[1] === b[1]
}

async function vote(id, score) {
  const body = new FormData()
  body.append('id', id)
  body.append('score', score)
  const rawResponse = await fetch('https://yande.re/post/vote.json', {
    method: 'POST',
    headers: {
      'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').attributes
        .content.value,
    },
    body,
  })
  await rawResponse.json()
}

function readLikeList() {
  return Object.fromEntries(
    (localStorage.getItem(STORAGE_KEY) || '').split(',').map(i => [i, true]),
  )
}

function isLiked(id) {
  return !!likedList[id]
}

function setLiked(id, v) {
  likedList[id] = v
  localStorage.setItem(
    STORAGE_KEY,
    Object.entries(likedList)
      .map(([i, v]) => (v ? i : null))
      .filter(i => i)
      .join(','),
  )
}

function like(idx) {
  const image = imagesList[idx]
  vote(image.id, 3)
  image.liked = true
  setLiked(image.id, true)
  image.dom.classList.toggle('liked', image.liked)
}

function unlike(idx) {
  const image = imagesList[idx]
  vote(image.id, 2)
  image.liked = false
  setLiked(image.id, false)
  image.dom.classList.toggle('liked', image.liked)
  removeCache(image.id)
}