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 headlin###rl = `/bbs/${boardData.name}/search?q=recommend%3A100` let headlinesEl = createElement(`<a class="btn" href="${headlin###rl}">爆文</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 r###lt = {} if (/^\/(bbs|man)\/([^\/]+)(?:\/[^\/]+)*\/(?:M|G)\.\d+\.A\.[0-9A-F]{3}\.html/.test(location.pathname)) { r###lt = { type: 'post', area: RegExp.$1, name: RegExp.$2, is404: document.title === '404', } } else if (/^\/(bbs|man)\/([^\/]+)(?:\/[^\/]+)*\/index(\d*).html/.test(location.pathname)) { r###lt = { type: 'index', area: RegExp.$1, name: RegExp.$2, pageNum: RegExp.$3 === '' ? 0 : parseInt(RegExp.$3, 10), } } else if (/^\/(bbs|man)\/([^\/]+)\/search/.test(location.pathname)) { r###lt = { type: 'search', area: RegExp.$1, name: RegExp.$2, isHeadline: /[\?&]q=recommend%3A100/.test(location.search), } } else if (location.pathname === '/ask/over18') { r###lt = { type: 'over18', } } return r###lt })() 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 }) })()