PTT web enhanced

Enhance user experience of PTT web

  1. // ==UserScript==
  2. // @name PTT web enhanced
  3. // @namespace 2CF9973A-28C9-11EC-9EA6-98F49F6E8EAB
  4. // @version 2.8.1
  5. // @description Enhance user experience of PTT web
  6. // @author Rick0
  7. // @match https://www.ptt.cc/*
  8. // @grant GM.xmlHttpRequest
  9. // @connect imgur.com
  10. // @connect ptt.cc
  11. // @run-at document-start
  12. // @compatible firefox Tampermonkey, Violentmonkey, Greasemonkey 4.11+
  13. // @compatible chrome Tampermonkey, Violentmonkey
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. (function() {
  18. 'use strict'
  19.  
  20. // == basic methods ==
  21.  
  22. function createElement(html) {
  23. let template = document.createElement('template')
  24. template.innerHTML = html
  25. return template.content.firstChild
  26. }
  27.  
  28. function insertElementToNextLine (positionElement, element) {
  29. let positionNextSibling = positionElement.nextSibling
  30. switch (positionNextSibling?.nodeType) {
  31. case Node.TEXT_NODE:
  32. positionNextSibling.parentNode.replaceChild(element, positionNextSibling)
  33. let textMatchList = positionNextSibling.data.match(/^([^\n]*)(\n?.*)$/s)
  34. if (textMatchList[1] !== undefined) element.insertAdjacentText('beforebegin', textMatchList[1])
  35. if (textMatchList[2] !== undefined) element.insertAdjacentText('afterend', textMatchList[2])
  36. break
  37. case Node.ELEMENT_NODE:
  38. case undefined:
  39. positionElement.insertAdjacentElement('afterend', element)
  40. break
  41. default:
  42. throw new Error('insertElementToNextLine receive invalid positionElement')
  43. }
  44. }
  45.  
  46. function addStyle (cssCode) {
  47. document.head.append(createElement(`<style>${cssCode}</style>`))
  48. }
  49.  
  50. function getImgurInfo (imgurUrl) {
  51. return new Promise((resolve, reject) => {
  52. let urlData = new URL(imgurUrl)
  53.  
  54. if (regExpData.imgur.idExt.test(urlData.pathname)) {
  55. let imageId = RegExp.$1
  56. fetch(`https://api.imgur.com/3/image/${imageId}`, {
  57. method: 'GET',
  58. referrerPolicy: 'no-referrer',
  59. headers: {
  60. Authorization: 'Client-ID b654e1b04c90bc8'
  61. },
  62. })
  63. .then(res => res.json())
  64. .then(json => resolve(json.data))
  65. .catch(err => reject(err))
  66. } else if (regExpData.imgur.album.test(urlData.pathname)) {
  67. let albumId = RegExp.$1
  68. fetch(`https://api.imgur.com/3/album/${albumId}/images`, {
  69. method: 'GET',
  70. referrerPolicy: 'no-referrer',
  71. headers: {
  72. Authorization: 'Client-ID b654e1b04c90bc8'
  73. },
  74. })
  75. .then(res => res.json())
  76. .then(json => resolve(json.data[0]))
  77. .catch(err => reject(err))
  78. } else if (regExpData.imgur.gallery.test(urlData.pathname)) {
  79. let galleryId = RegExp.$1
  80. fetch(`https://api.imgur.com/3/gallery/${galleryId}/images`, {
  81. method: 'GET',
  82. referrerPolicy: 'no-referrer',
  83. headers: {
  84. Authorization: 'Client-ID b654e1b04c90bc8'
  85. },
  86. })
  87. .then(res => res.json())
  88. .then(json => resolve(Array.isArray(json.data) ? json.data[0] : json.data))
  89. .catch(err => reject(err))
  90. } else {
  91. reject(new Error(`不支援的格式: ${imgurUrl}`))
  92. }
  93. })
  94. }
  95.  
  96. // == dependent methods ==
  97.  
  98. function agreeOver18 () {
  99. document.cookie = `over18=1;path=/;expires=${(new Date(2100, 0)).toUTCString()}`
  100. location.replace(`https://www.ptt.cc/${decodeURIComponent(location.search.match(/[?&]from=([^&]+)/)[1])}`)
  101. }
  102.  
  103. function addHeadlines () {
  104. let boardToolsEl = document.querySelector('.btn-group.btn-group-dir')
  105. let headlinesUrl = `/bbs/${boardData.name}/search?q=recommend%3A100`
  106. let headlinesEl = createElement(`<a class="btn" href="${headlinesUrl}">爆文</a>`)
  107. // 如果在爆文搜尋頁面,按鈕加上樣式
  108. if (/[\?&]q=recommend%3A100/.test(location.search)) headlinesEl.classList.add('selected')
  109. boardToolsEl.append(headlinesEl)
  110. }
  111.  
  112. function addSearch () {
  113. // 設定 css
  114. addStyle(
  115. `#navigation {
  116. display: flex;
  117. }
  118. #navigation > * {
  119. white-space: nowrap;
  120. }
  121. .ellipsis {
  122. text-overflow: ellipsis;
  123. overflow: hidden;
  124. }`
  125. )
  126.  
  127. // 系列文
  128. let title = document.querySelectorAll('.article-metaline')[1]
  129. .querySelector('.article-meta-value')
  130. .textContent.match(/^(?:(?:Re|Fw): +)?(.+)$/)[1]
  131. let titleEl = createElement(`<a class="board ellipsis" style="cursor: pointer;">系列 ${title}</a>`)
  132. let titleUrl = `${location.pathname.match(/^(.+\/).+?$/)[1]}search?q=${encodeURIComponent(`thread:${title}`).replace(/%20/g, '+')}`
  133. titleEl.addEventListener('click', function (e) {
  134. location.href = titleUrl
  135. })
  136.  
  137. // 同作者
  138. let author = document.querySelectorAll('.article-metaline')[0]
  139. .querySelector('.article-meta-value')
  140. .textContent.match(/^[^ ]+/)[0]
  141. let authorEl = createElement(`<a class="board" style="cursor: pointer;">作者 ${author}</a>`)
  142. let authorUrl = `${location.pathname.match(/^(.+\/).+?$/)[1]}search?q=${encodeURIComponent(`author:${author}`).replace(/%20/g, '+')}`
  143. authorEl.addEventListener('click', function (e) {
  144. location.href = authorUrl
  145. })
  146.  
  147. // 插入到畫面中
  148. let navigation = document.querySelector('#navigation')
  149. navigation.firstElementChild.remove()
  150. navigation.insertAdjacentElement('afterbegin', titleEl)
  151. navigation.insertAdjacentElement('afterbegin', createElement('<div class="bar"></div>'))
  152. navigation.insertAdjacentElement('beforeend', authorEl)
  153. }
  154.  
  155. function pttImageEnhanced () {
  156. function getPrevRichcontentEl (el) {
  157. while (el.parentElement.id !== 'main-content') {
  158. el = el.parentElement
  159. }
  160. return el
  161. }
  162.  
  163. // == 取消所有 ptt web 原生的 imgur 圖片載入 ==
  164. for (let img of document.querySelectorAll('.richcontent > img[src*="imgur.com"]')) {
  165. img.src = ''
  166. img.parentElement.remove()
  167. }
  168.  
  169. // == 建立 lazy observer ==
  170. let onEnterView = function (entries, observer) {
  171. for (let entry of entries) {
  172. if (entry.isIntersecting) {
  173. // 目標進入畫面
  174. let triggerRichcontent = entry.target
  175. let imgurUrl = triggerRichcontent.dataset.imgurUrl
  176.  
  177. getImgurInfo(imgurUrl)
  178. .then(imgurInfo => {
  179. let attachment
  180. if (imgurInfo.animated) {
  181. attachment = createElement(`<video src="https://i.imgur.com/${imgurInfo.id}.mp4" autoplay loop muted style="max-width: 100%;max-height: 800px;"></video>`)
  182. attachment.addEventListener('loadedmetadata', function (e) {
  183. triggerRichcontent.removeAttribute('style')
  184. })
  185. } else {
  186. attachment = createElement(`<img src="https://i.imgur.com/${imgurInfo.id}h.jpg" alt>`)
  187. attachment.addEventListener('load', function (e) {
  188. triggerRichcontent.removeAttribute('style')
  189. })
  190. }
  191. triggerRichcontent.append(attachment)
  192. })
  193. .catch(err => {
  194. triggerRichcontent.remove()
  195. })
  196. observer.unobserve(triggerRichcontent)
  197. }
  198. }
  199. }
  200. let options = {
  201. rootMargin: '200%',
  202. }
  203. let lazyObserver = new IntersectionObserver(onEnterView, options)
  204.  
  205. for (let link of document.querySelectorAll('.bbs-screen.bbs-content a[href*="imgur.com"]')) {
  206. // 建立 richcontent
  207. let prevRichcontentEl = getPrevRichcontentEl(link)
  208. let richcontent = createElement(`<div class="richcontent" style="min-height: 30vh;" data-imgur-url="${link.href}"></div>`)
  209. lazyObserver.observe(richcontent)
  210. insertElementToNextLine(prevRichcontentEl, richcontent)
  211. }
  212. }
  213.  
  214.  
  215. // == main ==
  216.  
  217. var regExpData = {
  218. imgur: {
  219. idExt: /^\/(\w+)(?:\.(\w+))?$/,
  220. album: /\/a\/(\w+)/,
  221. gallery: /\/gallery\/(\w+)/,
  222. },
  223. }
  224. var pageData = {
  225. set metaReferrer (value) {
  226. if (this.metaReferrer !== undefined) {
  227. document.querySelector('meta[name="referrer"]').content = value
  228. } else {
  229. document.head.append(createElement(`<meta name="referrer" content="${value}">`))
  230. }
  231. },
  232.  
  233. get metaReferrer () {
  234. return document.querySelector('meta[name="referrer"]')?.content
  235. },
  236.  
  237. get isMobile () {
  238. return navigator.userAgentData.mobile
  239. },
  240. }
  241. var boardData = (() => {
  242. let result = {}
  243.  
  244. if (/^\/(bbs|man)\/([^\/]+)(?:\/[^\/]+)*\/(?:M|G)\.\d+\.A\.[0-9A-F]{3}\.html/.test(location.pathname)) {
  245. result = {
  246. type: 'post',
  247. area: RegExp.$1,
  248. name: RegExp.$2,
  249. is404: document.title === '404',
  250. }
  251. } else if (/^\/(bbs|man)\/([^\/]+)(?:\/[^\/]+)*\/index(\d*).html/.test(location.pathname)) {
  252. result = {
  253. type: 'index',
  254. area: RegExp.$1,
  255. name: RegExp.$2,
  256. pageNum: RegExp.$3 === '' ? 0 : parseInt(RegExp.$3, 10),
  257. }
  258. } else if (/^\/(bbs|man)\/([^\/]+)\/search/.test(location.pathname)) {
  259. result = {
  260. type: 'search',
  261. area: RegExp.$1,
  262. name: RegExp.$2,
  263. isHeadline: /[\?&]q=recommend%3A100/.test(location.search),
  264. }
  265. } else if (location.pathname === '/ask/over18') {
  266. result = {
  267. type: 'over18',
  268. }
  269. }
  270.  
  271. return result
  272. })()
  273.  
  274. switch (boardData.type) {
  275. case 'over18':
  276. agreeOver18()
  277. break
  278. }
  279.  
  280. document.addEventListener('DOMContentLoaded', function () {
  281. switch (boardData.type) {
  282. case 'post':
  283. if (!boardData.is404) {
  284. pageData.metaReferrer = 'no-referrer'
  285. pttImageEnhanced()
  286. // 只有一般看板頁面需要,排除精華區
  287. if (boardData.area === 'bbs') {
  288. addSearch()
  289. }
  290. }
  291. break
  292.  
  293. case 'index':
  294. case 'search':
  295. addHeadlines()
  296. // 手機因為排版關係,使用最新來被爆文取代,但精華區並沒有最新按鈕,所以要排除
  297. if (pageData.isMobile && boardData.area === 'bbs') {
  298. let oldestEl = document.querySelector('.btn.wide')
  299. oldestEl.insertAdjacentElement('beforebegin', document.querySelectorAll('.btn')[2])
  300. oldestEl.remove()
  301. }
  302. break
  303. }
  304. }, { once: true })
  305. })()