// ==UserScript== // @name Bilibili - 在未登录的情况下照常加载评论 // @namespace https://bilibili.com/ // @version 0.8 // @description 在未登录的情况下照常加载评论 | V0.8 修复使用分页器跳转回第一页时不加载置顶评论的bug; 新增切换评论至'最新'排序的功能 // @license GPL-3.0 // @author DD1969 // @match https://www.bilibili.com/video/* // @icon https://www.bilibili.com/favicon.ico // @require https://lib.baomitu.com/viewerjs/1.11.4/viewer.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js // @grant none // @downloadURL none // ==/UserScript== (async function() { 'use strict'; // 如需使用分页加载主评论,请将下一行等号右边的 false 改为 true,然后保存 const enableReplyPagination = false; // 如需一次性加载所有子评论,请将下一行等号右边的 false 改为 true,然后保存 const enableLoadAllSubRepliesAtOnce = false; // no need to continue this script if user already logged in if (document.cookie.includes('DedeUserID')) return; // reload page when url changed const re = /https:\/\/www\.bilibili\.com\/video\/.*/; const oldHref = window.location.href; const timer4Url = setInterval(() => { const newHref = window.location.href; if (newHref === oldHref) return; if (re.test(newHref) || re.test(oldHref)) { clearInterval(timer4Url); window.location.reload(); } }, 200); // collect essential data or elements const { oid, createrID, rootReplyHash, subReplyHash, replyList } = await new Promise(resolve => { const timer = setInterval(() => { const oid = window?.__INITIAL_STATE__?.aid; const createrID = window?.__INITIAL_STATE__?.upData?.mid; const rootReplyHashMatchResult = document.head.innerHTML.match(/\.reply-item\[(?data-v-[a-z0-9]{8})\]/); const subReplyHashMatchResult = document.head.innerHTML.match(/\.sub-reply-item\[(?data-v-[a-z0-9]{8})\]/); const replyList = document.querySelector('.reply-list'); if (oid && createrID && rootReplyHashMatchResult && subReplyHashMatchResult && replyList) { clearInterval(timer); resolve({ oid, createrID: parseInt(createrID), rootReplyHash: rootReplyHashMatchResult.groups.rootReplyHash, subReplyHash: subReplyHashMatchResult.groups.subReplyHash, replyList }); } }, 200); }); // enable switching sort type const sortTypeConstant = { LATEST: 0, HOT: 2 }; let currentSortType = sortTypeConstant.HOT; await enableSwitchingSortType(); // use to prevent loading duplicated main reply let replyPool = {}; // add style patch await addStyle(); // load first pagination await loadFirstPagination(); // ---------- functions below ---------- // load first pagination async function loadFirstPagination() { // get data of first pagination const { data: firstPaginationData, code: resultCode } = await getPaginationData(1); // clear replyList replyList.innerHTML = ''; // clear replyPool replyPool = {}; // script ends here if not able to fetch pagination data if (resultCode !== 0) { replyList.innerHTML = '

无法从API获取评论数据

