章节讨论吐槽加强

章节讨论中置顶显示自己的吐槽,高亮回复过的章节格子

  1. // ==UserScript==
  2. // @name 章节讨论吐槽加强
  3. // @namespace https://bgm.tv/group/topic/408098
  4. // @version 0.3.2
  5. // @description 章节讨论中置顶显示自己的吐槽,高亮回复过的章节格子
  6. // @author oo
  7. // @include http*://bgm.tv/*
  8. // @include http*://chii.in/*
  9. // @include http*://bangumi.tv/*
  10. // @grant GM_xmlhttpRequest
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (async function () {
  15.  
  16. const colors = {
  17. watched: localStorage.getItem('incheijs_ep_watched') || '#825AFA',
  18. air: localStorage.getItem('incheijs_ep_air') || '#87CEFA'
  19. }
  20. const myUsername = document.querySelector('#dock a').href.split('/').pop();
  21. const style = document.createElement('style');
  22. const refreshStyle = () => {
  23. style.textContent = `
  24. a.load-epinfo.epBtnWatched {
  25. opacity: .6;
  26. }
  27. a.load-epinfo.epBtnWatched.commented {
  28. opacity: 1;
  29. background: ${colors.watched};
  30. }
  31. a.load-epinfo.epBtnWatched.uncommented {
  32. opacity: 1;
  33. }
  34. a.load-epinfo.epBtnAir.commented {
  35. background: ${colors.air};
  36. }
  37. html[data-theme="dark"] a.load-epinfo.epBtnWatched.commented {
  38. background: ${colors.watched};
  39. }
  40. html[data-theme="dark"] a.load-epinfo.epBtnAir.commented {
  41. background: ${colors.air};
  42. }
  43. .cloned_mine{
  44. display: block !important;
  45. background: transparent;
  46. }
  47. div.row_reply.light_even.cloned_mine {
  48. background: transparent;
  49. }
  50. .cloned_mine .inner {
  51. margin: 0 0 0 50px;
  52. }
  53. .colorPickers input {
  54. border: 0;
  55. padding: 0;
  56. width: 1em;
  57. height: 1em;
  58. border-radius: 2px;
  59. }
  60. .colorPickers input::-webkit-color-swatch-wrapper {
  61. padding: 0;
  62. }
  63. .colorPickers input::-webkit-color-swatch {
  64. border: 0;
  65. }
  66. .subject_my_comments_section {
  67. margin: 5px 0;
  68. padding: 10px;
  69. font-size: 12px;
  70. -webkit-border-radius: 5px;
  71. -moz-border-radius: 5px;
  72. border-radius: 5px;
  73. -moz-background-clip: padding;
  74. -webkit-background-clip: padding-box;
  75. background-clip: padding-box;
  76. background: #FAFAFA;
  77. }
  78. html[data-theme="dark"] .subject_my_comments_section {
  79. background: #353535;
  80. }
  81. .subject_my_comments_section .inner {
  82. font-size: 14px;
  83. color: #444;
  84. }
  85. html[data-theme="dark"] .subject_my_comments_section .inner {
  86. color: #e1e1e1;
  87. }
  88. .subject_my_comments_section .inner.loading {
  89. opacity: .3;
  90. pointer-events: none;
  91. }
  92. `;
  93. };
  94. refreshStyle();
  95. document.head.appendChild(style);
  96.  
  97. async function getEpComments(episodeId) {
  98. return new Promise((resolve, reject) => {
  99. GM_xmlhttpRequest({
  100. method: 'GET',
  101. url: `https://next.bgm.tv/p1/episodes/${episodeId}/comments`,
  102. onload: function(response) {
  103. if (response.status >= 200 && response.status < 300) {
  104. resolve(JSON.parse(response.responseText));
  105. } else {
  106. reject(new Error(`请求失败,状态码: ${response.status}`));
  107. }
  108. },
  109. onerror: function(error) {
  110. reject(new Error(`请求出错: ${error}`));
  111. }
  112. });
  113. });
  114. }
  115.  
  116. const cacheHandler = {
  117. // 初始化时检查并清理过期项目
  118. init(target) {
  119. const data = JSON.parse(localStorage.getItem(target.storageKey) || '{}');
  120. const now = Date.now();
  121. for (const key in data) {
  122. if (data[key].expiry < now) {
  123. delete data[key];
  124. }
  125. }
  126. localStorage.setItem(target.storageKey, JSON.stringify(data));
  127. },
  128. get(target, key) {
  129. const data = JSON.parse(localStorage.getItem(target.storageKey) || '{}');
  130. const now = Date.now();
  131. const oneMonth = 30 * 24 * 60 * 60 * 1000;
  132.  
  133. if (data[key] && now < data[key].expiry) {
  134. // 调用时延后一个月过期时间
  135. data[key].expiry = now + oneMonth;
  136. localStorage.setItem(target.storageKey, JSON.stringify(data));
  137. return data[key].value;
  138. } else {
  139. delete data[key];
  140. localStorage.setItem(target.storageKey, JSON.stringify(data));
  141. return undefined;
  142. }
  143. },
  144. set(target, key, value) {
  145. const now = Date.now();
  146. const oneMonth = 30 * 24 * 60 * 60 * 1000;
  147. const expiry = now + oneMonth;
  148.  
  149. const data = JSON.parse(localStorage.getItem(target.storageKey) || '{}');
  150. data[key] = { value, expiry };
  151. localStorage.setItem(target.storageKey, JSON.stringify(data));
  152.  
  153. return true;
  154. }
  155. };
  156.  
  157. const cacheTarget = { storageKey: 'incheijs_ep_cache' };
  158. cacheHandler.init(cacheTarget);
  159. const cache = new Proxy(cacheTarget, cacheHandler);
  160.  
  161. const saveRepliesHTML = (getHTML) => (epName, epId, replies) => {
  162. sessionStorage.setItem(`incheijs_ep_content_${epId}`, replies.reduce((acc, reply) => {
  163. return acc += `<a class="l" href="/ep/${ epId }#${ reply.id }">📌</a> ${ getHTML(reply) }<div class="clear section_line"></div>`;
  164. }, `<h2 class="subtitle">${epName}</h2>`));
  165. };
  166.  
  167. const saveRepliesHTMLFromDOM = saveRepliesHTML((reply) => reply.querySelector('.message').innerHTML.trim());
  168.  
  169. const saveRepliesHTMLFromJSON = saveRepliesHTML((reply) => bbcodeToHtml(reply.content));
  170.  
  171. // 章节讨论页
  172. if (location.pathname.startsWith('/ep')) {
  173. let replies = getRepliesFromDOM(document);
  174. const id = location.pathname.split('/')[2];
  175. if (replies.length) {
  176. document.getElementById('reply_wrapper').before(...replies.map(elem => {
  177. const clone = elem.cloneNode(true);
  178. clone.id += '_clone';
  179. clone.classList.add('cloned_mine');
  180. clone.querySelectorAll('.likes_grid a').forEach(a => {
  181. a.href = 'javascript:';
  182. a.style.cursor = 'default';
  183. }); // 防止点击贴贴无效跳转首页
  184. return clone;
  185. }));
  186. cache[id] = true;
  187. saveRepliesHTMLFromDOM(document.title.split(' ')[0], id, replies);
  188. } else {
  189. cache[id] = false;
  190. }
  191. // 兼容开播前隐藏
  192.  
  193. // 添加回复
  194. document.querySelector('#ReplyForm').addEventListener('submit', async () => {
  195. const observer = new MutationObserver(() => {
  196. // 因 AJAX 添加的元素未设置 dataset,不可用 getRepliesFromDOM
  197. const myReplies = [...document.querySelectorAll('#comment_list .row_reply')].filter(comment => comment.querySelector('.avatar').href.split('/').pop() === myUsername);
  198. if (myReplies.length) {
  199. cache[id] = true;
  200. saveRepliesHTMLFromDOM(document.title.split(' ')[0], id, myReplies);
  201. observer.disconnect();
  202. }
  203. });
  204. observer.observe(document.querySelector('#comment_list'), { childList: true });
  205. });
  206. // 侧栏其他章节,无法直接判断是否看过,只取缓存不检查
  207. const epElems = document.querySelectorAll('.sideEpList li a');
  208. for (const elem of epElems) {
  209. const url = elem.href;
  210. const id = url.split('/')[4];
  211. if (cache[id] === true) elem.style.color = colors.watched;
  212. }
  213. }
  214.  
  215. function getRepliesFromDOM(dom) {
  216. return [...dom.querySelectorAll('#comment_list .row_reply')].filter(comment => comment.dataset.itemUser === myUsername);
  217. }
  218.  
  219. // 动画条目页
  220. const subjectID = location.pathname.match(/(?<=subject\/)\d+/)?.[0];
  221. if (subjectID) {
  222. const type = document.querySelector('.focus').href.split('/')[3];
  223. if (['anime', 'real'].includes(type)) {
  224. await renderChecks();
  225. document.querySelector('.subject_tag_section').insertAdjacentHTML('afterend', `
  226. <div class="subject_my_comments_section">
  227. <h2 class="subtitle" style="font-size:14px">我的每集吐槽<a style="padding-left:5px;font-size:12px" class="l" id="expandInd" href="javascript:">[展开]</a>
  228. <span class="colorPickers" style="float:right">
  229. <input type="color" class="titleTip" title="看过格子高亮色" name="watched" value=${colors.watched}>
  230. <input type="color" class="titleTip" title="非看过格子高亮色" name="air" value="${colors.air}">
  231. </span>
  232. </h2>
  233. <div class="inner" hidden style="padding: 5px 10px">
  234. ${ [...document.querySelectorAll('.load-epinfo')].map(elem => `<div id="incheijs_ep_content_${elem.id.split('_').pop()}"><div class="loader"></div></div>`).join('') }
  235. </div>
  236. </div>
  237. `);
  238. document.querySelectorAll('.colorPickers input').forEach(picker => {
  239. picker.addEventListener('change', () => {
  240. const type = picker.name;
  241. localStorage.setItem(`incheijs_ep_${type}`, picker.value);
  242. colors[type] = picker.value;
  243. refreshStyle();
  244. });
  245. $(picker).tooltip();
  246. });
  247. document.querySelector('#expandInd').addEventListener('click', async (e) => {
  248. e.target.remove();
  249. const inner = document.querySelector('.subject_my_comments_section .inner');
  250. inner.hidden = false;
  251. inner.classList.add('loading');
  252. await displayMine();
  253. inner.classList.remove('loading');
  254. if (!inner.querySelector('h2')) {
  255. inner.innerHTML = '<div style="width: 100%;text-align:center">没有找到吐槽_(:з”∠)_</div>';
  256. return;
  257. }
  258. [...inner.querySelectorAll('.section_line')].pop()?.remove();
  259. });
  260. }
  261. }
  262.  
  263. // 首页
  264. if (location.pathname === '/') {
  265. renderChecks();
  266. }
  267.  
  268. async function retryAsyncOperation(operation, maxRetries = 3, delay = 1000) {
  269. let error;
  270. for (let i = 0; i < maxRetries; i++) {
  271. try {
  272. return await operation();
  273. } catch (e) {
  274. error = e;
  275. if (i < maxRetries - 1) {
  276. await new Promise(resolve => setTimeout(resolve, delay));
  277. }
  278. }
  279. }
  280. throw error;
  281. }
  282.  
  283. async function limitConcurrency(tasks, concurrency = 2) {
  284. const results = [];
  285. let index = 0;
  286.  
  287. async function runTask() {
  288. while (index < tasks.length) {
  289. const currentIndex = index++;
  290. const task = tasks[currentIndex];
  291. try {
  292. const result = await task();
  293. results[currentIndex] = result;
  294. } catch (error) {
  295. results[currentIndex] = error;
  296. }
  297. }
  298. }
  299.  
  300. const runners = Array.from({ length: concurrency }, runTask);
  301. await Promise.all(runners);
  302. return results;
  303. }
  304.  
  305. async function walkThroughEps({
  306. cached = () => false,
  307. onCached = () => {},
  308. shouldFetch = () => true,
  309. onSuccess = () => {},
  310. onError = () => {}
  311. } = {}) {
  312. const epElems = document.querySelectorAll('.load-epinfo');
  313. const tasks = [];
  314.  
  315. for (const epElem of epElems) {
  316. const epData = {
  317. epElem,
  318. epName: epElem.title.split(' ')[0],
  319. epId: new URL(epElem.href).pathname.split('/').pop()
  320. };
  321.  
  322. tasks.push(async () => {
  323. if (cached(epData)) {
  324. onCached(epData);
  325. return;
  326. } else if (shouldFetch(epData)) {
  327. try {
  328. const data = await retryAsyncOperation(() => getEpComments(epData.epId));
  329. const comments = data.filter(comment => comment.user.username === myUsername && comment.content);
  330. if (comments.length) saveRepliesHTMLFromJSON(epData.epName, epData.epId, comments);
  331. onSuccess(epData, comments);
  332. } catch (error) {
  333. console.error(`Failed to fetch ${epElem.href}:`, error);
  334. onError(epData);
  335. }
  336. }
  337. });
  338. }
  339.  
  340. await limitConcurrency(tasks, 5);
  341. }
  342.  
  343. async function renderChecks() {
  344. await walkThroughEps({
  345. cached: ({ epId }) => cache[epId] !== undefined,
  346. onCached: ({ epElem, epId }) => epElem.classList.add(cache[epId] ? 'commented' : 'uncommented'),
  347. shouldFetch: ({ epElem }) => epElem.classList.contains('epBtnWatched'),
  348. onSuccess: ({ epElem, epId }, comments) => {
  349. const hasComments = comments.length > 0;
  350. cache[epId] = hasComments;
  351. epElem.classList.add(hasComments ? 'commented' : 'uncommented');
  352. }
  353. });
  354. }
  355.  
  356. async function displayMine() {
  357. await walkThroughEps({
  358. cached: ({ epId }) => sessionStorage.getItem(`incheijs_ep_content_${epId}`),
  359. onCached: ({ epId }) => setContainer(epId),
  360. shouldFetch: ({ epId }) => cache[epId],
  361. onSuccess: ({ epId }) => setContainer(epId),
  362. onError: ({ epName, epId }) => setContainer(epId,
  363. `${ epName }加载失败<div class="clear section_line"></div>`
  364. )
  365. });
  366.  
  367. function setContainer(epId, content) {
  368. const cacheKey = `incheijs_ep_content_${epId}`;
  369. const container = document.querySelector(`#${cacheKey}`);
  370. container.innerHTML = content || sessionStorage.getItem(cacheKey);
  371. }
  372. }
  373.  
  374. function bbcodeToHtml(bbcode) {
  375. // (bgm38)
  376. let html = bbcode.replace(/\(bgm(\d+)\)/g, function (_, number) {
  377. const mathNumber = parseInt(number);
  378. let imgUrl, appendix = '';
  379. if (mathNumber > 23) {
  380. const formattedNumber = (mathNumber - 23).toString().padStart(2, '0');
  381. imgUrl = `/img/smiles/tv/${formattedNumber}.gif`;
  382. appendix = 'width="21"';
  383. } else if (mathNumber > 10) {
  384. imgUrl = `/img/smiles/bgm/${number}.gif`;
  385. } else {
  386. imgUrl = `/img/smiles/bgm/${number}.png`;
  387. }
  388. return `<img src="${imgUrl}" smileid="${mathNumber + 16}" alt="(bgm${number})" ${appendix}>`;
  389. });
  390. // [url]
  391. html = html.replace(/\[url=([^\]]+)\]([^\[]+)\[\/url\]/g, '<a class="l" href="$1" target="_blank" rel="nofollow external noopener noreferrer">$2</a>');
  392. html = html.replace(/\[url\]([^[]+)\[\/url\]/g, '<a class="l" href="$1" target="_blank" rel="nofollow external noopener noreferrer">$1</a>');
  393. // [img]
  394. html = html.replace(/\[img(?:=(\d+),(\d+))?\]([^[]+)\[\/img\]/g, function (_, width, height, url) {
  395. const trimmedUrl = url.trim();
  396. return `<img class="code" src="${trimmedUrl}" rel="noreferrer" referrerpolicy="no-referrer" alt="${trimmedUrl}" loading="lazy"${width && height ? ` width="${width}" height="${height}"` : ''}>`;
  397. });
  398. // [b]
  399. html = html.replace(/\[b\]([^[]+)\[\/b\]/g, '<span style="font-weight:bold">$1</span>');
  400. // [u]
  401. html = html.replace(/\[u\]([^[]+)\[\/u\]/g, '<span style="text-decoration:underline">$1</span>');
  402. // [size]
  403. html = html.replace(/\[size=([^\]]+)\]([^[]+)\[\/size\]/g, '<span style="font-size:$1px; line-height:$1px;">$2</span>');
  404. // [mask]
  405. html = html.replace(/\[mask\]([^[]+)\[\/mask\]/g, '<span class="text_mask" style="background-color:#555;color:#555;border:1px solid #555;">$1</span>');
  406. // [s]
  407. html = html.replace(/\[s\]([^[]+)\[\/s\]/g, '<span style="text-decoration: line-through;">$1</span>');
  408. // [quote]
  409. html = html.replace(/\[quote\]([^[]+)\[\/quote\]/g, '<div class="quote"><q>$1</q></div>');
  410. // \n
  411. html = html.replace(/\n/g, '<br>');
  412.  
  413. return html;
  414. }
  415.  
  416. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址