8chan Lightweight Extended Suite

Spoiler revealer for 8chan with nested replies

当前为 2025-04-18 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name 8chan Lightweight Extended Suite
  3. // @namespace https://greasyfork.org/en/scripts/533173
  4. // @version 2.1.1
  5. // @description Spoiler revealer for 8chan with nested replies
  6. // @author impregnator
  7. // @match https://8chan.moe/*
  8. // @match https://8chan.se/*
  9. // @match https://8chan.cc/*
  10. // @grant none
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. // Function to process images and replace spoiler placeholders with thumbnails
  18. function processImages(images, isCatalog = false) {
  19. images.forEach(img => {
  20. // Check if the image is a spoiler placeholder (custom or default)
  21. if (img.src.includes('custom.spoiler') || img.src.includes('spoiler.png')) {
  22. let fullFileUrl;
  23. if (isCatalog) {
  24. // Catalog: Get the href from the parent <a class="linkThumb">
  25. const link = img.closest('a.linkThumb');
  26. if (link) {
  27. // Construct the thumbnail URL based on the thread URL
  28. fullFileUrl = link.href;
  29. const threadMatch = fullFileUrl.match(/\/([a-z0-9]+)\/res\/([0-9]+)\.html$/i);
  30. if (threadMatch && threadMatch[1] && threadMatch[2]) {
  31. const board = threadMatch[1];
  32. const threadId = threadMatch[2];
  33. // Fetch the thread page to find the actual image URL
  34. fetchThreadImage(board, threadId).then(thumbnailUrl => {
  35. if (thumbnailUrl) {
  36. img.src = thumbnailUrl;
  37. }
  38. });
  39. }
  40. }
  41. } else {
  42. // Thread: Get the parent <a> element containing the full-sized file URL
  43. const link = img.closest('a.imgLink');
  44. if (link) {
  45. // Extract the full-sized file URL
  46. fullFileUrl = link.href;
  47. // Extract the file hash (everything after /.media/ up to the extension)
  48. const fileHash = fullFileUrl.match(/\/\.media\/([a-f0-9]+)\.[a-z0-9]+$/i);
  49. if (fileHash && fileHash[1]) {
  50. // Construct the thumbnail URL using the current domain
  51. const thumbnailUrl = `${window.location.origin}/.media/t_${fileHash[1]}`;
  52. // Replace the spoiler image with the thumbnail
  53. img.src = thumbnailUrl;
  54. }
  55. }
  56. }
  57. }
  58. });
  59. }
  60.  
  61. // Function to fetch the thread page and extract the thumbnail URL
  62. async function fetchThreadImage(board, threadId) {
  63. try {
  64. const response = await fetch(`https://${window.location.host}/${board}/res/${threadId}.html`);
  65. const text = await response.text();
  66. const parser = new DOMParser();
  67. const doc = parser.parseFromString(text, 'text/html');
  68. // Find the first image in the thread's OP post
  69. const imgLink = doc.querySelector('.uploadCell a.imgLink');
  70. if (imgLink) {
  71. const fullFileUrl = imgLink.href;
  72. const fileHash = fullFileUrl.match(/\/\.media\/([a-f0-9]+)\.[a-z0-9]+$/i);
  73. if (fileHash && fileHash[1]) {
  74. return `${window.location.origin}/.media/t_${fileHash[1]}`;
  75. }
  76. }
  77. return null;
  78. } catch (error) {
  79. console.error('Error fetching thread image:', error);
  80. return null;
  81. }
  82. }
  83.  
  84. // Process existing images on page load
  85. const isCatalogPage = window.location.pathname.includes('catalog.html');
  86. if (isCatalogPage) {
  87. const initialCatalogImages = document.querySelectorAll('.catalogCell a.linkThumb img');
  88. processImages(initialCatalogImages, true);
  89. } else {
  90. const initialThreadImages = document.querySelectorAll('.uploadCell img');
  91. processImages(initialThreadImages, false);
  92. }
  93.  
  94. // Set up MutationObserver to handle dynamically added posts
  95. const observer = new MutationObserver(mutations => {
  96. mutations.forEach(mutation => {
  97. if (mutation.addedNodes.length) {
  98. // Check each added node for new images
  99. mutation.addedNodes.forEach(node => {
  100. if (node.nodeType === Node.ELEMENT_NODE) {
  101. if (isCatalogPage) {
  102. const newCatalogImages = node.querySelectorAll('.catalogCell a.linkThumb img');
  103. processImages(newCatalogImages, true);
  104. } else {
  105. const newThreadImages = node.querySelectorAll('.uploadCell img');
  106. processImages(newThreadImages, false);
  107. }
  108. }
  109. });
  110. }
  111. });
  112. });
  113.  
  114. // Observe changes to the document body, including child nodes and subtrees
  115. observer.observe(document.body, {
  116. childList: true,
  117. subtree: true
  118. });
  119. })();
  120.  
  121. //Opening all posts from the catalog in a new tag section
  122.  
  123. // Add click event listener to catalog thumbnail images
  124. document.addEventListener('click', function(e) {
  125. // Check if the clicked element is an image inside a catalog cell
  126. if (e.target.tagName === 'IMG' && e.target.closest('.catalogCell')) {
  127. // Find the parent link with class 'linkThumb'
  128. const link = e.target.closest('.linkThumb');
  129. if (link) {
  130. // Prevent default link behavior
  131. e.preventDefault();
  132. // Open the thread in a new tab
  133. window.open(link.href, '_blank');
  134. }
  135. }
  136. });
  137.  
  138. //Automatically redirect to catalog section
  139.  
  140. // Redirect to catalog if on a board's main page, excluding overboard pages
  141. (function() {
  142. const currentPath = window.location.pathname;
  143. // Check if the path matches a board's main page (e.g., /v/, /a/) but not overboard pages
  144. if (currentPath.match(/^\/[a-zA-Z0-9]+\/$/) && !currentPath.match(/^\/(sfw|overboard)\/$/)) {
  145. // Redirect to the catalog page
  146. window.location.replace(currentPath + 'catalog.html');
  147. }
  148. })();
  149.  
  150. // Text spoiler revealer
  151.  
  152. (function() {
  153. // Function to reveal spoilers
  154. function revealSpoilers() {
  155. const spoilers = document.querySelectorAll('span.spoiler');
  156. spoilers.forEach(spoiler => {
  157. // Override default spoiler styles to make text visible
  158. spoiler.style.background = 'none';
  159. spoiler.style.color = 'inherit';
  160. spoiler.style.textShadow = 'none';
  161. });
  162. }
  163.  
  164. // Run initially for existing spoilers
  165. revealSpoilers();
  166.  
  167. // Set up MutationObserver to watch for new spoilers
  168. const observer = new MutationObserver((mutations) => {
  169. mutations.forEach(mutation => {
  170. if (mutation.addedNodes.length > 0) {
  171. // Check if new nodes contain spoilers
  172. mutation.addedNodes.forEach(node => {
  173. if (node.nodeType === Node.ELEMENT_NODE) {
  174. const newSpoilers = node.querySelectorAll('span.spoiler');
  175. newSpoilers.forEach(spoiler => {
  176. spoiler.style.background = 'none';
  177. spoiler.style.color = 'inherit';
  178. spoiler.style.textShadow = 'none';
  179. });
  180. }
  181. });
  182. }
  183. });
  184. });
  185.  
  186. // Observe the document body for changes (new posts)
  187. observer.observe(document.body, {
  188. childList: true,
  189. subtree: true
  190. });
  191. })();
  192.  
  193. //Inline reply chains
  194.  
  195. (function() {
  196. 'use strict';
  197.  
  198. console.log('Userscript is running');
  199.  
  200. // Add CSS for visual nesting
  201. const style = document.createElement('style');
  202. style.innerHTML = `
  203. .inlineQuote .replyPreview {
  204. margin-left: 20px;
  205. border-left: 1px solid #ccc;
  206. padding-left: 10px;
  207. }
  208. .closeInline {
  209. color: #ff0000;
  210. cursor: pointer;
  211. margin-left: 5px;
  212. font-weight: bold;
  213. }
  214. `;
  215. document.head.appendChild(style);
  216.  
  217. // Wait for tooltips to initialize
  218. window.addEventListener('load', function() {
  219. if (!window.tooltips) {
  220. console.error('tooltips module not found');
  221. return;
  222. }
  223. console.log('tooltips module found');
  224.  
  225. // Ensure Inline Replies is enabled
  226. if (!tooltips.inlineReplies) {
  227. console.log('Enabling Inline Replies');
  228. localStorage.setItem('inlineReplies', 'true');
  229. tooltips.inlineReplies = true;
  230.  
  231. // Check and update the checkbox, retrying if not yet loaded
  232. const enableCheckbox = () => {
  233. const inlineCheckbox = document.getElementById('settings-SW5saW5lIFJlcGxpZX');
  234. if (inlineCheckbox) {
  235. inlineCheckbox.checked = true;
  236. console.log('Inline Replies checkbox checked');
  237. return true;
  238. }
  239. console.warn('Inline Replies checkbox not found, retrying...');
  240. return false;
  241. };
  242.  
  243. // Try immediately
  244. if (!enableCheckbox()) {
  245. // Retry every 500ms up to 5 seconds
  246. let attempts = 0;
  247. const maxAttempts = 10;
  248. const interval = setInterval(() => {
  249. if (enableCheckbox() || attempts >= maxAttempts) {
  250. clearInterval(interval);
  251. if (attempts >= maxAttempts) {
  252. console.error('Failed to find Inline Replies checkbox after retries');
  253. }
  254. }
  255. attempts++;
  256. }, 500);
  257. }
  258. } else {
  259. console.log('Inline Replies already enabled');
  260. }
  261.  
  262. // Override addLoadedTooltip to ensure replyPreview exists
  263. const originalAddLoadedTooltip = tooltips.addLoadedTooltip;
  264. tooltips.addLoadedTooltip = function(htmlContents, tooltip, quoteUrl, replyId, isInline) {
  265. console.log('addLoadedTooltip called for:', quoteUrl);
  266. originalAddLoadedTooltip.apply(this, arguments);
  267. if (isInline) {
  268. let replyPreview = htmlContents.querySelector('.replyPreview');
  269. if (!replyPreview) {
  270. replyPreview = document.createElement('div');
  271. replyPreview.className = 'replyPreview';
  272. htmlContents.appendChild(replyPreview);
  273. }
  274. }
  275. };
  276.  
  277. // Override addInlineClick for nested replies, excluding post number links
  278. tooltips.addInlineClick = function(quote, innerPost, isBacklink, quoteTarget, sourceId) {
  279. // Skip post number links (href starts with #q)
  280. if (quote.href.includes('#q')) {
  281. console.log('Skipping post number link:', quote.href);
  282. return;
  283. }
  284.  
  285. // Remove existing listeners by cloning
  286. const newQuote = quote.cloneNode(true);
  287. quote.parentNode.replaceChild(newQuote, quote);
  288. quote = newQuote;
  289.  
  290. // Reapply hover events to preserve preview functionality
  291. tooltips.addHoverEvents(quote, innerPost, quoteTarget, sourceId);
  292. console.log('Hover events reapplied for:', quoteTarget.quoteUrl);
  293.  
  294. // Add click handler
  295. quote.addEventListener('click', function(e) {
  296. console.log('linkQuote clicked:', quoteTarget.quoteUrl);
  297. if (!tooltips.inlineReplies) {
  298. console.log('inlineReplies disabled');
  299. return;
  300. }
  301. e.preventDefault();
  302. e.stopPropagation(); // Prevent site handlers
  303.  
  304. // Find or create replyPreview
  305. let replyPreview = innerPost.querySelector('.replyPreview');
  306. if (!replyPreview) {
  307. replyPreview = document.createElement('div');
  308. replyPreview.className = 'replyPreview';
  309. innerPost.appendChild(replyPreview);
  310. }
  311.  
  312. // Check for duplicates or loading
  313. if (tooltips.loadingPreviews[quoteTarget.quoteUrl] ||
  314. tooltips.quoteAlreadyAdded(quoteTarget.quoteUrl, innerPost)) {
  315. console.log('Duplicate or loading:', quoteTarget.quoteUrl);
  316. return;
  317. }
  318.  
  319. // Create and load inline post
  320. const placeHolder = document.createElement('div');
  321. placeHolder.style.whiteSpace = 'normal';
  322. placeHolder.className = 'inlineQuote';
  323. tooltips.loadTooltip(placeHolder, quoteTarget.quoteUrl, sourceId, true);
  324.  
  325. // Verify post loaded
  326. if (!placeHolder.querySelector('.linkSelf')) {
  327. console.log('Failed to load post:', quoteTarget.quoteUrl);
  328. return;
  329. }
  330.  
  331. // Add close button
  332. const close = document.createElement('a');
  333. close.innerText = 'X';
  334. close.className = 'closeInline';
  335. close.onclick = () => placeHolder.remove();
  336. placeHolder.querySelector('.postInfo').prepend(close);
  337.  
  338. // Process quotes in the new inline post
  339. Array.from(placeHolder.querySelectorAll('.linkQuote'))
  340. .forEach(a => tooltips.processQuote(a, false, true));
  341.  
  342. if (tooltips.bottomBacklinks) {
  343. const alts = placeHolder.querySelector('.altBacklinks');
  344. if (alts && alts.firstChild) {
  345. Array.from(alts.firstChild.children)
  346. .forEach(a => tooltips.processQuote(a, true));
  347. }
  348. }
  349.  
  350. // Append to replyPreview
  351. replyPreview.appendChild(placeHolder);
  352. console.log('Inline post appended:', quoteTarget.quoteUrl);
  353.  
  354. tooltips.removeIfExists();
  355. }, true); // Use capture phase
  356. };
  357.  
  358. // Reprocess all existing linkQuote and backlink elements, excluding post numbers
  359. console.log('Reprocessing linkQuote elements');
  360. const quotes = document.querySelectorAll('.linkQuote, .panelBacklinks a');
  361. quotes.forEach(quote => {
  362. const innerPost = quote.closest('.innerPost, .innerOP');
  363. if (!innerPost) {
  364. console.log('No innerPost found for quote:', quote.href);
  365. return;
  366. }
  367.  
  368. // Skip post number links
  369. if (quote.href.includes('#q')) {
  370. console.log('Skipping post number link:', quote.href);
  371. return;
  372. }
  373.  
  374. const isBacklink = quote.parentElement.classList.contains('panelBacklinks') ||
  375. quote.parentElement.classList.contains('altBacklinks');
  376. const quoteTarget = api.parsePostLink(quote.href);
  377. const sourceId = api.parsePostLink(innerPost.querySelector('.linkSelf').href).post;
  378.  
  379. tooltips.addInlineClick(quote, innerPost, isBacklink, quoteTarget, sourceId);
  380. });
  381.  
  382. // Observe for dynamically added posts
  383. const observer = new MutationObserver(mutations => {
  384. mutations.forEach(mutation => {
  385. mutation.addedNodes.forEach(node => {
  386. if (node.nodeType !== 1) return;
  387. const newQuotes = node.querySelectorAll('.linkQuote, .panelBacklinks a');
  388. newQuotes.forEach(quote => {
  389. if (quote.dataset.processed || quote.href.includes('#q')) {
  390. if (quote.href.includes('#q')) {
  391. console.log('Skipping post number link:', quote.href);
  392. }
  393. return;
  394. }
  395. quote.dataset.processed = 'true';
  396. const innerPost = quote.closest('.innerPost, .innerOP');
  397. if (!innerPost) return;
  398.  
  399. const isBacklink = quote.parentElement.classList.contains('panelBacklinks') ||
  400. quote.parentElement.classList.contains('altBacklinks');
  401. const quoteTarget = api.parsePostLink(quote.href);
  402. const sourceId = api.parsePostLink(innerPost.querySelector('.linkSelf').href).post;
  403.  
  404. tooltips.addInlineClick(quote, innerPost, isBacklink, quoteTarget, sourceId);
  405. });
  406. });
  407. });
  408. });
  409. observer.observe(document.querySelector('.divPosts') || document.body, {
  410. childList: true,
  411. subtree: true
  412. });
  413. console.log('MutationObserver set up');
  414. });
  415. })();