- // ==UserScript==
- // @name 章节讨论吐槽加强
- // @namespace https://bgm.tv/group/topic/408098
- // @version 0.3.2
- // @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 = `
- a.load-epinfo.epBtnWatched {
- opacity: .6;
- }
- a.load-epinfo.epBtnWatched.commented {
- opacity: 1;
- background: ${colors.watched};
- }
- a.load-epinfo.epBtnWatched.uncommented {
- opacity: 1;
- }
- a.load-epinfo.epBtnAir.commented {
- background: ${colors.air};
- }
- html[data-theme="dark"] a.load-epinfo.epBtnWatched.commented {
- background: ${colors.watched};
- }
- html[data-theme="dark"] a.load-epinfo.epBtnAir.commented {
- background: ${colors.air};
- }
- .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 renderChecks();
- document.querySelector('.subject_tag_section').insertAdjacentHTML('afterend', `
- <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>
- <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">
- ${ [...document.querySelectorAll('.load-epinfo')].map(elem => `<div id="incheijs_ep_content_${elem.id.split('_').pop()}"><div class="loader"></div></div>`).join('') }
- </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();
- });
- document.querySelector('#expandInd').addEventListener('click', async (e) => {
- e.target.remove();
- const inner = document.querySelector('.subject_my_comments_section .inner');
- 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();
- });
- }
- }
-
- // 首页
- if (location.pathname === '/') {
- renderChecks();
- }
-
- 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('.load-epinfo');
- 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 renderChecks() {
- await walkThroughEps({
- cached: ({ epId }) => cache[epId] !== undefined,
- onCached: ({ epElem, epId }) => epElem.classList.add(cache[epId] ? 'commented' : 'uncommented'),
- shouldFetch: ({ epElem }) => epElem.classList.contains('epBtnWatched'),
- onSuccess: ({ epElem, epId }, comments) => {
- const hasComments = comments.length > 0;
- cache[epId] = hasComments;
- epElem.classList.add(hasComments ? 'commented' : '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) {
- // (bgm38)
- let html = 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 if (mathNumber > 10) {
- imgUrl = `/img/smiles/bgm/${number}.gif`;
- } else {
- imgUrl = `/img/smiles/bgm/${number}.png`;
- }
- return `<img src="${imgUrl}" smileid="${mathNumber + 16}" alt="(bgm${number})" ${appendix}>`;
- });
- // [url]
- html = html.replace(/\[url=([^\]]+)\]([^\[]+)\[\/url\]/g, '<a class="l" href="$1" target="_blank" rel="nofollow external noopener noreferrer">$2</a>');
- html = html.replace(/\[url\]([^[]+)\[\/url\]/g, '<a class="l" href="$1" target="_blank" rel="nofollow external noopener noreferrer">$1</a>');
- // [img]
- html = html.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}"` : ''}>`;
- });
- // [b]
- html = html.replace(/\[b\]([^[]+)\[\/b\]/g, '<span style="font-weight:bold">$1</span>');
- // [u]
- html = html.replace(/\[u\]([^[]+)\[\/u\]/g, '<span style="text-decoration:underline">$1</span>');
- // [size]
- html = html.replace(/\[size=([^\]]+)\]([^[]+)\[\/size\]/g, '<span style="font-size:$1px; line-height:$1px;">$2</span>');
- // [mask]
- html = html.replace(/\[mask\]([^[]+)\[\/mask\]/g, '<span class="text_mask" style="background-color:#555;color:#555;border:1px solid #555;">$1</span>');
- // [s]
- html = html.replace(/\[s\]([^[]+)\[\/s\]/g, '<span style="text-decoration: line-through;">$1</span>');
- // [quote]
- html = html.replace(/\[quote\]([^[]+)\[\/quote\]/g, '<div class="quote"><q>$1</q></div>');
- // \n
- html = html.replace(/\n/g, '<br>');
-
- return html;
- }
-
- })();