Greasy Fork is available in English.

PTT web enhanced

Enhance user experience of PTT web

// ==UserScript==
// @name         PTT web enhanced
// @namespace    2CF9973A-28C9-11EC-9EA6-98F49F6E8EAB
// @version      2.8.1
// @description  Enhance user experience of PTT web
// @author       Rick0
// @match        https://www.ptt.cc/*
// @grant        GM.xmlHttpRequest
// @connect      imgur.com
// @connect      ptt.cc
// @run-at       document-start
// @compatible   firefox Tampermonkey, Violentmonkey, Greasemonkey 4.11+
// @compatible   chrome Tampermonkey, Violentmonkey
// @license      MIT
// ==/UserScript==

(function() {
  'use strict'

  // == basic methods ==

  function createElement(html) {
    let template = document.createElement('template')
    template.innerHTML = html
    
    return template.content.firstChild
  }

  function insertElementToNextLine (positionElement, element) {
    let positionNextSibling = positionElement.nextSibling
    switch (positionNextSibling?.nodeType) {
      case Node.TEXT_NODE:
        positionNextSibling.parentNode.replaceChild(element, positionNextSibling)
        let textMatchList = positionNextSibling.data.match(/^([^\n]*)(\n?.*)$/s)
        if (textMatchList[1] !== undefined) element.insertAdjacentText('beforebegin', textMatchList[1])
        if (textMatchList[2] !== undefined) element.insertAdjacentText('afterend', textMatchList[2])
        break
        
      case Node.ELEMENT_NODE:
      case undefined:
        positionElement.insertAdjacentElement('afterend', element)
        break
      
      default:
        throw new Error('insertElementToNextLine receive invalid positionElement')
    }
  }

  function addStyle (cssCode) {
    document.head.append(createElement(`<style>${cssCode}</style>`))
  }

  function getImgurInfo (imgurUrl) {
    return new Promise((resolve, reject) => {
      let urlData = new URL(imgurUrl)

      if (regExpData.imgur.idExt.test(urlData.pathname)) {
        let imageId = RegExp.$1
        fetch(`https://api.imgur.com/3/image/${imageId}`, {
          method: 'GET',
          referrerPolicy: 'no-referrer',
          headers: {
            Authorization: 'Client-ID b654e1b04c90bc8'
          },
        })
          .then(res => res.json())
          .then(json => resolve(json.data))
          .catch(err => reject(err))
      } else if (regExpData.imgur.album.test(urlData.pathname)) {
        let albumId = RegExp.$1
        fetch(`https://api.imgur.com/3/album/${albumId}/images`, {
          method: 'GET',
          referrerPolicy: 'no-referrer',
          headers: {
            Authorization: 'Client-ID b654e1b04c90bc8'
          },
        })
          .then(res => res.json())
          .then(json => resolve(json.data[0]))
          .catch(err => reject(err))
      } else if (regExpData.imgur.gallery.test(urlData.pathname)) {
        let galleryId = RegExp.$1
        fetch(`https://api.imgur.com/3/gallery/${galleryId}/images`, {
          method: 'GET',
          referrerPolicy: 'no-referrer',
          headers: {
            Authorization: 'Client-ID b654e1b04c90bc8'
          },
        })
          .then(res => res.json())
          .then(json => resolve(Array.isArray(json.data) ? json.data[0] : json.data))
          .catch(err => reject(err))
      } else {
        reject(new Error(`不支援的格式: ${imgurUrl}`))
      }
    })
  }

  // == dependent methods ==

  function agreeOver18 () {
    document.cookie = `over18=1;path=/;expires=${(new Date(2100, 0)).toUTCString()}`
    location.replace(`https://www.ptt.cc/${decodeURIComponent(location.search.match(/[?&]from=([^&]+)/)[1])}`) 
  }

  function addHeadlines () {
    let boardToolsEl = document.querySelector('.btn-group.btn-group-dir')
    let headlinesUrl = `/bbs/${boardData.name}/search?q=recommend%3A100`
    let headlinesEl = createElement(`<a class="btn" href="${headlinesUrl}">爆文</a>`)
    // 如果在爆文搜尋頁面,按鈕加上樣式
    if (/[\?&]q=recommend%3A100/.test(location.search)) headlinesEl.classList.add('selected')
    boardToolsEl.append(headlinesEl)
  }

  function addSearch () {
    // 設定 css
    addStyle(
      `#navigation {
        display: flex;
      }
      #navigation > * {
        white-space: nowrap;
      }
      .ellipsis {
        text-overflow: ellipsis;
        overflow: hidden;
      }`
    )

    // 系列文
    let title = document.querySelectorAll('.article-metaline')[1]
      .querySelector('.article-meta-value')
      .textContent.match(/^(?:(?:Re|Fw): +)?(.+)$/)[1]
    let titleEl = createElement(`<a class="board ellipsis" style="cursor: pointer;">系列 ${title}</a>`)
    let titleUrl = `${location.pathname.match(/^(.+\/).+?$/)[1]}search?q=${encodeURIComponent(`thread:${title}`).replace(/%20/g, '+')}`
    titleEl.addEventListener('click', function (e) {
      location.href = titleUrl
    })

    // 同作者
    let author = document.querySelectorAll('.article-metaline')[0]
      .querySelector('.article-meta-value')
      .textContent.match(/^[^ ]+/)[0]
    let authorEl = createElement(`<a class="board" style="cursor: pointer;">作者 ${author}</a>`)
    let authorUrl = `${location.pathname.match(/^(.+\/).+?$/)[1]}search?q=${encodeURIComponent(`author:${author}`).replace(/%20/g, '+')}`
    authorEl.addEventListener('click', function (e) {
      location.href = authorUrl
    })

    // 插入到畫面中
    let navigation = document.querySelector('#navigation')
    navigation.firstElementChild.remove()
    navigation.insertAdjacentElement('afterbegin', titleEl)
    navigation.insertAdjacentElement('afterbegin', createElement('<div class="bar"></div>'))
    navigation.insertAdjacentElement('beforeend', authorEl)
  }

  function pttImageEnhanced () {
    function getPrevRichcontentEl (el) {
      while (el.parentElement.id !== 'main-content') {
        el = el.parentElement
      }
      return el
    }

    // == 取消所有 ptt web 原生的 imgur 圖片載入 ==
    for (let img of document.querySelectorAll('.richcontent > img[src*="imgur.com"]')) {
      img.src = ''
      img.parentElement.remove()
    }

    // == 建立 lazy observer ==
    let onEnterView = function (entries, observer) {
      for (let entry of entries) {
        if (entry.isIntersecting) {
          // 目標進入畫面
          let triggerRichcontent = entry.target
          let imgurUrl = triggerRichcontent.dataset.imgurUrl

          getImgurInfo(imgurUrl)
            .then(imgurInfo => {
              let attachment
              if (imgurInfo.animated) {
                attachment = createElement(`<video src="https://i.imgur.com/${imgurInfo.id}.mp4" autoplay loop muted style="max-width: 100%;max-height: 800px;"></video>`)
                attachment.addEventListener('loadedmetadata', function (e) {
                  triggerRichcontent.removeAttribute('style')
                })
              } else {
                attachment = createElement(`<img src="https://i.imgur.com/${imgurInfo.id}h.jpg" alt>`)
                attachment.addEventListener('load', function (e) {
                  triggerRichcontent.removeAttribute('style')
                })
              }
              triggerRichcontent.append(attachment)
            })
            .catch(err => {
              triggerRichcontent.remove()
            })
          observer.unobserve(triggerRichcontent)
        }
      }
    }
    let options = {
      rootMargin: '200%',
    }
    let lazyObserver = new IntersectionObserver(onEnterView, options)

    for (let link of document.querySelectorAll('.bbs-screen.bbs-content a[href*="imgur.com"]')) {
      // 建立 richcontent
      let prevRichcontentEl = getPrevRichcontentEl(link)
      let richcontent = createElement(`<div class="richcontent" style="min-height: 30vh;" data-imgur-url="${link.href}"></div>`)
      lazyObserver.observe(richcontent)
      insertElementToNextLine(prevRichcontentEl, richcontent)
    }
  }


  // == main ==

  var regExpData = {
    imgur: {
      idExt: /^\/(\w+)(?:\.(\w+))?$/,
      album: /\/a\/(\w+)/,
      gallery: /\/gallery\/(\w+)/,
    },
  }
  var pageData = {
    set metaReferrer (value) {
      if (this.metaReferrer !== undefined) {
        document.querySelector('meta[name="referrer"]').content = value
      } else {
        document.head.append(createElement(`<meta name="referrer" content="${value}">`))
      }
    },

    get metaReferrer () {
      return document.querySelector('meta[name="referrer"]')?.content
    },

    get isMobile () {
      return navigator.userAgentData.mobile
    },
  }
  var boardData = (() => {
    let result = {}

    if (/^\/(bbs|man)\/([^\/]+)(?:\/[^\/]+)*\/(?:M|G)\.\d+\.A\.[0-9A-F]{3}\.html/.test(location.pathname)) {
      result = {
        type: 'post',
        area: RegExp.$1,
        name: RegExp.$2,
        is404: document.title === '404',
      }
    } else if (/^\/(bbs|man)\/([^\/]+)(?:\/[^\/]+)*\/index(\d*).html/.test(location.pathname)) {
      result = {
        type: 'index',
        area: RegExp.$1,
        name: RegExp.$2,
        pageNum: RegExp.$3 === '' ? 0 : parseInt(RegExp.$3, 10),
      }
    } else if (/^\/(bbs|man)\/([^\/]+)\/search/.test(location.pathname)) {
      result = {
        type: 'search',
        area: RegExp.$1,
        name: RegExp.$2,
        isHeadline: /[\?&]q=recommend%3A100/.test(location.search),
      }
    } else if (location.pathname === '/ask/over18') {
      result = {
        type: 'over18',
      }
    }

    return result
  })()

  switch (boardData.type) {
    case 'over18':
      agreeOver18()
      break
  }

  document.addEventListener('DOMContentLoaded', function () {
    switch (boardData.type) {
      case 'post':
        if (!boardData.is404) {
          pageData.metaReferrer = 'no-referrer'
          pttImageEnhanced()
          // 只有一般看板頁面需要,排除精華區
          if (boardData.area === 'bbs') {
            addSearch()
          }
        }
        break

      case 'index':
      case 'search':
        addHeadlines()
        // 手機因為排版關係,使用最新來被爆文取代,但精華區並沒有最新按鈕,所以要排除
        if (pageData.isMobile && boardData.area === 'bbs') {
          let oldestEl = document.querySelector('.btn.wide')
          oldestEl.insertAdjacentElement('beforebegin', document.querySelectorAll('.btn')[2])
          oldestEl.remove()
        }
        break
    }
  }, { once: true })
})()