yande.re refine

Refining yande.re

  1. // ==UserScript==
  2. // @name yande.re refine
  3. // @namespace https://greasyfork.org/scripts/397612-yande-re-refine
  4. // @description Refining yande.re
  5. // @include *://behoimi.org/*
  6. // @include *://www.behoimi.org/*
  7. // @include *://*.donmai.us/*
  8. // @include *://konachan.tld/*2
  9. // @include *://yande.re/*
  10. // @include *://chan.sankakucomplex.com/*
  11. // @version 2021.08.28.a
  12. // @grant none
  13. // ==/UserScript==
  14.  
  15. // You can found alternatives in https://gist.github.com/jimmywarting/ac1be6ea0297c16c477e17f8fbe51347
  16. const CORS_PROXY = 'https://cors-anywhere.herokuapp.com/'
  17. const CORS_ENABLED = false
  18. const CACHE_ENABLED = true
  19. const CACHE_ONLY_LIKE = true
  20.  
  21. const STORAGE_KEY = 'yandere-refine-liked'
  22. const INDEXED_DB_NAME = 'yandere-refine-cache'
  23. const SUGGEST_WIDTH = 300
  24. // Minimum amount of window left to scroll, maintained by loading more pages.
  25. const scrollBuffer = 600
  26. // 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.
  27. const timeToFailure = 15000
  28.  
  29. //= ===========================================================================
  30. //= ========================Script initialization==============================
  31. //= ===========================================================================
  32.  
  33. let nextPage, mainTable, mainParent, timeout, iframe
  34. let previewIframe, previewImage, previewImageDiv, previewDialog
  35. const imagesList = []
  36. let currentImage = 0
  37. const pending = ref(false, v =>
  38. document.getElementById('loader').classList.toggle('hidden', !v),
  39. )
  40. const likedList = readLikeList()
  41. const viewingFavorites = isViewingFavorites()
  42. let db
  43. const memcache = {}
  44.  
  45. injectGlobalStyle()
  46. initialize()
  47.  
  48. function initialize() {
  49. // Stop if inside an iframe
  50. if (window !== window.top || scrollBuffer === 0) return
  51.  
  52. // Stop if no "table"
  53. mainTable = getMainTable(document)
  54. if (!mainTable) return
  55.  
  56. injectStyle()
  57. initDOM()
  58. addImages(getImages())
  59.  
  60. // Stop if no more pages
  61. nextPage = getNextPage(document)
  62. if (!nextPage) return
  63.  
  64. // Hide the blacklist sidebar, since this script breaks the tag totals and post unhiding.
  65. const sidebar = document.getElementById('blacklisted-sidebar')
  66. if (sidebar) sidebar.style.display = 'none'
  67.  
  68. // Other important variables:
  69. mainParent = mainTable.parentNode
  70. pending.value = false
  71.  
  72. iframe = document.createElement('iframe')
  73. iframe.width = iframe.height = 0
  74. iframe.style.visibility = 'hidden'
  75. document.body.appendChild(iframe)
  76.  
  77. // Slight delay so that Danbooru's initialize_edit_links() has time to hide all the edit boxes on the Comment index
  78. iframe.addEventListener(
  79. 'load',
  80. (e) => {
  81. setTimeout(appendNewContent, 100)
  82. },
  83. false,
  84. )
  85.  
  86. window.addEventListener('scroll', testScrollPosition, false)
  87. testScrollPosition()
  88.  
  89. if (CACHE_ENABLED) {
  90. const script = document.createElement('script')
  91. script.src = 'https://unpkg.com/dexie@latest/dist/dexie.js'
  92. script.onload = () => {
  93. // eslint-disable-next-line no-undef
  94. db = new Dexie(INDEXED_DB_NAME)
  95.  
  96. db.version(1).stores({
  97. images: 'id, blob',
  98. })
  99. }
  100. document.head.appendChild(script)
  101. }
  102. }
  103.  
  104. //= ===========================================================================
  105. //= ===========================Script functions================================
  106. //= ===========================================================================
  107.  
  108. function awaitImage(img) {
  109. if (img.completed)
  110. return
  111. return new Promise((resolve) => {
  112. img.onload = () => {
  113. resolve(img)
  114. img.onload = undefined
  115. }
  116. })
  117. }
  118.  
  119. async function setCache(id, img) {
  120. if (!db)
  121. return false
  122.  
  123. console.log(`Caching ${id}`)
  124. const canvas = document.createElement('canvas')
  125. canvas.width = img.naturalWidth
  126. canvas.height = img.naturalHeight
  127.  
  128. const ctx = canvas.getContext('2d')
  129. ctx.drawImage(img, 0, 0)
  130.  
  131. const blob = await new Promise((resolve) => {
  132. canvas.toBlob(resolve)
  133. })
  134.  
  135. await db.images.put({ id, blob })
  136. return true
  137. }
  138.  
  139. async function getCache(id) {
  140. if (memcache[id])
  141. return URL.createObjectURL(memcache[id])
  142. if (!db)
  143. return
  144. const data = await db.images.get(id)
  145. if (data && data.blob) {
  146. console.log(`Loading cache of ${id}`)
  147. memcache[id] = data.blob
  148. return URL.createObjectURL(data.blob)
  149. }
  150. }
  151.  
  152. async function removeCache(id) {
  153. if (!db)
  154. return
  155. await db.images.delete(id)
  156. }
  157.  
  158. // Some pages match multiple "tables", so order is important.
  159. function getMainTable(source) {
  160. // Special case: Sankaku post index with Auto Paging enabled
  161. if (
  162. /sankaku/.test(location.host)
  163. && /auto_page=1/.test(document.cookie)
  164. && /^(post(\/|\/index\/?)?|\/)$/.test(location.pathname)
  165. )
  166. return null
  167.  
  168. const xpath = [
  169. './/div[@id=\'c-favorites\']//div[@id=\'posts\']', // Danbooru (/favorites)
  170. './/div[@id=\'posts\']/div', // Danbooru; don't want to fall through to the wrong xpath if no posts ("<article>") on first page.
  171. './/div[@id=\'c-pools\']//section/article/..', // Danbooru (/pools/####)
  172.  
  173. './/div[@id=\'a-index\']/table[not(contains(@class,\'search\'))]', // Danbooru (/forum_topics, ...), take care that this doesn't catch comments containing tables
  174. './/div[@id=\'a-index\']', // Danbooru (/comments, ...)
  175.  
  176. './/table[contains(@class,\'highlight\')]', // large number of pages
  177. './/div[@id=\'content\']/div/div/div/div/span[@class=\'author\']/../../../..', // Sankaku: note search
  178. './/div[contains(@id,\'comment-list\')]/div/..', // comment index
  179. './/*[not(contains(@id,\'popular\'))]/span[contains(@class,\'thumb\')]/a/../..', // post/index, pool/show, note/index
  180. './/li/div/a[contains(@class,\'thumb\')]/../../..', // post/index, note/index
  181. './/div[@id=\'content\']//table/tbody/tr[@class=\'even\']/../..', // user/index, wiki/history
  182. './/div[@id=\'content\']/div/table', // 3dbooru user records
  183. './/div[@id=\'forum\']', // forum/show
  184. ]
  185.  
  186. for (let i = 0; i < xpath.length; i++) {
  187. // eslint-disable-next-line no-func-assign
  188. getMainTable = (function(query) {
  189. return function(source) {
  190. const mTable = new XPathEvaluator().evaluate(
  191. query,
  192. source,
  193. null,
  194. XPathResult.FIRST_ORDERED_NODE_TYPE,
  195. null,
  196. ).singleNodeValue
  197. if (!mTable) return mTable
  198.  
  199. // Special case: Danbooru's /favorites lacks the extra DIV that /posts has, which causes issues with the paginator/page break.
  200. const xDiv = document.createElement('div')
  201. xDiv.style.overflow = 'hidden'
  202. mTable.parentNode.insertBefore(xDiv, mTable)
  203. xDiv.appendChild(mTable)
  204. return xDiv
  205. }
  206. })(xpath[i])
  207.  
  208. const result = getMainTable(source)
  209. if (result) {
  210. // alert("UPW main table query: "+xpath[i]+"\n\n"+location.pathname);
  211. return result
  212. }
  213. }
  214.  
  215. return null
  216. }
  217.  
  218. function getNextPage(doc = document) {
  219. return (doc.querySelector('a.next_page') || {}).href
  220. }
  221.  
  222. function testScrollPosition() {
  223. if (!nextPage) return
  224.  
  225. // Take the max of the two heights for browser compatibility
  226. if (
  227. !pending.value
  228. && window.pageYOffset + window.innerHeight + scrollBuffer
  229. > Math.max(
  230. document.documentElement.scrollHeight,
  231. document.documentElement.offsetHeight,
  232. )
  233. ) {
  234. console.log(`loading ${nextPage}`)
  235. pending.value = true
  236. timeout = setTimeout(() => {
  237. pending.value = false
  238. testScrollPosition()
  239. }, timeToFailure)
  240. iframe.contentDocument.location.replace(nextPage)
  241. }
  242. }
  243.  
  244. function appendNewContent() {
  245. // 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.
  246. clearTimeout(timeout)
  247. if (!nextPage.includes(iframe.contentDocument.location.href)) {
  248. setTimeout(() => {
  249. pending.value = false
  250. }, 1000)
  251. return
  252. }
  253.  
  254. const images = getImages(iframe.contentDocument)
  255. addImages(images)
  256.  
  257. if (!images.length) nextPage = null
  258. else
  259. nextPage = getNextPage(iframe.contentDocument)
  260.  
  261. if (nextPage) {
  262. history.pushState({}, iframe.contentDocument, nextPage)
  263. }
  264. else {
  265. // TODO: end of pages
  266. console.log('End of pages')
  267. }
  268.  
  269. pending.value = false
  270. testScrollPosition()
  271. }
  272.  
  273. function injectGlobalStyle() {
  274. const s = document.createElement('style')
  275. s.innerHTML = `
  276. body { padding: 0; }
  277. #header { margin: 0 !important; text-align: center; }
  278. #header ul { float: none !important; display: inline-block;}
  279. #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; }
  280. #content > div:first-child > div.sidebar:hover { transform: translateX(0); }
  281. div.content { width: 100vw; text-align: center; float: none }
  282. div.footer { clear: both !important; }
  283. div#paginator a { border: none; }
  284. #comments { max-width: unset !important; width: unset !important; padding: 20px; }
  285. .avatar { border-radius: 1000px; }
  286. form textarea { color: white; background: inherit; padding: 10px 5px; }
  287. .comment .content { text-align: left; }
  288. `
  289. document.body.appendChild(s)
  290. }
  291.  
  292. function injectStyle() {
  293. const s = document.createElement('style')
  294. s.innerHTML = `
  295. #gallery .row { width: 100vw; white-space: nowrap; height: var(--image-height); --image-height: 300px; }
  296. #gallery .row .thumb { position: relative; display: inline-block; transition: .2s ease-out; overflow: hidden; }
  297. #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; }
  298. #gallery .row .thumb:first-child { transform-origin: left; }
  299. #gallery .row .thumb:last-child { transform-origin: right; }
  300. #gallery .row .thumb img { height: var(--image-height); }
  301. #gallery .row:hover { z-index: 1; }
  302. #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; }
  303.  
  304. #loader { padding: 10px; text-align: center; }
  305.  
  306. .hidden { display: none !important; }
  307. .preview-dialog { position: fixed; top: 0; left: 0; height: 100vh; width: 100vw; background: rgba(0,0,0,0.7); z-index: 100; }
  308. .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; }
  309. .preview-dialog .image-host { position: fixed; top: 0; left: 0; height: 100vh; width: 100vw; overflow: auto; text-align: center; }
  310. .preview-dialog .image-host img { margin: auto; }
  311. .preview-dialog .image-host img.loading { filter: blur(3px); height: 100vh; }
  312. .preview-dialog .image-host.full { overflow: hidden }
  313. .preview-dialog .image-host.full img { max-width: 100vw; max-height: 100vh; }
  314. `
  315. document.body.appendChild(s)
  316. }
  317.  
  318. function initPreviewIframe() {
  319. previewDialog = document.createElement('div')
  320. previewDialog.addClassName('preview-dialog hidden')
  321. previewDialog.onclick = (e) => {
  322. if (e.target === previewDialog)
  323. previewDialog.classList.toggle('hidden', true)
  324. }
  325. window.onkeydown = (e) => {
  326. if (!previewDialog.classList.contains('hidden')) {
  327. if (e.key === 'ArrowLeft') {
  328. currentImage = Math.max(0, currentImage - 1)
  329. openImage(currentImage)
  330. e.preventDefault()
  331. }
  332. if (e.key === 'ArrowRight') {
  333. currentImage = Math.min(imagesList.length - 1, currentImage + 1)
  334. openImage(currentImage)
  335. e.preventDefault()
  336. }
  337. if (e.key === 'Escape') {
  338. previewDialog.classList.toggle('hidden', true)
  339. e.preventDefault()
  340. }
  341. if (e.key === 'Tab') {
  342. openImage(currentImage, 'page')
  343. e.preventDefault()
  344. }
  345. if (e.code === 'Space') {
  346. previewImageDiv.classList.toggle('full')
  347. e.preventDefault()
  348. }
  349. if (e.code === 'KeyL') {
  350. like(currentImage, 3)
  351. e.preventDefault()
  352. }
  353. if (e.code === 'KeyU') {
  354. unlike(currentImage, 2)
  355. e.preventDefault()
  356. }
  357. }
  358. }
  359.  
  360. previewIframe = document.createElement('iframe')
  361. previewImageDiv = document.createElement('div')
  362. previewImageDiv.className = 'image-host full'
  363. previewImage = document.createElement('img')
  364.  
  365. previewImageDiv.onclick = (e) => {
  366. previewDialog.classList.toggle('hidden', true)
  367. }
  368.  
  369. previewDialog.appendChild(previewIframe)
  370. previewImageDiv.appendChild(previewImage)
  371. previewDialog.appendChild(previewImageDiv)
  372. document.body.appendChild(previewDialog)
  373. }
  374.  
  375. function ref(v, handler) {
  376. let value = v
  377. return new Proxy(
  378. {},
  379. {
  380. get(obj, prop) {
  381. return value
  382. },
  383. set(obj, prop, v) {
  384. if (value !== v) {
  385. value = v
  386. handler(value)
  387. }
  388. },
  389. },
  390. )
  391. }
  392.  
  393. function getImages(doc = document) {
  394. const result = Array.from(
  395. doc.querySelectorAll('ul#post-list-posts > li'),
  396. ).map((li) => {
  397. const page = (li.querySelector('a.thumb') || {}).href
  398. const thumb = (li.querySelector('a.thumb img') || {}).src
  399. const large = (li.querySelector('a.largeimg') || {}).href || (li.querySelector('a.smallimg') || {}).href
  400. const id = page.split('/').slice(-1)[0]
  401. const resText = (li.querySelector('.directlink-res') || {}).textContent
  402. let res
  403. if (resText && resText.includes('x')) {
  404. const [height, width] = resText.split(' x ').map(i => +i)
  405. res = { height, width, radio: width / height }
  406. }
  407. if (viewingFavorites) setLiked(id, true)
  408. const liked = isLiked(id)
  409.  
  410. return { page, thumb, large, id, res, liked }
  411. })
  412. doc.getElementById('post-list-posts').remove()
  413. return result
  414. }
  415.  
  416. function initDOM() {
  417. const list = document.getElementById('post-list')
  418. const gallery = document.createElement('div')
  419. gallery.id = 'gallery'
  420. list.appendChild(gallery)
  421. const loader = document.createElement('div')
  422. loader.id = 'loader'
  423. loader.textContent = 'Loading...'
  424. list.appendChild(loader)
  425. }
  426.  
  427. function addImages(images) {
  428. const gallery = document.getElementById('gallery')
  429. const RADIO = Math.round(window.innerWidth / SUGGEST_WIDTH)
  430.  
  431. images.forEach((info, i) => {
  432. const idx = imagesList.length + i
  433. let row = gallery.querySelector('.row:last-child:not(.full)')
  434. if (!row) {
  435. row = document.createElement('div')
  436. row.className = 'row'
  437. gallery.appendChild(row)
  438. }
  439. row.dataset.width = +(row.dataset.width || 0) + 1 / info.res.radio
  440.  
  441. if (+row.dataset.width >= RADIO) {
  442. row.classList.toggle('full', true)
  443. row.style = `--image-height: calc(100vw / ${row.dataset.width})`
  444. }
  445.  
  446. const thumb = document.createElement('div')
  447. thumb.className = 'thumb'
  448. row.appendChild(thumb)
  449.  
  450. const img = document.createElement('img')
  451. img.src = info.thumb
  452. thumb.appendChild(img)
  453. info.dom = thumb
  454. thumb.classList.toggle('liked', info.liked)
  455.  
  456. let lastClicked = -Infinity
  457. let timer = null
  458. img.onclick = (e) => {
  459. e.preventDefault()
  460. // double click
  461. if (Date.now() - lastClicked < 300) {
  462. if (info.liked) unlike(idx)
  463. else like(idx)
  464.  
  465. clearTimeout(timer)
  466. // click
  467. }
  468. else {
  469. lastClicked = +Date.now()
  470. timer = setTimeout(() => openImage(idx), 400)
  471. }
  472. return false
  473. }
  474. })
  475. imagesList.push(...images)
  476. }
  477.  
  478. function getProxiedUrl(url) {
  479. if (!CORS_ENABLED)
  480. return url
  481. return CORS_PROXY + url
  482. }
  483.  
  484. async function openImage(idx, type = 'image') {
  485. currentImage = idx
  486. const img = imagesList[idx]
  487. const { page, large, id, thumb } = img
  488. if (!previewIframe) initPreviewIframe()
  489.  
  490. if (!large) type = 'page'
  491.  
  492. previewDialog.classList.toggle('hidden', false)
  493. previewImage.dataset.id = id
  494.  
  495. if (type === 'image') {
  496. previewImage.crossOrigin = null
  497. const cache = await getCache(id)
  498. if (cache) {
  499. previewImage.src = cache
  500. // show image
  501. previewIframe.classList.toggle('hidden', true)
  502. previewImageDiv.classList.toggle('hidden', false)
  503. }
  504. else {
  505. // show image
  506. previewIframe.classList.toggle('hidden', true)
  507. previewImageDiv.classList.toggle('hidden', false)
  508.  
  509. // thumbnail
  510. previewImage.classList.toggle('loading', true)
  511. previewImage.src = thumb
  512. await awaitImage(previewImage)
  513.  
  514. // full image
  515. if (CORS_ENABLED)
  516. previewImage.crossOrigin = 'Anonymous'
  517. const url = getProxiedUrl(large)
  518. previewImage.src = url
  519. await awaitImage(previewImage)
  520. previewImage.classList.toggle('loading', false)
  521.  
  522. // image changed
  523. if (previewImage.dataset.id !== id)
  524. return
  525.  
  526. // cache
  527. if (!CACHE_ONLY_LIKE || isLiked(id))
  528. await setCache(id, previewImage)
  529. }
  530. }
  531. else {
  532. previewIframe.src = page
  533. previewIframe.classList.toggle('hidden', false)
  534. previewImageDiv.classList.toggle('hidden', true)
  535. await awaitImage(previewIframe)
  536. if (
  537. previewIframe.contentWindow.location.href !== page
  538. && !previewIframe.contentWindow.location.pathname.startsWith('/post/show/')
  539. ) {
  540. location.href = previewIframe.contentWindow.location.href
  541. previewDialog.classList.toggle('hidden', true)
  542. previewIframe.onload = null
  543. }
  544. }
  545. }
  546.  
  547. function getFavoritesLike() {
  548. return document.querySelector('.user .submenu li:nth-child(3) a').href
  549. }
  550.  
  551. function isViewingFavorites() {
  552. const fav = getFavoritesLike()
  553. if (!fav)
  554. return false
  555.  
  556. const a = (new URL(fav).searchParams.get('tags') || '')
  557. .toLowerCase()
  558. .split(' ')
  559. .sort()
  560. const b = (new URL(location.href).searchParams.get('tags') || '')
  561. .toLowerCase()
  562. .split(' ')
  563. .sort()
  564.  
  565. return a[0] && a[0] === b[0] && a[1] === b[1]
  566. }
  567.  
  568. async function vote(id, score) {
  569. const body = new FormData()
  570. body.append('id', id)
  571. body.append('score', score)
  572. const rawResponse = await fetch('https://yande.re/post/vote.json', {
  573. method: 'POST',
  574. headers: {
  575. 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').attributes
  576. .content.value,
  577. },
  578. body,
  579. })
  580. await rawResponse.json()
  581. }
  582.  
  583. function readLikeList() {
  584. return Object.fromEntries(
  585. (localStorage.getItem(STORAGE_KEY) || '').split(',').map(i => [i, true]),
  586. )
  587. }
  588.  
  589. function isLiked(id) {
  590. return !!likedList[id]
  591. }
  592.  
  593. function setLiked(id, v) {
  594. likedList[id] = v
  595. localStorage.setItem(
  596. STORAGE_KEY,
  597. Object.entries(likedList)
  598. .map(([i, v]) => (v ? i : null))
  599. .filter(i => i)
  600. .join(','),
  601. )
  602. }
  603.  
  604. function like(idx) {
  605. const image = imagesList[idx]
  606. vote(image.id, 3)
  607. image.liked = true
  608. setLiked(image.id, true)
  609. image.dom.classList.toggle('liked', image.liked)
  610. }
  611.  
  612. function unlike(idx) {
  613. const image = imagesList[idx]
  614. vote(image.id, 2)
  615. image.liked = false
  616. setLiked(image.id, false)
  617. image.dom.classList.toggle('liked', image.liked)
  618. removeCache(image.id)
  619. }