'; return; } // load the top reply if it exists if (firstPaginationData.top_replies && firstPaginationData.top_replies.length !== 0) { const topReplyData = firstPaginationData.top_replies[0]; appendReplyItem(topReplyData, true); } // script ends here if there is no reply of this video if (firstPaginationData.replies.length === 0) return; // load normal replies for (const replyData of firstPaginationData.replies) { appendReplyItem(replyData); } // add page loader enableReplyPagination ? addReplyPageSwitcher() : addAnchor(); } // get reply data according to pagination number async function getPaginationData(paginationNumber) { return await fetch(`https://api.bilibili.com/x/v2/reply?oid=${oid}&type=1&sort=${currentSortType}&pn=${paginationNumber}`) .then(res => res.json()) .then(json => ({ data: json.data, code: json.code })); } function appendReplyItem(replyData, isTopReply) { if (!enableReplyPagination && replyPool[replyData.rpid_str]) return; const replyItemElement = document.createElement('div'); replyItemElement.classList.add('reply-item'); replyItemElement.innerHTML = `
${ replyData.member.pendant.image ? `
` : '' }
${ replyData.member.user_sailing?.cardbg ? `
NO.
${replyData.member.user_sailing.cardbg.fan.number.toString().padStart(6, '0')}
` : '' }
${isTopReply? '置顶': ''}${replyData.content.pictures ? `
笔记
` : ''}${getConvertedMessage(replyData.content)}
${ replyData.content.pictures ? `
${getImageItems(replyData.content.pictures)}
` : '' }
${getFormattedTime(replyData.ctime)} ${replyData.like} 回复
${ replyData.card_label ? replyData.card_label.reduce((acc, cur) => acc + `${cur.text_content}`, '') : '' }
${getSubReplyItems(replyData.replies) || ''} ${ replyData.rcount > 3 ? `
共${replyData.rcount}条回复, 点击查看
` : '' }
`; replyList.appendChild(replyItemElement); if (!enableReplyPagination) replyPool[replyData.rpid_str] = true; // setup image viewer const previewImageContainer = replyItemElement.querySelector('.preview-image-container'); if (previewImageContainer) new Viewer(previewImageContainer, { title: false, toolbar: false, tooltip: false, keyboard: false }); // setup view more button const subReplyList = replyItemElement.querySelector('.sub-reply-list'); const viewMoreBtn = replyItemElement.querySelector('.view-more-btn'); viewMoreBtn && viewMoreBtn.addEventListener('click', () => { enableLoadAllSubRepliesAtOnce ? loadAllSubReplies(replyData.rpid, subReplyList) : loadPaginatedSubReplies(replyData.rpid, subReplyList, replyData.rcount, 1); }); } function getFormattedTime(ms) { const time = new Date(ms * 1000); const year = time.getFullYear(); const month = (time.getMonth() + 1).toString().padStart(2, '0'); const day = time.getDate().toString().padStart(2, '0'); const hour = time.getHours().toString().padStart(2, '0'); const minute = time.getMinutes().toString().padStart(2, '0'); return `${year}-${month}-${day} ${hour}:${minute}`; } function getMemberLevelColor(level) { return ({ 1: '#BBBBBB', 2: '#8BD29B', 3: '#7BCDEF', 4: '#FEBB8B', 5: '#EE672A', 6: '#F04C49' })[level]; } function getConvertedMessage(content) { let result = content.message; // convert emote tag to image if (content.emote) { for (const [key, value] of Object.entries(content.emote)) { const imageElementHTML = `${key}`; result = result.replaceAll(key, imageElementHTML); } } // convert timestamp to link result = result.replaceAll(/\d{1,2}(:|:)\d{1,2}/g, (timestamp) => { const [minute, second] = timestamp.replace(':', ':').split(':'); const totalSecond = parseInt(minute) * 60 + parseInt(second); if (Number.isNaN(totalSecond)) return timestamp; return `${timestamp.replace(':', ':')}`; }); // convert url to link if (Object.keys(content.jump_url).length) { for (const [key, value] of Object.entries(content.jump_url)) { const href = key.startsWith('BV') ? `https://www.bilibili.com/video/${key}` : (value.pc_url || key); const linkElementHTML = `${value.title}`; result = result.replaceAll(key, linkElementHTML); } } // convert @ user if (content.at_name_to_mid) { for (const [key, value] of Object.entries(content.at_name_to_mid)) { const linkElementHTML = `@${key}`; result = result.replaceAll(`@${key}`, linkElementHTML); } } return result; } function getImageItems(images) { images = images.slice(0, 3); const imageSizeConfig = ({ 1: 'max-width: 280px; max-height: 180px;', 2: 'width: 128px; height: 128px;', 3: 'width: 96px; height: 96px;', })[images.length]; let result = ''; for (const image of images) { result += `
`; } return result; } function getSubReplyItems(subReplies) { if (!subReplies || subReplies.length === 0) return; let result = ''; for (const replyData of subReplies) { result += `
${getConvertedMessage(replyData.content)}
${getFormattedTime(replyData.ctime)} ${replyData.like} 回复
`; } return result; } async function loadAllSubReplies(rootReplyID, subReplyList) { let subPaginationCounter = 1; while(true) { const subReplyData = await fetch(`https://api.bilibili.com/x/v2/reply/reply?oid=${oid}&pn=${subPaginationCounter++}&ps=20&root=${rootReplyID}&type=1`).then(res => res.json()).then(json => json.data); if (subPaginationCounter - 1 === 1) subReplyList.innerHTML = ''; if (subReplyData.replies) subReplyList.innerHTML += getSubReplyItems(subReplyData.replies); else break; } } async function loadPaginatedSubReplies(rootReplyID, subReplyList, subReplyAmount, paginationNumber) { // replace reply list with new replies const subReplyData = await fetch(`https://api.bilibili.com/x/v2/reply/reply?oid=${oid}&pn=${paginationNumber}&ps=10&root=${rootReplyID}&type=1`).then(res => res.json()).then(json => json.data); if (subReplyData.replies) subReplyList.innerHTML = getSubReplyItems(subReplyData.replies); // add page switcher addSubReplyPageSwitcher(rootReplyID, subReplyList, subReplyAmount, paginationNumber); // scroll to the top of replyItem let elemTop = subReplyList.parentElement.parentElement.offsetTop; let parentElem = subReplyList.parentElement.parentElement.offsetParent; while (parentElem) { elemTop += parentElem.offsetTop; parentElem = parentElem.offsetParent; } window.scrollTo(0, elemTop - 60); } function addSubReplyPageSwitcher(rootReplyID, subReplyList, subReplyAmount, currentPageNumber) { if (subReplyAmount <= 10) return; const pageAmount = Math.ceil(subReplyAmount / 10); const pageSwitcher = document.createElement('div'); pageSwitcher.classList.add('view-more'); pageSwitcher.innerHTML = `
共${pageAmount}页 ${ currentPageNumber !== 1 ? '上一页' : '' } ${ (() => { // 4 on the left, 4 on the right, then merge const left = [currentPageNumber - 4, currentPageNumber - 3, currentPageNumber - 2, currentPageNumber - 1].filter(num => num >= 1); const right = [currentPageNumber + 1, currentPageNumber + 2, currentPageNumber + 3, currentPageNumber + 4].filter(num => num <= pageAmount); const merge = [].concat(left, currentPageNumber, right); // chosen 5(if able) let chosen; if (currentPageNumber <= 3) chosen = merge.slice(0, 5); else if (currentPageNumber >= pageAmount - 3) chosen = merge.reverse().slice(0, 5).reverse(); else chosen = merge.slice(merge.indexOf(currentPageNumber) - 2, merge.indexOf(currentPageNumber) + 3); // add first and dots let final = JSON.parse(JSON.stringify(chosen)); if (!final.includes(1)) { let front = [1]; if (final.at(0) !== 2) front = [1, '...']; final = [].concat(front, final); } // add last and dots if (!final.includes(pageAmount)) { let back = [pageAmount]; if (final.at(-1) !== pageAmount - 1) back = ['...', pageAmount]; final = [].concat(final, back); } // assemble to html return final.reduce((acc, cur) => { if (cur === '...') return acc + '...'; if (cur === currentPageNumber) return acc + `${cur}`; return acc + `${cur}`; }, ''); })() } ${ currentPageNumber !== pageAmount ? '下一页': '' }
`; // add click event listener pageSwitcher.querySelector('.pagination-to-prev-btn')?.addEventListener('click', () => loadPaginatedSubReplies(rootReplyID, subReplyList, subReplyAmount, currentPageNumber - 1)); pageSwitcher.querySelector('.pagination-to-next-btn')?.addEventListener('click', () => loadPaginatedSubReplies(rootReplyID, subReplyList, subReplyAmount, currentPageNumber + 1)); pageSwitcher.querySelectorAll('.pagination-page-number:not(.current-page)')?.forEach(pageNumberElement => { const number = parseInt(pageNumberElement.textContent); pageNumberElement.addEventListener('click', () => loadPaginatedSubReplies(rootReplyID, subReplyList, subReplyAmount, number)); }); // append page switcher subReplyList.appendChild(pageSwitcher); } async function addReplyPageSwitcher() { // clear old page switcher const oldPageSwitcher = document.querySelector('#comment .reply-warp .page-switcher'); oldPageSwitcher && oldPageSwitcher.remove(); let isPageAmountFound = false; let currentMaxPageNumber = 1; let currentPageNumber = 1; // check if there is no reply in page 2 const { data: nextPaginationData } = await getPaginationData(currentPageNumber + 1); if (!nextPaginationData.replies || nextPaginationData.replies.length === 0) return; const pageSwitcher = document.createElement('div'); pageSwitcher.classList.add('page-switcher'); pageSwitcher.style = ` width: 100%; display: flex; justify-content: center; transform: translateY(-60px); `; pageSwitcher.appendChild(generatePageSwitcher()); document.querySelector('#comment .reply-warp').appendChild(pageSwitcher); function generatePageSwitcher() { const wrapper = document.createElement('div'); wrapper.classList.add('page-switcher-wrapper'); wrapper.innerHTML = ` ${ currentPageNumber === 1 ? '上一页' : '上一页' } ${ (() => { // 4 on the left, 4 on the right, then merge const left = [currentPageNumber - 4, currentPageNumber - 3, currentPageNumber - 2, currentPageNumber - 1].filter(num => num >= 1); const right = [currentPageNumber + 1, currentPageNumber + 2, currentPageNumber + 3, currentPageNumber + 4].filter(num => num <= currentMaxPageNumber); const merge = [].concat(left, currentPageNumber, right); // chosen 5(if able) let chosen; if (currentPageNumber <= 3) chosen = merge.slice(0, 5); else if (currentPageNumber >= currentMaxPageNumber - 3) chosen = merge.reverse().slice(0, 5).reverse(); else chosen = merge.slice(merge.indexOf(currentPageNumber) - 2, merge.indexOf(currentPageNumber) + 3); // add first and dots let final = JSON.parse(JSON.stringify(chosen)); if (!final.includes(1)) { let front = [1]; if (final.at(0) !== 2) front = [1, 'dot']; final = [].concat(front, final); } // add last and dots if (!final.includes(currentMaxPageNumber)) { let back = [currentMaxPageNumber]; if (final.at(-1) !== currentMaxPageNumber - 1) back = ['dot', currentMaxPageNumber]; final = [].concat(final, back); } // assemble to html return final.reduce((acc, cur) => { if (cur === 'dot') return acc + '•••'; if (cur === currentPageNumber) return acc + `${cur}`; return acc + `${cur}`; }, ''); })() } ${ isPageAmountFound && currentPageNumber === currentMaxPageNumber ? '下一页' : '下一页' } `; // prev button click event wrapper.querySelector('.page-switcher-prev-btn')?.addEventListener('click', async () => { currentPageNumber -= 1; const { data: prevPaginationData } = await getPaginationData(currentPageNumber); replyList.innerHTML = ''; // if loading page 1, load top reply if it exists if (currentPageNumber === 1 && prevPaginationData.top_replies && prevPaginationData.top_replies.length !== 0) { const topReplyData = prevPaginationData.top_replies[0]; appendReplyItem(topReplyData, true); } for (const replyData of prevPaginationData.replies) { appendReplyItem(replyData); } pageSwitcher.innerHTML = ''; pageSwitcher.appendChild(generatePageSwitcher()); scrollToTopOfReplyList(); }); // next button click event wrapper.querySelector('.page-switcher-next-btn')?.addEventListener('click', async function nextButtonOnClickHandler(e) { if (currentPageNumber === currentMaxPageNumber && isPageAmountFound) return; const { data: nextPaginationData } = await getPaginationData(currentPageNumber + 1); if (!nextPaginationData.replies || nextPaginationData.replies.length === 0) { isPageAmountFound = true; e.target.classList.add('page-switcher-next-btn__disabled'); e.target.classList.remove('page-switcher-next-btn'); return; } if (currentPageNumber === currentMaxPageNumber) currentMaxPageNumber += 1; currentPageNumber += 1; replyList.innerHTML = ''; for (const replyData of nextPaginationData.replies) { appendReplyItem(replyData); } pageSwitcher.innerHTML = ''; pageSwitcher.appendChild(generatePageSwitcher()); scrollToTopOfReplyList(); }); // number button click event wrapper.querySelectorAll('.page-switcher-number:not(.page-switcher-current-page)')?.forEach(numberElement => { numberElement.addEventListener('click', async () => { const targetPageNumber = parseInt(numberElement.textContent); currentPageNumber = targetPageNumber; const { data: paginationData } = await getPaginationData(targetPageNumber); replyList.innerHTML = ''; // if loading page 1, load top reply if it exists if (targetPageNumber === 1 && paginationData.top_replies && paginationData.top_replies.length !== 0) { const topReplyData = paginationData.top_replies[0]; appendReplyItem(topReplyData, true); } for (const replyData of paginationData.replies) { appendReplyItem(replyData); } pageSwitcher.innerHTML = ''; pageSwitcher.appendChild(generatePageSwitcher()); scrollToTopOfReplyList(); }); }); return wrapper; } // scroll to the top of reply list function scrollToTopOfReplyList() { let elemTop = replyList.offsetTop; let parentElem = replyList.offsetParent; while (parentElem) { elemTop += parentElem.offsetTop; parentElem = parentElem.offsetParent; } window.scrollTo(0, elemTop - 196); } } function addAnchor() { // clear old anchor const oldAnchor = document.querySelector('#comment .reply-warp .anchor-for-loading'); oldAnchor && oldAnchor.remove(); const anchorElement = document.createElement('div'); anchorElement.classList.add('anchor-for-loading'); anchorElement.textContent = '正在加载...'; anchorElement.style = ` width: calc(100% - 22px); height: 40px; margin-left: 22px; display: flex; justify-content: center; align-items: center; transform: translateY(-60px); color: #61666d; `; document.querySelector('#comment .reply-warp').appendChild(anchorElement); let paginationCounter = 1; const ob = new IntersectionObserver(async (entries) => { if (!entries[0].isIntersecting) return; const { data: newPaginationData } = await getPaginationData(++paginationCounter); if (!newPaginationData.replies || newPaginationData.replies.length === 0) { anchorElement.textContent = '所有评论已加载完毕'; ob.disconnect(); return; } for (const replyData of newPaginationData.replies) { appendReplyItem(replyData); } }); ob.observe(anchorElement); } async function enableSwitchingSortType() { // make sure target exists const { hotSortElement, timeSortElement } = await new Promise(resolve => { const timer = setInterval(() => { const hotSortElement = document.querySelector('#comment .reply-header .hot-sort'); const timeSortElement = document.querySelector('#comment .reply-header .time-sort'); if (hotSortElement && timeSortElement) { clearInterval(timer); resolve({ hotSortElement, timeSortElement }); } }, 200); }); // add click listener hotSortElement.addEventListener('click', async () => { if (currentSortType === sortTypeConstant.HOT) return; currentSortType = sortTypeConstant.HOT; hotSortElement.style.color = '#18191C'; timeSortElement.style.color = '#9499A0'; await loadFirstPagination(); }); timeSortElement.addEventListener('click', async () => { if (currentSortType === sortTypeConstant.LATEST) return; currentSortType = sortTypeConstant.LATEST; hotSortElement.style.color = '#9499A0'; timeSortElement.style.color = '#18191C'; await loadFirstPagination(); }); } async function addStyle() { // avatar CSS const avatarCSS = document.createElement('style'); avatarCSS.textContent = ` .reply-item .root-reply-avatar .avatar .bili-avatar { width: 48px; height: 48px; } .sub-reply-item .sub-reply-avatar .avatar .bili-avatar { width: 30px; height: 30px; } @media screen and (max-width: 1620px) { .reply-item .root-reply-avatar .avatar .bili-avatar { width: 40px; height: 40px; } .sub-reply-item .sub-reply-avatar .avatar .bili-avatar { width: 24px; height: 24px; } } `; document.head.appendChild(avatarCSS); // view-more CSS const viewMoreCSS = document.createElement('style'); viewMoreCSS.textContent = ` .sub-reply-container .view-more-btn:hover { color: #00AEEC; } .view-more { padding-left: 8px; color: #222; font-size: 13px; user-select: none; } .pagination-page-count { margin-right: 10px; } .pagination-page-dot, .pagination-page-number { margin: 0 4px; } .pagination-btn, .pagination-page-number { cursor: pointer; } .current-page, .pagination-btn:hover, .pagination-page-number:hover { color: #00AEEC; } `; document.head.appendChild(viewMoreCSS); // page switcher CSS const pageSwitcherCSS = document.createElement('style'); pageSwitcherCSS.textContent = ` .page-switcher-wrapper { display: flex; font-size: 14px; color: #666; user-select: none; } .page-switcher-wrapper span { margin-right: 6px; } .page-switcher-wrapper span:not(.page-switcher-dot){ display: flex; padding: 0 14px; height: 38px; align-items: center; border: 1px solid #D7DDE4; border-radius: 4px; cursor: pointer; transition: border-color 0.2s; } .page-switcher-prev-btn:hover, .page-switcher-next-btn:hover, .page-switcher-number:hover { border-color: #00A1D6 !important; } .page-switcher-current-page { color: white; background-color: #00A1D6; border-color: #00A1D6 !important; } .page-switcher-dot { padding: 0 5px; display: flex; align-items: center; color: #CCC; } .page-switcher-prev-btn__disabled, .page-switcher-next-btn__disabled { color: #D7DDE4 !important; cursor: not-allowed !important; } `; document.head.appendChild(pageSwitcherCSS); // viewerjs const viewerjsCSS = document.createElement('style'); viewerjsCSS.textContent = await fetch('https://lib.baomitu.com/viewerjs/1.11.4/viewer.min.css').then(res => res.text()); document.head.appendChild(viewerjsCSS); } })();