Guest User

easychan/mokachan archive

a guest
Mar 16th, 2026
81
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 19.34 KB | None | 0 0
  1. // ==UserScript==
  2. // @name Easychan/Mokachan Archive
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.0
  5. // @match https://easychan.net/*
  6. // @match https://mokachan.cafe/*
  7. // @grant none
  8. // ==/UserScript==
  9.  
  10. (function () {
  11. 'use strict';
  12.  
  13. const API_URL = 'https://meguca-archive.onrender.com';
  14. const SHA1_RE = /\/([0-9a-f]{40})\.\w/;
  15. const SOURCE = location.hostname.includes('mokachan') ? 'mokachan' : 'easychan';
  16. const ORIGIN_BASES = { easychan: 'https://easychan.net', mokachan: 'https://mokachan.cafe' };
  17.  
  18. // -----------------------------------------------------------------------
  19. // SERVER API
  20. // -----------------------------------------------------------------------
  21. async function searchPostsAdvanced(params) {
  22. const qs = new URLSearchParams();
  23. if (params.text) qs.set('q', params.text);
  24. if (params.sha1) qs.set('sha1', params.sha1);
  25. if (params.filename) qs.set('filename', params.filename);
  26. if (params.post) qs.set('post_id', params.post);
  27. if (params.thread) qs.set('thread_id', params.thread);
  28. if (params.board) qs.set('board', params.board);
  29. if (params.dateStart) qs.set('date_start', params.dateStart);
  30. if (params.dateEnd) qs.set('date_end', params.dateEnd);
  31. qs.set('source', params.source || SOURCE);
  32. qs.set('limit', '1000');
  33. const res = await fetch(`${API_URL}/search?${qs.toString()}`);
  34. if (!res.ok) throw new Error(`Search failed: ${res.status}`);
  35. return (await res.json()).posts ?? [];
  36. }
  37.  
  38. // -----------------------------------------------------------------------
  39. // "FIND ALL POSTS" CONTEXT MENU ITEM
  40. // -----------------------------------------------------------------------
  41. const menuObserver = new MutationObserver((mutations) => {
  42. for (const mutation of mutations) {
  43. for (const node of mutation.addedNodes) {
  44. if (!(node instanceof Element)) continue;
  45. const menu = node.matches('ul.popup-menu') ? node : node.querySelector('ul.popup-menu');
  46. if (!menu) continue;
  47. const fileNamesItem = menu.querySelector('[data-id="viewFileNames"]');
  48. if (!fileNamesItem) continue;
  49. if (menu.querySelector('[data-id="globalHashSearch"]')) continue;
  50. const li = document.createElement('li');
  51. li.setAttribute('data-id', 'globalHashSearch');
  52. li.textContent = 'Find All Posts';
  53. menu.insertBefore(li, fileNamesItem.nextSibling);
  54. }
  55. }
  56. });
  57. menuObserver.observe(document.body, { childList: true, subtree: true });
  58.  
  59. document.addEventListener('click', async (e) => {
  60. const target = e.target;
  61. if (!(target instanceof Element)) return;
  62. if (target.getAttribute('data-id') !== 'globalHashSearch') return;
  63. const article = target.closest('article');
  64. if (!article) return;
  65. const sha1 = getSha1FromDOM(article);
  66. if (!sha1) { alert('Could not find image hash for this post.'); return; }
  67. target.closest('ul.popup-menu')?.remove();
  68. try {
  69. const results = await searchPostsAdvanced({ sha1, source: SOURCE });
  70. if (!results.length) { alert('No posts found with this image hash.'); return; }
  71. showModal(results, `${results.length} post${results.length !== 1 ? 's' : ''} with this image`);
  72. } catch (e) {
  73. alert(`Search failed: ${e.message}`);
  74. }
  75. }, true);
  76.  
  77. // -----------------------------------------------------------------------
  78. // MODAL DISPLAY
  79. // -----------------------------------------------------------------------
  80. function showModal(posts, title) {
  81. const overlay = document.getElementById('modal-overlay');
  82. if (!overlay) return;
  83. overlay.querySelectorAll('.post-collection').forEach(el => el.remove());
  84.  
  85. const modal = document.createElement('div');
  86. modal.className = 'modal post-collection';
  87. modal.style.display = 'block';
  88.  
  89. const closer = document.createElement('a');
  90. closer.textContent = '[X]';
  91. closer.style.cssFloat = 'right';
  92. closer.style.cursor = 'pointer';
  93. closer.addEventListener('click', () => {
  94. modal.remove();
  95. openSearchPanel();
  96. }, { passive: true });
  97. modal.append(closer);
  98.  
  99. if (title) {
  100. const titleDiv = document.createElement('div');
  101. titleDiv.style.margin = '0 0 10px 0';
  102. const h3 = document.createElement('h3');
  103. h3.textContent = title;
  104. h3.style.cssText = 'margin:0; padding:5px 0; display:inline;';
  105. titleDiv.append(h3);
  106. modal.append(titleDiv);
  107. }
  108.  
  109. const PAGE_SIZE = 50;
  110. let currentPage = 0;
  111. const sorted = [...posts].sort((a, b) => (b.time || b.id) - (a.time || a.id));
  112.  
  113. const postsContainer = document.createElement('div');
  114. modal.append(postsContainer);
  115.  
  116. const pageInfo = document.createElement('div');
  117. pageInfo.style.cssText = 'display:flex;align-items:center;gap:8px;margin:10px 0 4px;font-size:12px;opacity:0.7;border-top:1px solid currentColor;padding-top:8px;';
  118.  
  119. const prevBtn = document.createElement('a');
  120. prevBtn.textContent = '← Prev';
  121. prevBtn.style.cssText = 'cursor:pointer;';
  122.  
  123. const nextBtn = document.createElement('a');
  124. nextBtn.textContent = 'Next →';
  125. nextBtn.style.cssText = 'cursor:pointer;';
  126.  
  127. const pageLabel = document.createElement('span');
  128. pageLabel.style.cssText = 'flex:1; text-align:center;';
  129.  
  130. const limitNote = document.createElement('span');
  131. limitNote.textContent = 'Only first 1000 results shown';
  132. limitNote.style.cssText = 'font-size:11px; opacity:0.5; display:block; text-align:center; margin-top:4px;';
  133.  
  134. pageInfo.append(prevBtn, pageLabel, nextBtn);
  135. modal.append(pageInfo, limitNote);
  136.  
  137. function renderPage(page) {
  138. currentPage = page;
  139. const totalPages = Math.ceil(sorted.length / PAGE_SIZE);
  140. const start = page * PAGE_SIZE;
  141. const slice = sorted.slice(start, start + PAGE_SIZE);
  142. postsContainer.innerHTML = '';
  143. for (const p of slice) postsContainer.append(buildPostStub(p));
  144. pageLabel.textContent = `Page ${page + 1} of ${totalPages} (${sorted.length} results)`;
  145. prevBtn.style.opacity = page === 0 ? '0.3' : '1';
  146. prevBtn.style.pointerEvents = page === 0 ? 'none' : 'auto';
  147. nextBtn.style.opacity = page >= totalPages - 1 ? '0.3' : '1';
  148. nextBtn.style.pointerEvents = page >= totalPages - 1 ? 'none' : 'auto';
  149. modal.scrollTop = 0;
  150. }
  151.  
  152. prevBtn.addEventListener('click', () => renderPage(currentPage - 1));
  153. nextBtn.addEventListener('click', () => renderPage(currentPage + 1));
  154. renderPage(0);
  155. overlay.append(modal);
  156. }
  157.  
  158. function buildPostStub(p) {
  159. const date = p.time ? new Date(p.time * 1000).toLocaleString('en-GB', {
  160. day: '2-digit', month: 'short', year: 'numeric', weekday: 'short',
  161. hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
  162. }) : '';
  163.  
  164. const postBase = ORIGIN_BASES[p.source] ?? ORIGIN_BASES[SOURCE];
  165. const isStaleThumb = p.thumb_url && (p.thumb_url.includes('supabase.co') || p.thumb_url.includes('/thumb/'));
  166. const thumbUrl = (!isStaleThumb && p.thumb_url)
  167. ? (p.thumb_url.startsWith('http') ? p.thumb_url : `${postBase}${p.thumb_url}`)
  168. : (p.sha1 ? `${postBase}/assets/images/thumb/${p.sha1}.webp` : null);
  169. const srcUrl = p.src_url
  170. ? (p.src_url.startsWith('http') ? p.src_url : `${postBase}${p.src_url}`)
  171. : (p.sha1 ? `${postBase}/assets/images/src/${p.sha1}` : null);
  172. const filename = p.filename ?? p.sha1 ?? '';
  173.  
  174. const isOp = p.id === p.thread;
  175. const isAdmin = p.role === 'admin';
  176. const isMod = p.role === 'mod';
  177.  
  178. const article = document.createElement('article');
  179. article.id = `archive-p${p.id}`;
  180. article.className = 'glass media';
  181. article.style.cssText = [
  182. 'border: 1px solid rgba(128,128,128,0.25)',
  183. 'padding: 8px 12px',
  184. 'margin-bottom: 6px',
  185. isAdmin ? 'border-color: rgba(200,50,50,0.6)' : '',
  186. isMod ? 'border-color: rgba(0,120,120,0.6)' : '',
  187. isOp ? 'border-left: 3px solid rgba(100,100,220,0.5)' : '',
  188. ].filter(Boolean).join(';');
  189.  
  190. const threadLink = p.thread && p.board
  191. ? `<a href="${postBase}/${escHtml(p.board)}/${p.thread}#p${p.id}" target="_blank" style="margin-left:6px;opacity:.6;font-size:11px;">→ /${escHtml(p.board)}/ #${p.thread}</a>`
  192. : '';
  193.  
  194. const badges = [
  195. isOp ? `<span style="background:rgba(80,80,200,0.3);color:#aaf;font-size:10px;padding:1px 5px;border-radius:2px;margin-left:5px;">OP</span>` : '',
  196. isAdmin ? `<span style="background:#a00;color:#fff;font-size:10px;padding:1px 5px;border-radius:2px;margin-left:5px;">## Admin</span>` : '',
  197. isMod ? `<span style="background:#055;color:#fff;font-size:10px;padding:1px 5px;border-radius:2px;margin-left:5px;">## Mod</span>` : '',
  198. ].join('');
  199.  
  200. const banHtml = p.ban_message
  201. ? `<div style="color:#e44;font-size:11px;font-weight:bold;margin-top:5px;">${escHtml(p.ban_message)}</div>`
  202. : '';
  203.  
  204. article.innerHTML = `
  205. <header class="spaced" style="margin-bottom:5px;font-size:12px;">
  206. <b class="name spaced"><span>Anonymous</span>${badges}</b>
  207. <time style="opacity:.6;">${escHtml(date)}</time>
  208. <nav><a class="quote" style="font-weight:bold;">${p.id}</a>${threadLink}</nav>
  209. </header>
  210. <figcaption class="spaced" style="font-size:11px;opacity:.6;margin-bottom:4px;">
  211. ${p.meta ? `<span class="media-metadata">${escHtml(p.meta)}</span>` : ''}
  212. ${srcUrl ? `<a class="filename-link" href="${escHtml(srcUrl)}" download="${escHtml(filename)}">${escHtml(filename)}</a>` : ''}
  213. ${srcUrl ? `<a class="download-link symbol" href="${escHtml(srcUrl)}" target="_blank" style="margin-left:4px;">⬇</a>` : ''}
  214. </figcaption>
  215. <div class="post-container">
  216. ${thumbUrl ? `<figure style="margin:0 8px 0 0;"><a target="_blank" href="${escHtml(srcUrl ?? thumbUrl)}">
  217. <img loading="lazy" draggable="false" src="${escHtml(thumbUrl)}" style="max-height:80px;max-width:80px;object-fit:cover;display:block;"></a></figure>` : ''}
  218. <blockquote style="margin:0;font-size:13px;white-space:pre-wrap;word-break:break-word;">${escHtml(p.body ?? '')}</blockquote>
  219. </div>
  220. ${banHtml}`;
  221. return article;
  222. }
  223.  
  224. // -----------------------------------------------------------------------
  225. // SEARCH PANEL
  226. // -----------------------------------------------------------------------
  227. function openSearchPanel() {
  228. document.getElementById('ec-search-panel')?.remove();
  229. const overlay = document.getElementById('modal-overlay');
  230. if (!overlay) return;
  231.  
  232. const panel = document.createElement('div');
  233. panel.id = 'ec-search-panel';
  234. panel.className = 'modal post-collection';
  235. panel.style.cssText = 'display:block; padding:14px; min-width:300px; max-width:360px;';
  236.  
  237. panel.innerHTML = `
  238. <style>
  239. #ec-search-panel .ec-row { display:grid; grid-template-columns:90px 1fr; align-items:center; gap:8px; margin-bottom:8px; }
  240. #ec-search-panel .ec-row label { font-size:12px; opacity:0.7; text-align:right; }
  241. #ec-search-panel input, #ec-search-panel select { width:100%; box-sizing:border-box; padding:3px 6px; font-size:13px; background:transparent; color:inherit; border:1px solid currentColor; border-radius:3px; outline:none; }
  242. #ec-search-panel .ec-btns { display:flex; gap:8px; justify-content:flex-end; margin-top:10px; padding-top:8px; border-top:1px solid currentColor; opacity:0.85; }
  243. #ec-search-panel .ec-btns a { cursor:pointer; font-size:13px; }
  244. </style>
  245. <a id="ec-panel-close" style="float:right; cursor:pointer;">[X]</a>
  246. <div style="margin-bottom:10px; font-size:13px; font-weight:bold;"><a href="https://meguca-archive.onrender.com" target="_blank" style="color:inherit;">Archive Search ↗</a></div>
  247. <div class="ec-row"><label>Text</label><input id="bm-arc-text" type="text" placeholder='post body (add * for all results)' /></div>
  248. <div class="ec-row"><label>Image hash</label><input id="ec-q-sha1" type="text" placeholder="sha1 or partial" /></div>
  249. <div class="ec-row"><label>Filename</label><input id="ec-q-filename" type="text" placeholder="e.g. video.mp4" /></div>
  250. <div class="ec-row"><label>Post no.</label><input id="ec-q-post" type="text" placeholder="e.g. 15820" /></div>
  251. <div class="ec-row"><label>Thread no.</label><input id="ec-q-thread" type="text" placeholder="e.g. 6621" /></div>
  252. <div class="ec-row"><label>Date start</label><input id="ec-q-date-start" type="date" /></div>
  253. <div class="ec-row"><label>Date end</label><input id="ec-q-date-end" type="date" /></div>
  254. ${SOURCE === 'easychan' ? `
  255. <div class="ec-row"><label>Board</label>
  256. <select id="ec-q-board">
  257. <option value="">All boards</option>
  258. <option value="kr">/kr/</option>
  259. </select>
  260. </div>` : ''}
  261. <div class="ec-row"><label>Site</label>
  262. <div>
  263. <select id="ec-q-source">
  264. <option value="${SOURCE}" selected>${SOURCE}</option>
  265. ${SOURCE === 'mokachan' ? '<option value="easychan">easychan</option>' : '<option value="mokachan">mokachan</option>'}
  266. </select>
  267. <div style="font-size:10px;opacity:0.45;margin-top:3px;">⚠ searching another site may return broken image links</div>
  268. </div>
  269. </div>
  270. <div class="ec-btns">
  271. <a id="ec-clear-btn">[Clear]</a>
  272. <a id="ec-go-btn">[Search]</a>
  273. </div>
  274. `;
  275.  
  276. overlay.append(panel);
  277. document.getElementById('ec-panel-close').addEventListener('click', () => panel.remove());
  278. document.getElementById('ec-q-text')?.focus();
  279.  
  280. document.getElementById('ec-clear-btn').addEventListener('click', () => {
  281. ['ec-q-text','ec-q-sha1','ec-q-filename','ec-q-post','ec-q-thread','ec-q-date-start','ec-q-date-end'].forEach(id => {
  282. const el = document.getElementById(id); if (el) el.value = '';
  283. });
  284. const board = document.getElementById('ec-q-board');
  285. if (board) board.value = '';
  286. document.getElementById('ec-q-source').value = SOURCE;
  287. });
  288.  
  289. const doSearch = async () => {
  290. const params = {
  291. text: document.getElementById('ec-q-text')?.value.trim(),
  292. sha1: document.getElementById('ec-q-sha1')?.value.trim(),
  293. filename: document.getElementById('ec-q-filename')?.value.trim(),
  294. post: document.getElementById('ec-q-post')?.value.trim(),
  295. thread: document.getElementById('ec-q-thread')?.value.trim(),
  296. dateStart: document.getElementById('ec-q-date-start')?.value,
  297. dateEnd: document.getElementById('ec-q-date-end')?.value,
  298. board: document.getElementById('ec-q-board')?.value ?? '',
  299. source: document.getElementById('ec-q-source')?.value ?? SOURCE,
  300. };
  301. if (!Object.values(params).some(v => v)) { alert('Fill in at least one search field.'); return; }
  302. panel.remove();
  303. try {
  304. const results = await searchPostsAdvanced(params);
  305. if (!results.length) { alert('No results found.'); return; }
  306. showModal(results, `${results.length} result${results.length !== 1 ? 's' : ''}`);
  307. } catch (e) { alert(`Search failed: ${e.message}`); }
  308. };
  309.  
  310. panel.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
  311. document.getElementById('ec-go-btn').addEventListener('click', doSearch);
  312. }
  313.  
  314. // -----------------------------------------------------------------------
  315. // INJECT SEARCH BUTTON
  316. // -----------------------------------------------------------------------
  317. function injectSearchUI() {
  318. if (document.getElementById('ec-search-bar')) return;
  319.  
  320. const bar = document.createElement('span');
  321. bar.id = 'ec-search-bar';
  322. bar.style.cssText = 'display:inline-flex; align-items:center;';
  323.  
  324. const searchBtn = document.createElement('a');
  325. searchBtn.className = 'banner-float svg-link noscript-hide';
  326. searchBtn.title = 'Search Archive';
  327. searchBtn.style.cursor = 'pointer';
  328. searchBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
  329. <path d="M3.5 0C1.57 0 0 1.57 0 3.5c0 1.93 1.57 3.5 3.5 3.5 0.77 0 1.48-0.25 2.06-0.66L7.29 8 8 7.29 6.34 5.56C6.75 4.98 7 4.27 7 3.5 7 1.57 5.43 0 3.5 0zm0 1C4.88 1 6 2.12 6 3.5S4.88 6 3.5 6 1 4.88 1 3.5 2.12 1 3.5 1z"/>
  330. </svg>`;
  331.  
  332. searchBtn.addEventListener('click', (e) => {
  333. e.stopPropagation();
  334. if (document.getElementById('ec-search-panel')) {
  335. document.getElementById('ec-search-panel').remove();
  336. } else {
  337. openSearchPanel();
  338. }
  339. });
  340.  
  341. bar.append(searchBtn);
  342.  
  343. const inject = () => {
  344. const nekotv = document.getElementById('banner-nekotv');
  345. if (nekotv && !document.getElementById('ec-search-bar')) { nekotv.before(bar); return true; }
  346. const nav = document.getElementById('board-navigation');
  347. if (nav && !document.getElementById('ec-search-bar')) { nav.append(bar); return true; }
  348. return false;
  349. };
  350.  
  351. const obs = new MutationObserver(() => inject());
  352. obs.observe(document.body, { childList: true, subtree: true });
  353. inject();
  354. }
  355.  
  356. // -----------------------------------------------------------------------
  357. // HELPERS
  358. // -----------------------------------------------------------------------
  359. function getSha1FromDOM(article) {
  360. const img = article.querySelector('figure img');
  361. if (img) { const m = img.getAttribute('src')?.match(SHA1_RE); if (m) return m[1]; }
  362. const link = article.querySelector('a[href*="/assets/images/"]');
  363. if (link) { const m = link.getAttribute('href')?.match(SHA1_RE); if (m) return m[1]; }
  364. const dlLink = article.querySelector('a.filename-link');
  365. if (dlLink) { const m = dlLink.getAttribute('onclick')?.match(/sha1=([a-f0-9]{40})/); if (m) return m[1]; }
  366. return null;
  367. }
  368.  
  369. function escHtml(str) {
  370. return String(str ?? '')
  371. .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  372. }
  373.  
  374. // -----------------------------------------------------------------------
  375. // INIT
  376. // -----------------------------------------------------------------------
  377. injectSearchUI();
  378.  
  379. })();
Advertisement
Add Comment
Please, Sign In to add comment