// ==UserScript== // @name 进度时间线显示评论 // @namespace https://bgm.tv/group/topic/ // @version 0.1.2 // @description 在班固米显示动画进度时间线的对应评论 // @author oov // @match https://bangumi.tv/ // @match https://bgm.tv/ // @match https://chii.in/ // @match https://bangumi.tv/user/*/timeline* // @match https://bgm.tv/user/*/timeline* // @match https://chii.in/user/*/timeline* // @icon https://www.google.com/s2/favicons?sz=64&domain=bgm.tv // @grant unsafeWindow // @grant GM_xmlhttpRequest // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/529610/%E8%BF%9B%E5%BA%A6%E6%97%B6%E9%97%B4%E7%BA%BF%E6%98%BE%E7%A4%BA%E8%AF%84%E8%AE%BA.user.js // @updateURL https://update.greasyfork.icu/scripts/529610/%E8%BF%9B%E5%BA%A6%E6%97%B6%E9%97%B4%E7%BA%BF%E6%98%BE%E7%A4%BA%E8%AF%84%E8%AE%BA.meta.js // ==/UserScript== /* * 兼容性: * - [加载更多](https://bgm.tv/dev/app/432) */ (async function() { 'use strict'; const FACE_KEY_GIF_MAPPING = { "0": "44", "140": "101", "80": "41", "54": "15", "85": "46", "104": "65", "88": "49", "62": "23", "79": "40", "53": "14", "122": "83", "92": "53", "118": "79", "141": "102", "90": "51", "76": "37", "60": "21", "128": "89", "47": "08", "68": "29", "137": "98", "132": "93" }; const dontNetabare = localStorage.getItem('incheijs_eptl_nonetabare') === 'true'; const style = document.createElement('style'); style.textContent = /* css */` .skeleton { background-color: #e0e0e0; border-radius: 4px; position: relative; overflow: hidden; } .skeleton::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent); animation: shimmer 1.5s infinite; } @keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } html[data-theme="dark"] .skeleton { background-color: #333; } html[data-theme="dark"] .skeleton::after { background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); } .comment-skeleton { max-width: 500px; height: 32.4px; margin-top: 5px; margin-bottom: 5px; border-radius: 5px; border: 1px solid transparent; } .netabare-comment-container { max-height: 200px; overflow: auto; scrollbar-width: thin; ${ dontNetabare ? /* css */` .netabare-comment { filter: blur(4px); transition: filter 200ms cubic-bezier(1, 0, 0, 1) 100ms; img:not([smileid]) { filter: blur(3em); clip-path: inset(0); transition: filter 200ms cubic-bezier(1, 0, 0, 1) 100ms; } }` : '' } } .netabare-comment-container:hover, .netabare-comment-container:focus { ${ dontNetabare ? /* css */` .netabare-comment { filter: blur(0); img:not([smileid]) { filter: blur(0); } }` : '' } } .comment.comment-failed { opacity: .4; } .comment.comment-failed:hover, .comment.comment-failed:focus { opacity: 1; } `; document.head.appendChild(style); class LocalStorageWithExpiry { constructor() { this.prefix = 'incheijs_eptl_'; this.initialize(); this.ttl = 240; // 分钟 } // 初始化时清理过期项 initialize() { Object.keys(localStorage).forEach((key) => { if (key.startsWith(this.prefix)) { const item = JSON.parse(localStorage.getItem(key)); if (this.isExpired(item)) localStorage.removeItem(key); } }); } isExpired(item) { return item && item.expiry && Date.now() > item.expiry; } setItem(key, value) { const storageKey = `${this.prefix}${key}`; const expiry = Date.now() + this.ttl * 60 * 1000; const item = { value, expiry }; localStorage.setItem(storageKey, JSON.stringify(item)); } getItem(key) { const storageKey = `${this.prefix}${key}`; const item = JSON.parse(localStorage.getItem(storageKey)); if (this.isExpired(item)) { localStorage.removeItem(storageKey); return null; } return item ? item.value : null; } removeItem(key) { const storageKey = `${this.prefix}${key}`; localStorage.removeItem(storageKey); } } const storage = new LocalStorageWithExpiry(); const epCommentsCache = new Map(); const subjectEpIdCache = new Map(); const myUsername = document.querySelector('#dock a').href.split('/').pop(); const menu = document.querySelector('#timelineTabs'); const tmlContent = document.querySelector('#tmlContent'); const epExists = focused => ['tab_all', 'tab_progress'].includes(focused.id); const isEpTl = li => { const subjectOrEpLink = li.querySelector(`a.l[href^="${location.origin}/subject/"]`); return subjectOrEpLink?.href.includes('/ep/') || subjectOrEpLink?.previousSibling.textContent.trim() === '完成了'; // 主页和时光机前后空格不同 } const superGetEpComments = beDistinctConcurrentRetryCached(getEpComments, { cacheMap: epCommentsCache }); const superGetSubjectEpId = beDistinctConcurrentRetryCached(getSubjectEpId, { maxCacheSize: 10, cacheMap: subjectEpIdCache, genKey: (subjectId, epNum) => `${subjectId}_${epNum}` }); let loading = false; // 兼容加载更多,避免连续点击导致重复 // 初始 const initialTab = document.querySelector('#timelineTabs .focus'); if (epExists(initialTab)) { lazyLoadLis([...tmlContent.querySelectorAll('li')].filter(isEpTl)); } // 翻页 tmlContent.addEventListener('click', e => { if (loading || !e.target.classList.contains('p')) return; const text = e.target.textContent; let toObserve, getLis; if (['下一页 ››', '‹‹上一页'].includes(text)) { superGetEpComments.abortAll(); toObserve = tmlContent; getLis = addedNodes => [...addedNodes].find((node) => node.id === 'timeline')?.querySelectorAll('li'); } else if (['加载更多', '再来点'].includes(text)) { // 兼容加载更多 toObserve = document.querySelector('#timeline'); getLis = addedNodes => [...addedNodes].filter((node) => node.tagName === 'UL').flatMap((ul) => [...ul.children]); } else { return; } const observer = new MutationObserver(mutations => { const focused = document.querySelector('#timelineTabs .focus'); if (!epExists(focused)) return; for (const mutation of mutations) { const { addedNodes } = mutation; let addedLis = getLis(addedNodes); addedLis &&= [...addedLis].filter(isEpTl); if (!addedLis || addedLis.length === 0) continue; observer.disconnect(); lazyLoadLis(addedLis); loading = false; } }); observer.observe(toObserve, { childList: true }); loading = true; }, true); // 切换Tab let loadedObserver, currentResolve; const loadbarRemoved = (mutations) => mutations.some(mutation => [...mutation.removedNodes].some(node => node.classList?.contains('loading'))); const initLoadedObserver = () => { if (loadedObserver) return; loadedObserver = new MutationObserver(mutations => { if (loadbarRemoved(mutations)) { loadedObserver.disconnect(); currentResolve(); currentResolve = null; } }); }; menu.addEventListener('click', async (e) => { loadedObserver?.disconnect(); if (e.target.tagName !== 'A' || !epExists(e.target)) return; superGetEpComments.abortAll(); await (new Promise(resolve => { currentResolve = resolve; initLoadedObserver(); loadedObserver.observe(tmlContent, { childList: true }); })); let originalItems = [...document.querySelectorAll('#timeline li')].filter(isEpTl); lazyLoadLis(originalItems); }, true); function lazyLoadLis(lis) { const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const li = entry.target; loadComments(li); observer.unobserve(li); } }); }, { threshold: 0.1 } ); lis.forEach((li) => observer.observe(li)); } async function loadComments(tl) { let comment = storage.getItem(tl.id); const inexist = comment?.inexist; let epA, epUrl, epId = comment?.epId; if (inexist && !epId) return; const subjectOrEpLink = tl.querySelector(`a.l[href^="${location.origin}/subject/"]`); const card = tl.querySelector('.card'); const isWcl = !!epId || !subjectOrEpLink.href.includes('/ep/'); const skeleton = document.createElement('div'); skeleton.className = 'comment-skeleton skeleton'; if (isWcl) { const subjectId = subjectOrEpLink.href.split('/').pop(); const progText = subjectOrEpLink.nextSibling; const progTextFrag = progText.textContent.split(' '); const epNum = +progTextFrag[1]; if (isNaN(epNum)) return; try { if (!epId) { card.before(skeleton); epId = await superGetSubjectEpId(subjectId, epNum); } epUrl = `/subject/ep/${epId}`; epA = document.createElement('a'); epA.className = 'l'; epA.href = epUrl; epA.textContent = epNum; const newProgText = document.createTextNode(` ${progTextFrag.slice(2).join(' ')}`); progText.replaceWith(' ', epA, newProgText); if (inexist) { skeleton.remove(); return; } } catch (error) { console.error(tl, error); card.before(makeReloadBtn(tl, '获取章节 ID 失败,点击重试')); skeleton.remove(); return; } } else { epA = subjectOrEpLink; epUrl = epA.href; epId = epUrl.split('/').pop(); } const footer = tl.querySelector('.post_actions.date'); const userId = tl.dataset.itemUser || location.pathname.split('/')?.[2]; card.before(skeleton); try { if (!comment || epCommentsCache.has(epId) || Object.keys(comment).length === 1 && Object.keys(comment)[0] === 'epId') { const data = await superGetEpComments(epId); const rawComment = data.find(comment => comment.user.username === userId && comment.content); if (!rawComment) { storage.setItem(tl.id, { inexist: true, ...(isWcl && { epId }) }); throw new Error('No comment found'); } const { content, id, reactions } = rawComment; comment = { html: bbcodeToHtml(content), id, tietie: reactions?.length ? getDataLikesList(epId, reactions) : null, ...(isWcl && { epId }) }; storage.setItem(tl.id, comment); } const { html, id, tietie } = comment; card.insertAdjacentHTML('beforebegin', `
`); epA.href = `${epUrl}#post_${id}`; footer.insertAdjacentHTML('beforebegin', ``); footer.insertAdjacentHTML('afterbegin', /* html */` `); unsafeWindow.chiiLib.likes.updateGridWithRelatedID(id, tietie); unsafeWindow.chiiLib.likes.init(); } catch (error) { if (error.message !== 'No comment found') { console.error(tl, error); if (isWcl) storage.setItem(tl.id, { epId }); card.before(makeReloadBtn(tl, '获取章节评论失败,点击重试')); } else { console.log(tl, '未找到评论'); } } finally { skeleton.remove(); } } function makeReloadBtn(tl, message) { const btn = document.createElement('div'); btn.className = 'comment comment-failed'; btn.textContent = message; btn.style.cursor = 'pointer'; btn.role = 'button'; btn.tabIndex = 0; btn.onclick = () => { btn.remove(); loadComments(tl); }; return btn; } async function getEpComments(episodeId) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `https://next.bgm.tv/p1/episodes/${episodeId}/comments`, onload: function(response) { if (response.status >= 200 && response.status < 300) { resolve(JSON.parse(response.responseText)); } else { reject(new Error(`请求 ${episodeId} 评论区失败,状态码: ${response.status}`)); } }, onerror: function(e) { reject(new Error(`请求出错: ${e.status}`)); } }); }); } function getSubjectEpIdFromDOM(subjectId, epNum) { if (location.pathname.includes('/user/')) return null; try { const epEles = [...document.querySelectorAll('.load-epinfo')]; const epTlEles = [...document.querySelectorAll('.tml_item')].filter(isEpTl); return (epEles.find(epEle => { const epEleSubjectId = epEle.getAttribute('subject_id'); const epEleEpNum = +epEle.textContent; return (epEleSubjectId === subjectId && epEleEpNum === epNum) }) || epTlEles.find(epTlEle => { const epLink = epTlEle.querySelector(':is(.info, .info_full) a.l:last-of-type'); const epTlEleSubjectId = epTlEle.querySelector('.card a').href.split('/').pop(); const epTlEleEpNum = +epLink.textContent.split(' ')[0].split('.')[1] if (isNaN(epTlEleEpNum)) return false; return (epTlEleSubjectId === subjectId && epTlEleEpNum === epNum) }))?.href.split('/').pop(); } catch (e) { console.error(e) } } async function getSubjectEpId(subjectId, epNum) { const epIdInDOM = getSubjectEpIdFromDOM(subjectId, epNum); if (epIdInDOM) return epIdInDOM; const response = await fetch(`https://api.bgm.tv/v0/episodes?subject_id=${subjectId}&limit=1&offset=${epNum-1}`); if (!response.ok) throw new Error(`请求 ${subjectId} ep${epNum} ID 失败,状态码: ${response.status}`); const { data } = await response.json(); // https://github.com/bangumi/api/issues/242 if (!data.length) throw new Error(`未找到 ${subjectId} ep${epNum}`); return data[0].id; } function bbcodeToHtml(bbcode, depth = 0, maxDepth = 10) { if (depth >= maxDepth || !/\[([^\]]+)\]/.test(bbcode)) { return bbcode.replace(/\(bgm(\d+)\)/g, function (_, number) { // (bgm38) const mathNumber = parseInt(number); let imgUrl, appendix = ''; if (mathNumber > 23) { const formattedNumber = (mathNumber - 23).toString().padStart(2, '0'); imgUrl = `/img/smiles/tv/${formattedNumber}.gif`; appendix = 'width="21"'; } else { imgUrl = `/img/smiles/bgm/${number}.png`; } return `${bbcodeToHtml(content, depth + 1, maxDepth)}