// ==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
// ==/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', `<div class="comment netabare-comment-container" role="button" tabindex="0"><span class="netabare-comment">${html}</span></div>`);
epA.href = `${epUrl}#post_${id}`;
footer.insertAdjacentHTML('beforebegin', `<div class="likes_grid" id="likes_grid_${id}"></div>`);
footer.insertAdjacentHTML('afterbegin', /* html */`
<div class="action dropdown dropdown_right">
<a href="javascript:void(0);" class="icon like_dropdown"
data-like-type="11"
data-like-main-id="${ epId }"
data-like-related-id="${ id }"
data-like-tpl-id="likes_reaction_menu">
<span class="ico ico_like"> </span>
<span class="title">贴贴</span>
</a>
</div>
`);
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 `<img src="${imgUrl}" smileid="${mathNumber + 16}" alt="(bgm${number})" ${appendix}>`;
}).replace(/\n/g, '<br>'); // \n
}
// [url]
bbcode = bbcode.replace(/\[url=([^\]]+)\]([^\[]+)\[\/url\]/g, '<a class="l" href="$1" target="_blank" rel="nofollow external noopener noreferrer">$2</a>');
bbcode = bbcode.replace(/\[url\]([^[]+)\[\/url\]/g, '<a class="l" href="$1" target="_blank" rel="nofollow external noopener noreferrer">$1</a>');
// [img]
bbcode = bbcode.replace(/\[img(?:=(\d+),(\d+))?\]([^[]+)\[\/img\]/g, function (_, width, height, url) {
const trimmedUrl = url.trim();
return `<img class="code" src="${trimmedUrl}" rel="noreferrer" referrerpolicy="no-referrer" alt="${trimmedUrl}" loading="lazy"${width && height ? ` width="${width}" height="${height}"` : ''}>`;
});
// [photo]
bbcode = bbcode.replace(/\[photo=[^\]]+\]([^[]+)\[\/photo\]/g, '<img class="code" src="//lain.bgm.tv/pic/photo/l/$1" rel="noreferrer" referrerpolicy="no-referrer" alt="https://lain.bgm.tv/pic/photo/l/$1" loading="lazy">');
// [b]
bbcode = bbcode.replace(/\[b\]([^[]+)\[\/b\]/g, function (_, content) {
return `<span style="font-weight:bold">${bbcodeToHtml(content, depth + 1, maxDepth)}</span>`;
});
// [u]
bbcode = bbcode.replace(/\[u\]([^[]+)\[\/u\]/g, function (_, content) {
return `<span style="text-decoration:underline">${bbcodeToHtml(content, depth + 1, maxDepth)}</span>`;
});
// [size]
bbcode = bbcode.replace(/\[size=([^\]]+)\]([^[]+)\[\/size\]/g, function (_, size, content) {
return `<span style="font-size:${size}px; line-height:${size}px;">${bbcodeToHtml(content, depth + 1, maxDepth)}</span>`;
});
// [mask]
bbcode = bbcode.replace(/\[mask\]([^[]+)\[\/mask\]/g, function (_, content) {
return `<span class="text_mask" style="background-color:#555;color:#555;border:1px solid #555;">${bbcodeToHtml(content, depth + 1, maxDepth)}</span>`;
});
// [s]
bbcode = bbcode.replace(/\[s\]([^[]+)\[\/s\]/g, function (_, content) {
return `<span style="text-decoration: line-through;">${bbcodeToHtml(content, depth + 1, maxDepth)}</span>`;
});
// [quote]
bbcode = bbcode.replace(/\[quote\]([^[]+)\[\/quote\]/g, function (_, content) {
return `<div class="quote"><q>${bbcodeToHtml(content, depth + 1, maxDepth)}</q></div>`;
});
return bbcodeToHtml(bbcode, depth + 1, maxDepth);
}
function getDataLikesList(mainID, reactions) {
return reactions.reduce((acc, i) => {
acc[i.value] = {
type: 11,
main_id: mainID,
value: i.value,
total: i.users.length,
emoji: FACE_KEY_GIF_MAPPING[i.value],
users: i.users,
selected: i.users.some(user => user.id === unsafeWindow.CHOBITS_UID)
};
return acc;
}, {})
}
function beDistinctConcurrentRetryCached(requestFunction, options = {}) {
const {
maxConcurrency = 3,
maxRetries = 3,
retryDelay = 1000,
maxCacheSize = 5,
cacheMap, // ep comments 缓存会在外部调用
genKey = (arg1) => arg1,
} = options;
const pendingRequests = new Map();
const activeRequests = new Set();
const abortControllers = new Map();
const wrapped = async (...args) => {
const key = genKey(...args);
if (cacheMap.has(key)) {
console.log(`Returning cached result for ${key}`);
const result = cacheMap.get(key);
cacheMap.delete(key);
cacheMap.set(key, result);
return result;
}
if (pendingRequests.has(key)) {
console.log(`Request to ${key} is already pending, waiting...`);
return pendingRequests.get(key);
}
while (activeRequests.size >= maxConcurrency) {
console.log(`Max concurrency (${maxConcurrency}) reached, waiting...`);
await Promise.race([...activeRequests]);
}
try {
const requestPromise = (async () => {
let retries = 0;
while (retries <= maxRetries) {
try {
const result = await requestFunction(...args);
if (cacheMap.size > maxCacheSize) {
const oldestKey = cacheMap.keys().next().value;
cacheMap.delete(oldestKey);
}
cacheMap.set(key, result);
return result;
} catch (error) {
retries++;
if (retries > maxRetries) {
throw new Error(`Request to ${key} failed after ${maxRetries} retries: ${error}`);
}
console.log(`Request to ${key} failed: ${error}, retrying (${retries}/${maxRetries})...`);
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
}
})();
const manageActiveRequests = (async () => {
activeRequests.add(requestPromise);
try {
return await requestPromise;
} finally {
activeRequests.delete(requestPromise);
}
})();
pendingRequests.set(key, manageActiveRequests);
return await manageActiveRequests;
} finally {
pendingRequests.delete(key);
}
};
wrapped.abortAll = () => {
abortControllers.forEach((controller) => controller.abort());
abortControllers.clear();
activeRequests.clear();
pendingRequests.clear();
};
return wrapped;
}
// 键盘操作
document.addEventListener('click', e => {
if (e.target.classList.contains('netabare-comment-container')) e.target.focus();
}, true);
// 保存贴贴变化
const originalReq = unsafeWindow.chiiLib.likes.req;
unsafeWindow.chiiLib.likes.req = (ele) => {
const tlId = ele.closest('.tml_item').id;
const comment = storage.getItem(tlId);
if (!comment) return originalReq.call(this, ele);
const id = new URLSearchParams(ele.href).get('id');
const originalAjax = $.ajax;
$.ajax = (options) => {
const originalSuccess = options.success;
options.success = function(json) {
originalSuccess.call(this, json);
const tietie = json.data?.[id];
if (tietie) {
comment.tietie = tietie;
} else {
const originalTietie = comment.tietie;
const onlyValue = (arr, filter) => arr.length === 1 && filter(arr[0]);
// 频繁贴贴会导致返回 undefined,此时不应该清除贴贴数据
if (!originalTietie || onlyValue(Object.keys(originalTietie), key => onlyValue(originalTietie[key].users, user => user.username === myUsername))) {
comment.tietie = null;
}
};
storage.setItem(tlId, comment);
};
const result = originalAjax.call(this, options);
$.ajax = originalAjax;
return result;
};
originalReq.call(this, ele);
};
})();