|
1 | 1 | // ==UserScript== |
2 | 2 | // @name /mjg/ Emote Replacer |
3 | 3 | // @namespace http://repo.riichi.moe/ |
4 | | -// @version 1.3.9 |
| 4 | +// @namespace http://repo.riichi.moe/ |
| 5 | +// @version 1.3.10 |
5 | 6 | // @description Detects emote strings in imageless posts in /mjg/ threads, and displays them as fake images posts. |
6 | 7 | // @icon https://files.catbox.moe/3sh459.png |
7 | 8 | // @author Ling and Anon |
|
20 | 21 | // Sources for MJS and RC emotes |
21 | 22 | const EMOTE_BASE_URLS = [ |
22 | 23 | 'https://files.riichi.moe/mjg/game%20resources%20and%20tools/Mahjong%20Soul/game%20files/emotes/', |
23 | | - 'https://files.riichi.moe/mjg/game%20resources%20and%20tools/Mahjong%20Soul/emote%20edits/', |
24 | | - 'https://tanoshii.moe/images/riichi_city_emotes/' |
| 24 | + 'https://files.riichi.moe/mjg/game%20resources%20and%20tools/Riichi%20City/emotes_new/' |
25 | 25 | ]; |
26 | | - |
27 | | - const EMOTE_REGEX = /\b(([a-zA-Z0-9\-\.]+-\d+[a-zA-Z]{0,10}|mooncakes\/\d)\.(?:png|jpg|jpeg|gif))\b/i; |
| 26 | + const EMOTE_REGEX = /\b(([a-zA-Z0-9\&\-\.]+-\d+[a-z]{0,4}|mooncakes\/\d)\.(?:png|jpg|jpeg|gif))\b/i; |
28 | 27 | const PROCESSED_MARKER = 'data-mjg-emote-processed'; // Values: 'true' (success), 'has-file', 'no-message', 'limit-not-reached', 'emote-not-found', 'checking' |
29 | 28 |
|
30 | | - // --- Helper: Check if remote image exists (tries multiple URLs) --- |
31 | | - function checkImageExists(urls) { |
32 | | - return new Promise(async resolve => { |
33 | | - for (const url of urls) { |
34 | | - try { |
35 | | - const img = new Image(); |
36 | | - const result = await new Promise(resolve => { |
37 | | - img.onload = () => resolve(true); // Image loaded successfully |
38 | | - img.onerror = () => resolve(false); // Image failed to load (404, CORS block, invalid etc.) |
39 | | - img.onabort = () => resolve(false); // Handle aborts as well |
40 | | - try { |
41 | | - img.src = url; |
42 | | - } catch (e) { |
43 | | - console.error("/mjg/ Emote Replacer: Error synchronously thrown while setting src for " + url, e); |
44 | | - resolve(false); |
| 29 | + // --- Main Execution --- |
| 30 | + if (isMjgThread()) { |
| 31 | + requestAnimationFrame(() => { |
| 32 | + initialScan(); |
| 33 | + observeNewPosts(); |
| 34 | + }); |
| 35 | + } |
| 36 | + |
| 37 | + // --- Check if this is the correct type of thread --- |
| 38 | + function isMjgThread() { |
| 39 | + const opSubjectElement = document.querySelector('.opContainer .postInfo .subject, .opContainer .postInfoM .subject'); |
| 40 | + if (!opSubjectElement) return false; |
| 41 | + const subjectText = opSubjectElement.textContent.toLowerCase(); |
| 42 | + return subjectText.includes('mjg') || subjectText.includes('mahjong'); |
| 43 | + } |
| 44 | + |
| 45 | + // --- Initial Scan --- |
| 46 | + function initialScan() { |
| 47 | + const posts = document.querySelectorAll('.postContainer.replyContainer .post.reply'); |
| 48 | + posts.forEach(post => { |
| 49 | + processPost(post).catch(e => { |
| 50 | + console.error("/mjg/ Emote Replacer: Error during async processPost in initial scan:", post?.id, e); |
| 51 | + // Mark post to avoid retrying on error. |
| 52 | + if (post) post.setAttribute(PROCESSED_MARKER, 'error'); |
| 53 | + }); |
| 54 | + }); |
| 55 | + } |
| 56 | + |
| 57 | + // --- Observe for new posts --- |
| 58 | + function observeNewPosts() { |
| 59 | + const threadElement = document.querySelector('.thread'); |
| 60 | + if (!threadElement) { |
| 61 | + console.error('/mjg/ Emote Replacer: Could not find thread element to observe.'); |
| 62 | + return; |
| 63 | + } |
| 64 | + const observer = new MutationObserver(mutations => { |
| 65 | + let postsToProcess = new Set(); |
| 66 | + mutations.forEach(mutation => { |
| 67 | + mutation.addedNodes.forEach(node => { |
| 68 | + if (node.nodeType === Node.ELEMENT_NODE) { |
| 69 | + if (node.matches('.postContainer.replyContainer')) { |
| 70 | + const postElement = node.querySelector('.post.reply'); |
| 71 | + if (postElement) postsToProcess.add(postElement); |
| 72 | + } else { |
| 73 | + node.querySelectorAll('.postContainer.replyContainer .post.reply').forEach(postElement => { |
| 74 | + postsToProcess.add(postElement); |
| 75 | + }); |
45 | 76 | } |
46 | | - }); |
47 | | - if (result) { |
48 | | - resolve({ exists: true, url: url }); |
49 | | - return; |
50 | 77 | } |
51 | | - } catch (e) { |
52 | | - // Continue to next URL |
53 | | - } |
| 78 | + }); |
| 79 | + }); |
| 80 | + |
| 81 | + if (postsToProcess.size > 0) { |
| 82 | + postsToProcess.forEach(postElement => { |
| 83 | + processPost(postElement).catch(e => { |
| 84 | + console.error("/mjg/ Emote Replacer: Error during async processPost from observer:", postElement?.id, e); |
| 85 | + if (postElement) postElement.setAttribute(PROCESSED_MARKER, 'error'); |
| 86 | + }); |
| 87 | + }); |
54 | 88 | } |
55 | | - resolve({ exists: false, url: null }); |
56 | 89 | }); |
| 90 | + |
| 91 | + observer.observe(threadElement, { childList: true, subtree: true }); |
| 92 | + } |
| 93 | + |
| 94 | + // --- Process a single post --- |
| 95 | + async function processPost(postElement) { |
| 96 | + // Basic checks first |
| 97 | + const postId = postElement?.id || 'unknown-element'; |
| 98 | + if (!postElement || !postElement.matches || !postElement.matches('.post.reply')) return; |
| 99 | + |
| 100 | + // Prevent re-processing or processing posts currently being checked |
| 101 | + const currentState = postElement.getAttribute(PROCESSED_MARKER); |
| 102 | + // Already processed, has file, no message, emote not found, or currently checking |
| 103 | + if (currentState && currentState !== 'limit-not-reached') return; |
| 104 | + |
| 105 | + // Check if it already has a *real* file attachment |
| 106 | + if (postElement.querySelector('.file')) { postElement.setAttribute(PROCESSED_MARKER, 'has-file'); return; } |
| 107 | + |
| 108 | + // Check if image limit is reached *now* |
| 109 | + const currentImageCount = getImageCount(); |
| 110 | + if (currentImageCount === -1 || currentImageCount < IMAGE_LIMIT) { |
| 111 | + postElement.setAttribute(PROCESSED_MARKER, 'limit-not-reached'); // Mark temporarily |
| 112 | + return; |
| 113 | + } |
| 114 | + // If limit was previously not reached, clear that temporary state |
| 115 | + if (currentState === 'limit-not-reached') { |
| 116 | + postElement.removeAttribute(PROCESSED_MARKER); |
| 117 | + } |
| 118 | + |
| 119 | + const postMessageElement = postElement.querySelector('.postMessage'); |
| 120 | + if (!postMessageElement) { |
| 121 | + postElement.setAttribute(PROCESSED_MARKER, 'no-message'); |
| 122 | + return; |
| 123 | + } |
| 124 | + |
| 125 | + const emoteString = findEmoteInNodes(postMessageElement); |
| 126 | + |
| 127 | + if (emoteString) { |
| 128 | + |
| 129 | + // Mark as checking to prevent concurrent checks from observer |
| 130 | + postElement.setAttribute(PROCESSED_MARKER, 'checking'); |
| 131 | + |
| 132 | + // Small delay might prevent rare race conditions. |
| 133 | + await new Promise(resolve => setTimeout(resolve, 50)); |
| 134 | + |
| 135 | + if (postElement.getAttribute(PROCESSED_MARKER) !== 'checking') return; // State changed during await |
| 136 | + |
| 137 | + // Check if the remote image actually exists |
| 138 | + const fullImageUrl = await findImageUrl(emoteString); |
| 139 | + if (postElement.getAttribute(PROCESSED_MARKER) !== 'checking') return; // State changed during check |
| 140 | + |
| 141 | + if (fullImageUrl) { |
| 142 | + addFakeImage(postElement, emoteString, fullImageUrl); |
| 143 | + postElement.setAttribute(PROCESSED_MARKER, 'true'); |
| 144 | + } else { |
| 145 | + // Mark as processed but note that the emote was not found |
| 146 | + postElement.setAttribute(PROCESSED_MARKER, 'emote-not-found'); |
| 147 | + } |
| 148 | + } else { |
| 149 | + // No emote string found in this post, mark it as processed for this state |
| 150 | + postElement.setAttribute(PROCESSED_MARKER, 'no-emote-found'); |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + // --- Get current image count --- |
| 155 | + function getImageCount() { |
| 156 | + let fileCountElement = document.getElementById('file-count'); |
| 157 | + if (!fileCountElement) { |
| 158 | + const threadStatsDiv = document.querySelector('.thread-stats .ts-images'); |
| 159 | + if (threadStatsDiv) fileCountElement = threadStatsDiv; |
| 160 | + } |
| 161 | + if (fileCountElement?.textContent) { |
| 162 | + const count = parseInt(fileCountElement.textContent.trim(), 10); |
| 163 | + return isNaN(count) ? -1 : count; |
| 164 | + } |
| 165 | + return -1; |
57 | 166 | } |
58 | 167 |
|
59 | 168 | // --- Helper: Find Emote String by Traversing Nodes (Recursive) --- |
|
80 | 189 | return null; |
81 | 190 | } |
82 | 191 |
|
83 | | - // --- 1. Check if this is the correct type of thread --- |
84 | | - function isMjgThread() { |
85 | | - const opSubjectElement = document.querySelector('.opContainer .postInfo .subject, .opContainer .postInfoM .subject'); |
86 | | - if (!opSubjectElement) return false; |
87 | | - const subjectText = opSubjectElement.textContent.toLowerCase(); |
88 | | - return subjectText.includes('mjg') || subjectText.includes('mahjong'); |
| 192 | + // --- Helper: Find if the emote exists in any of the base folders |
| 193 | + async function findImageUrl(emoteString) { |
| 194 | + for (const baseURL of EMOTE_BASE_URLS) { |
| 195 | + const fullImageUrl = baseURL + encodeURIComponent(emoteString); |
| 196 | + if (await checkImageExists(fullImageUrl)) { |
| 197 | + return fullImageUrl; |
| 198 | + } |
| 199 | + } |
| 200 | + return null; |
89 | 201 | } |
90 | 202 |
|
91 | | - // --- 2. Get current image count --- |
92 | | - function getImageCount() { |
93 | | - let fileCountElement = document.getElementById('file-count'); |
94 | | - if (!fileCountElement) { |
95 | | - const threadStatsDiv = document.querySelector('.thread-stats .ts-images'); |
96 | | - if (threadStatsDiv) fileCountElement = threadStatsDiv; |
97 | | - } |
98 | | - if (fileCountElement?.textContent) { |
99 | | - const count = parseInt(fileCountElement.textContent.trim(), 10); |
100 | | - return isNaN(count) ? -1 : count; |
101 | | - } |
102 | | - return -1; |
| 203 | + // --- Helper: Check if remote image exists --- |
| 204 | + function checkImageExists(url) { |
| 205 | + return new Promise(resolve => { |
| 206 | + const img = new Image(); |
| 207 | + img.onload = () => resolve(true); // Image loaded successfully |
| 208 | + img.onerror = (err) => resolve(false); // Image failed to load (404, CORS block, invalid etc.) |
| 209 | + img.onabort = () => resolve(false); // Handle aborts as well |
| 210 | + try { |
| 211 | + img.src = url; |
| 212 | + } catch (e) { |
| 213 | + console.error("/mjg/ Emote Replacer: Error synchronously thrown while setting src for " + url, e); |
| 214 | + resolve(false); |
| 215 | + } |
| 216 | + }); |
103 | 217 | } |
104 | 218 |
|
105 | | - // --- 3. Create and inject fake image HTML --- |
106 | | - function addFakeImage(postElement, emoteString, resolvedUrl) { |
| 219 | + // --- Create and inject fake image HTML --- |
| 220 | + function addFakeImage(postElement, emoteString, fullImageUrl) { |
107 | 221 | const postId = postElement.id ? postElement.id.substring(1) : null; |
108 | 222 | if (!postId) { |
109 | 223 | console.warn("/mjg/ Emote Replacer: Could not get post ID for", postElement); |
|
112 | 226 | // Double check file doesn't exist (in case of race condition) |
113 | 227 | if (postElement.querySelector('.file')) return; |
114 | 228 |
|
115 | | - const fullImageUrl = resolvedUrl; |
116 | 229 | const uniqueFileId = `f${postId}-emote`; |
117 | 230 | const uniqueFileTextId = `fT${postId}-emote`; |
118 | 231 |
|
|
169 | 282 | } else { |
170 | 283 | postElement.appendChild(fileDiv); |
171 | 284 | } |
172 | | - // console.log(`/mjg/ Emote Replacer: Added fake image for ${emoteString} to post ${postId}`); |
173 | | - } |
174 | | - |
175 | | - |
176 | | - // --- 4. Process a single post --- |
177 | | - async function processPost(postElement) { |
178 | | - // Basic checks first |
179 | | - const postId = postElement?.id || 'unknown-element'; |
180 | | - if (!postElement || !postElement.matches || !postElement.matches('.post.reply')) return; |
181 | | - |
182 | | - // Prevent re-processing or processing posts currently being checked |
183 | | - const currentState = postElement.getAttribute(PROCESSED_MARKER); |
184 | | - // Already processed, has file, no message, emote not found, or currently checking |
185 | | - if (currentState && currentState !== 'limit-not-reached') return; |
186 | | - |
187 | | - // Check if it already has a *real* file attachment |
188 | | - if (postElement.querySelector('.file')) { postElement.setAttribute(PROCESSED_MARKER, 'has-file'); return; } |
189 | | - |
190 | | - // Check if image limit is reached *now* |
191 | | - const currentImageCount = getImageCount(); |
192 | | - if (currentImageCount === -1 || currentImageCount < IMAGE_LIMIT) { |
193 | | - postElement.setAttribute(PROCESSED_MARKER, 'limit-not-reached'); // Mark temporarily |
194 | | - return; |
195 | | - } |
196 | | - // If limit was previously not reached, clear that temporary state |
197 | | - if (currentState === 'limit-not-reached') { |
198 | | - postElement.removeAttribute(PROCESSED_MARKER); |
199 | | - } |
200 | | - |
201 | | - const postMessageElement = postElement.querySelector('.postMessage'); |
202 | | - if (!postMessageElement) { |
203 | | - postElement.setAttribute(PROCESSED_MARKER, 'no-message'); |
204 | | - return; |
205 | | - } |
206 | | - |
207 | | - const emoteString = findEmoteInNodes(postMessageElement); |
208 | | - |
209 | | - if (emoteString) { |
210 | | - // Generate all candidate URLs for this emote |
211 | | - const candidateUrls = EMOTE_BASE_URLS.map(base => base + encodeURIComponent(emoteString)); |
212 | | - |
213 | | - // Mark as checking to prevent concurrent checks from observer |
214 | | - postElement.setAttribute(PROCESSED_MARKER, 'checking'); |
215 | | - |
216 | | - // Small delay might prevent rare race conditions. |
217 | | - await new Promise(resolve => setTimeout(resolve, 50)); |
218 | | - |
219 | | - if (postElement.getAttribute(PROCESSED_MARKER) !== 'checking') return; // State changed during await |
220 | | - |
221 | | - // Check if the remote image actually exists |
222 | | - const imageExists = await checkImageExists(candidateUrls); |
223 | | - if (postElement.getAttribute(PROCESSED_MARKER) !== 'checking') return; // State changed during check |
224 | | - if (imageExists.exists) { |
225 | | - addFakeImage(postElement, emoteString, imageExists.url); |
226 | | - postElement.setAttribute(PROCESSED_MARKER, 'true'); |
227 | | - } else { |
228 | | - // Mark as processed but note that the emote was not found |
229 | | - postElement.setAttribute(PROCESSED_MARKER, 'emote-not-found'); |
230 | | - } |
231 | | - } else { |
232 | | - // No emote string found in this post, mark it as processed for this state |
233 | | - postElement.setAttribute(PROCESSED_MARKER, 'no-emote-found'); |
234 | | - } |
235 | | - } |
236 | | - |
237 | | - // --- 5. Initial Scan --- |
238 | | - function initialScan() { |
239 | | - const posts = document.querySelectorAll('.postContainer.replyContainer .post.reply'); |
240 | | - posts.forEach(post => { |
241 | | - processPost(post).catch(e => { |
242 | | - console.error("/mjg/ Emote Replacer: Error during async processPost in initial scan:", post?.id, e); |
243 | | - // Mark post to avoid retrying on error. |
244 | | - if (post) post.setAttribute(PROCESSED_MARKER, 'error'); |
245 | | - }); |
246 | | - }); |
247 | | - } |
248 | | - |
249 | | - // --- 6. Observe for new posts --- |
250 | | - function observeNewPosts() { |
251 | | - const threadElement = document.querySelector('.thread'); |
252 | | - if (!threadElement) { |
253 | | - console.error('/mjg/ Emote Replacer: Could not find thread element to observe.'); |
254 | | - return; |
255 | | - } |
256 | | - const observer = new MutationObserver(mutations => { |
257 | | - let postsToProcess = new Set(); |
258 | | - mutations.forEach(mutation => { |
259 | | - mutation.addedNodes.forEach(node => { |
260 | | - if (node.nodeType === Node.ELEMENT_NODE) { |
261 | | - if (node.matches('.postContainer.replyContainer')) { |
262 | | - const postElement = node.querySelector('.post.reply'); |
263 | | - if (postElement) postsToProcess.add(postElement); |
264 | | - } else { |
265 | | - node.querySelectorAll('.postContainer.replyContainer .post.reply').forEach(postElement => { |
266 | | - postsToProcess.add(postElement); |
267 | | - }); |
268 | | - } |
269 | | - } |
270 | | - }); |
271 | | - }); |
272 | | - |
273 | | - if (postsToProcess.size > 0) { |
274 | | - postsToProcess.forEach(postElement => { |
275 | | - processPost(postElement).catch(e => { |
276 | | - console.error("/mjg/ Emote Replacer: Error during async processPost from observer:", postElement?.id, e); |
277 | | - if (postElement) postElement.setAttribute(PROCESSED_MARKER, 'error'); |
278 | | - }); |
279 | | - }); |
280 | | - } |
281 | | - }); |
282 | | - |
283 | | - observer.observe(threadElement, { childList: true, subtree: true }); |
284 | | - } |
285 | | - |
286 | | - // --- Main Execution --- |
287 | | - if (isMjgThread()) { |
288 | | - requestAnimationFrame(() => { |
289 | | - initialScan(); |
290 | | - observeNewPosts(); |
291 | | - }); |
292 | 285 | } |
293 | 286 |
|
294 | 287 | })(); |
0 commit comments