// ==UserScript==
// @name 章节讨论吐槽加强
// @namespace https://bgm.tv/group/topic/408098
// @version 0.4.1
// @description 章节讨论中置顶显示自己的吐槽,高亮回复过的章节格子
// @author oo
// @include http*://bgm.tv/*
// @include http*://chii.in/*
// @include http*://bangumi.tv/*
// @grant GM_xmlhttpRequest
// @license MIT
// ==/UserScript==
(async function () {
const colors = {
watched: localStorage.getItem('incheijs_ep_watched') || '#825AFA',
air: localStorage.getItem('incheijs_ep_air') || '#87CEFA'
}
const myUsername = document.querySelector('#dock a').href.split('/').pop();
const style = document.createElement('style');
const refreshStyle = () => {
style.textContent = /* css */`
a.load-epinfo.epBtnWatched,
.prg_list.load-all a.epBtnAir,
.prg_list.load-all a.epBtnQueue {
opacity: .6;
}
.commented a.load-epinfo.epBtnWatched {
opacity: 1;
background: ${colors.watched};
}
.uncommented a.load-epinfo.epBtnWatched,
.prg_list.load-all .commented a.epBtnAir,
.prg_list.load-all .commented a.epBtnQueue,
.prg_list.load-all .uncommented a.epBtnAir,
.prg_list.load-all .uncommented a.epBtnQueue {
opacity: 1;
}
.commented a.load-epinfo.epBtnAir {
background: ${colors.air};
}
.commented a.epBtnQueue {
background: linear-gradient(#FFADD1 80%, ${colors.watched} 80%);
}
html[data-theme="dark"] .commented a.load-epinfo.epBtnWatched {
background: ${colors.watched};
}
html[data-theme="dark"] .commented a.epBtnAir {
background: rgb(from ${colors.air} r g b / 90%);
}
html[data-theme="dark"] .commented a.epBtnQueue {
background: linear-gradient(#FFADD1 80%, ${colors.watched} 80%);
}
.cloned_mine{
display: block !important;
background: transparent;
}
div.row_reply.light_even.cloned_mine {
background: transparent;
}
.cloned_mine .inner {
margin: 0 0 0 50px;
}
.colorPickers input {
border: 0;
padding: 0;
width: 1em;
height: 1em;
border-radius: 2px;
}
.colorPickers input::-webkit-color-swatch-wrapper {
padding: 0;
}
.colorPickers input::-webkit-color-swatch {
border: 0;
}
.subject_my_comments_section {
margin: 5px 0;
padding: 10px;
font-size: 12px;
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
-moz-background-clip: padding;
-webkit-background-clip: padding-box;
background-clip: padding-box;
background: #FAFAFA;
}
html[data-theme="dark"] .subject_my_comments_section {
background: #353535;
}
.subject_my_comments_section .inner {
font-size: 14px;
color: #444;
}
html[data-theme="dark"] .subject_my_comments_section .inner {
color: #e1e1e1;
}
.subject_my_comments_section .inner.loading {
opacity: .3;
pointer-events: none;
}
`;
};
refreshStyle();
document.head.appendChild(style);
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(`请求失败,状态码: ${response.status}`));
}
},
onerror: function(error) {
reject(new Error(`请求出错: ${error}`));
}
});
});
}
const cacheHandler = {
// 初始化时检查并清理过期项目
init(target) {
const data = JSON.parse(localStorage.getItem(target.storageKey) || '{}');
const now = Date.now();
for (const key in data) {
if (data[key].expiry < now) {
delete data[key];
}
}
localStorage.setItem(target.storageKey, JSON.stringify(data));
},
get(target, key) {
const data = JSON.parse(localStorage.getItem(target.storageKey) || '{}');
const now = Date.now();
const oneMonth = 30 * 24 * 60 * 60 * 1000;
if (data[key] && now < data[key].expiry) {
// 调用时延后一个月过期时间
data[key].expiry = now + oneMonth;
localStorage.setItem(target.storageKey, JSON.stringify(data));
return data[key].value;
} else {
delete data[key];
localStorage.setItem(target.storageKey, JSON.stringify(data));
return undefined;
}
},
set(target, key, value) {
const now = Date.now();
const oneMonth = 30 * 24 * 60 * 60 * 1000;
const expiry = now + oneMonth;
const data = JSON.parse(localStorage.getItem(target.storageKey) || '{}');
data[key] = { value, expiry };
localStorage.setItem(target.storageKey, JSON.stringify(data));
return true;
}
};
const cacheTarget = { storageKey: 'incheijs_ep_cache' };
cacheHandler.init(cacheTarget);
const cache = new Proxy(cacheTarget, cacheHandler);
const saveRepliesHTML = (getHTML) => (epName, epId, replies) => {
sessionStorage.setItem(`incheijs_ep_content_${epId}`, replies.reduce((acc, reply) => {
return acc += `<a class="l" href="/ep/${ epId }#${ reply.id }">📌</a> ${ getHTML(reply) }<div class="clear section_line"></div>`;
}, `<h2 class="subtitle">${epName}</h2>`));
};
const saveRepliesHTMLFromDOM = saveRepliesHTML((reply) => reply.querySelector('.message').innerHTML.trim());
const saveRepliesHTMLFromJSON = saveRepliesHTML((reply) => bbcodeToHtml(reply.content));
// 章节讨论页
if (location.pathname.startsWith('/ep')) {
let replies = getRepliesFromDOM(document);
const id = location.pathname.split('/')[2];
if (replies.length) {
document.getElementById('reply_wrapper').before(...replies.map(elem => {
const clone = elem.cloneNode(true);
clone.id += '_clone';
clone.classList.add('cloned_mine');
clone.querySelectorAll('.likes_grid a').forEach(a => {
a.href = 'javascript:';
a.style.cursor = 'default';
}); // 防止点击贴贴无效跳转首页
return clone;
}));
cache[id] = true;
saveRepliesHTMLFromDOM(document.title.split(' ')[0], id, replies);
} else {
cache[id] = false;
}
// 兼容开播前隐藏
// 添加回复
document.querySelector('#ReplyForm').addEventListener('submit', async () => {
const observer = new MutationObserver(() => {
// 因 AJAX 添加的元素未设置 dataset,不可用 getRepliesFromDOM
const myReplies = [...document.querySelectorAll('#comment_list .row_reply')].filter(comment => comment.querySelector('.avatar').href.split('/').pop() === myUsername);
if (myReplies.length) {
cache[id] = true;
saveRepliesHTMLFromDOM(document.title.split(' ')[0], id, myReplies);
observer.disconnect();
}
});
observer.observe(document.querySelector('#comment_list'), { childList: true });
});
// 侧栏其他章节,无法直接判断是否看过,只取缓存不检查
const epElems = document.querySelectorAll('.sideEpList li a');
for (const elem of epElems) {
const url = elem.href;
const id = url.split('/')[4];
if (cache[id] === true) elem.style.color = colors.watched;
}
}
function getRepliesFromDOM(dom) {
return [...dom.querySelectorAll('#comment_list .row_reply')].filter(comment => comment.dataset.itemUser === myUsername);
}
// 动画条目页
const subjectID = location.pathname.match(/(?<=subject\/)\d+/)?.[0];
if (subjectID) {
const type = document.querySelector('.focus').href.split('/')[3];
if (['anime', 'real'].includes(type)) {
await renderWatched();
const prgList = document.querySelector('.prg_list');
const innerDefault = [...prgList.querySelectorAll('a')].map(elem => `<div id="incheijs_ep_content_${elem.id.split('_').pop()}"><div class="loader"></div></div>`).join('');
document.querySelector('.subject_tag_section').insertAdjacentHTML('afterend', /* html */`
<div class="subject_my_comments_section">
<h2 class="subtitle" style="font-size:14px">我的每集吐槽
<a style="padding-left:5px;font-size:12px" class="l" id="expandInd" href="javascript:">[展开]</a>
<a style="padding-left:5px;font-size:12px" class="l" id="checkRest" href="javascript:">[检查]</a>
<span class="colorPickers" style="float:right">
<input type="color" class="titleTip" title="看过格子高亮色" name="watched" value=${colors.watched}>
<input type="color" class="titleTip" title="非看过格子高亮色" name="air" value="${colors.air}">
</span>
</h2>
<div class="inner" hidden style="padding: 5px 10px"></div>
</div>
`);
document.querySelectorAll('.colorPickers input').forEach(picker => {
picker.addEventListener('change', () => {
const type = picker.name;
localStorage.setItem(`incheijs_ep_${type}`, picker.value);
colors[type] = picker.value;
refreshStyle();
});
$(picker).tooltip();
});
const expandInd = document.querySelector('#expandInd');
const checkRest = document.querySelector('#checkRest');
expandInd.addEventListener('click', async (e) => {
e.target.hidden = true;
const inner = document.querySelector('.subject_my_comments_section .inner');
inner.innerHTML = innerDefault;
inner.hidden = false;
inner.classList.add('loading');
await displayMine();
inner.classList.remove('loading');
if (!inner.querySelector('h2')) {
inner.innerHTML = '<div style="width: 100%;text-align:center">没有找到吐槽_(:з”∠)_</div>';
return;
}
[...inner.querySelectorAll('.section_line')].pop()?.remove();
});
checkRest.addEventListener('click', async (e) => {
e.target.remove();
prgList.classList.add('load-all');
await renderRest();
prgList.classList.remove('load-all');
expandInd.hidden = false;
});
}
}
// 首页
if (location.pathname === '/') {
renderWatched();
}
async function retryAsyncOperation(operation, maxRetries = 3, delay = 1000) {
let error;
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (e) {
error = e;
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw error;
}
async function limitConcurrency(tasks, concurrency = 2) {
const results = [];
let index = 0;
async function runTask() {
while (index < tasks.length) {
const currentIndex = index++;
const task = tasks[currentIndex];
try {
const result = await task();
results[currentIndex] = result;
} catch (error) {
results[currentIndex] = error;
}
}
}
const runners = Array.from({ length: concurrency }, runTask);
await Promise.all(runners);
return results;
}
async function walkThroughEps({
cached = () => false,
onCached = () => {},
shouldFetch = () => true,
onSuccess = () => {},
onError = () => {}
} = {}) {
const epElems = document.querySelectorAll('.prg_list a');
const tasks = [];
for (const epElem of epElems) {
const epData = {
epElem,
epName: epElem.title.split(' ')[0],
epId: new URL(epElem.href).pathname.split('/').pop()
};
tasks.push(async () => {
if (cached(epData)) {
onCached(epData);
return;
} else if (shouldFetch(epData)) {
try {
const data = await retryAsyncOperation(() => getEpComments(epData.epId));
const comments = data.filter(comment => comment.user.username === myUsername && comment.content);
if (comments.length) saveRepliesHTMLFromJSON(epData.epName, epData.epId, comments);
onSuccess(epData, comments);
} catch (error) {
console.error(`Failed to fetch ${epElem.href}:`, error);
onError(epData);
}
}
});
}
await limitConcurrency(tasks, 5);
}
async function renderEps(shouldFetch) {
await walkThroughEps({
cached: ({ epId }) => cache[epId] !== undefined,
onCached: ({ epElem, epId }) => epElem.parentElement.classList.add(cache[epId] ? 'commented' : 'uncommented'),
shouldFetch,
onSuccess: ({ epElem, epId }, comments) => {
const hasComments = comments.length > 0;
cache[epId] = hasComments;
epElem.parentElement.classList.add(hasComments ? 'commented' : 'uncommented');
}
});
}
async function renderWatched() {
await renderEps(({ epElem }) => epElem.classList.contains('epBtnWatched'));
}
async function renderRest() {
await renderEps(({ epElem }) => !epElem.classList.contains('commented') && !epElem.classList.contains('uncommented'));
}
async function displayMine() {
await walkThroughEps({
cached: ({ epId }) => sessionStorage.getItem(`incheijs_ep_content_${epId}`),
onCached: ({ epId }) => setContainer(epId),
shouldFetch: ({ epId }) => cache[epId],
onSuccess: ({ epId }) => setContainer(epId),
onError: ({ epName, epId }) => setContainer(epId,
`${ epName }加载失败<div class="clear section_line"></div>`
)
});
function setContainer(epId, content) {
const cacheKey = `incheijs_ep_content_${epId}`;
const container = document.querySelector(`#${cacheKey}`);
container.innerHTML = content || sessionStorage.getItem(cacheKey);
}
}
function bbcodeToHtml(bbcode, depth = 0, maxDepth = 10) {
if (depth >= maxDepth) {
return bbcode.replace(/\n/g, '<br>');
}
if (!/\[([^\]]+)\]/.test(bbcode)) {
return bbcode.replace(/\n/g, '<br>');
}
// (bgm38)
bbcode = bbcode.replace(/\(bgm(\d+)\)/g, function (_, number) {
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}>`;
});
// [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);
}
})();