// ==UserScript== // @name 5ch.net donguri Hit Response Getter // @namespace https://greasyfork.org/users/1310758 // @description Fetches and filters hit responses from donguri 5ch boards // @match https://donguri.5ch.net/cannonlogs // @match https://*.5ch.net/test/read.cgi/*/* // @match https://*.bbspink.com/test/read.cgi/*/* // @connect 5ch.net // @license MIT License // @author pachimonta // @grant GM_xmlhttpRequest // @grant GM_addStyle // @version 2024-06-05_001 // @downloadURL none // ==/UserScript== (function() { 'use strict'; const manual = ` `; const donguriLogCSS = ` body { margin: 0; padding: 12px; display: block; } thead, tbody { white-space: nowrap; } th { user-select: none; } th:hover { background: #ccc; } th:active { background: #ff0; } th, td { font-size: 15px; } td a:hover { opacity: 0.5; } td a:visited { color: #808; } .hidden { display: none; } `; const readCGICSS = ` .dongurihit:target { background: #daa; } `; // Helper functions const $ = (selector, context = document) => context.querySelector(selector); const $$ = (selector, context = document) => [...context.querySelectorAll(selector)]; const readCgiRegex = /\/test\/read\.cgi\/\w+\/\d+.*$/; // Scroll and highlight the relevant post in read.cgi if (readCgiRegex.test(location.pathname)) { GM_addStyle(readCGICSS); const waitForTabToBecomeActive = () => { return new Promise((resolve) => { if (document.visibilityState === 'visible') { resolve(); } else { const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { document.removeEventListener('visibilitychange', handleVisibilityChange); resolve(); } }; document.addEventListener('visibilitychange', handleVisibilityChange); } }); }; const scrollActive = () => { const hashIsNumber = location.hash.match(/^#(\d+)$/)?.[1] || null; const match = location.hash.match(/(?:&|#)date=([^&=]{10})([^&=]+)/); const [dateymd, datehms] = match ? [match[1], match[2]] : [null, null]; if (hashIsNumber || dateymd) { $$('.date').some(dateElement => { const post = dateElement.closest('.post'); if (!post) { return; } if (post.id === hashIsNumber || dateymd && dateElement.textContent.includes(dateymd) && dateElement.textContent.includes(datehms)) { post.classList.add('dongurihit'); if (post.id && location.hash !== `#${post.id}`) { location.hash = `#${post.id}`; history.pushState({ scrollY: window.scrollY }, ''); history.go(-1); return; } const observer = new IntersectionObserver(async entries => { await waitForTabToBecomeActive(); entries.forEach(entry => { if (entry.isIntersecting) { setTimeout(() => { post.classList.remove('dongurihit'); }, 1500); } }); }); observer.observe(post); return; } }); } }; const scrollToElementWhenActive = async () => { await waitForTabToBecomeActive(); scrollActive(); window.addEventListener('hashchange', scrollActive); }; scrollToElementWhenActive(); return; } GM_addStyle(donguriLogCSS); // Storage for bbs list and subject list const bbsList = {}; const subjectList = {}; const completedURL = {}; const column = ['order', 'term', 'date', 'bbs', 'bbsname', 'key', 'id', 'hunter', 'target', 'subject']; const table = $('table'); const thead = $('thead', table); const tbody = $('tbody', table); const addWeekdayToDatetime = (datetimeStr) => { const firstColonIndex = datetimeStr.indexOf(':'); const splitIndex = firstColonIndex - 2; const datePart = datetimeStr.slice(0, splitIndex); const timePart = datetimeStr.slice(splitIndex); const [year, month, day] = datePart.split('/').map(Number); const date = new Date(year, month - 1, day); const weekdays = ['日', '月', '火', '水', '木', '金', '土']; const weekday = weekdays[date.getDay()]; return `${datePart}(${weekday}) ${timePart}`; }; const appendCell = (tr, txt = null, elementName = 'td') => { if (tr.parentElement.tagName === 'THEAD') { elementName = 'th'; } const e = tr.appendChild(document.createElement(elementName)); if (txt !== null) { e.textContent = txt; } return e; }; if ($('tr th:nth-of-type(1)', thead)) { // 順,期,date(投稿時刻),bbs,bbs名,key,ハンターID,ハンター名,ターゲット,subject // order,term,date,bbs,bbsname,key,id,hunter,target,subject const tr = $('tr:nth-of-type(1)', thead); $('th:nth-of-type(1)', tr).textContent = '順'; $('th:nth-of-type(1)', tr).removeAttribute('style'); $('th:nth-of-type(2)', tr).textContent = '期'; $('th:nth-of-type(2)', tr).removeAttribute('style'); appendCell(tr, 'date(投稿時刻)'); appendCell(tr, 'bbs'); appendCell(tr, 'bbs名'); appendCell(tr, 'key'); appendCell(tr, 'ハンターID'); appendCell(tr, 'ハンター名'); appendCell(tr, 'ターゲット'); appendCell(tr, 'subject'); table.insertAdjacentHTML('beforebegin', manual); const headers = Array.from($$('th', thead)); const rows = Array.from($$('tr', tbody)); // 各列ヘッダーにダブルクリックイベントを設定 headers.forEach((header, index) => { let sortOrder = 1; // 1: 自然順, -1: 逆順 header.addEventListener('dblclick', () => { // クリックされた列のインデックスに基づいてソート rows.sort((rowA, rowB) => { const cellA = rowA.cells[index].textContent; const cellB = rowB.cells[index].textContent; // テキストで自然順ソート return cellA.localeCompare(cellB, 'ja', { numeric: true }) * sortOrder; }); // ソート順を反転 sortOrder *= -1; // ソート済みの行をtbodyに再配置 rows.forEach(row => tbody.appendChild(row)); }); }); } const rloRegex = /[\x00-\x1F\x7F\u200E\u200F\u202A\u202B\u202C\u202D\u202E]/g; // Regular expression to detect and replace unwanted characters const replaceTextRecursively = (element) => { element.childNodes.forEach(node => { if (node.nodeType === Node.TEXT_NODE) { node.textContent = node.textContent.replace(rloRegex, match => `[U+${match.codePointAt(0).toString(16).toUpperCase()}]`); } else if (node.nodeType === Node.ELEMENT_NODE) { replaceTextRecursively(node); } }); }; const userRegex = /^(.*)?さん\[([a-f0-9]{8})\]は(.*(?:\[[a-f0-9]{4}\*\*\*\*\])?)?さんを撃った$/; Array.from($$('tr', tbody)).forEach((tr, i) => { replaceTextRecursively(tr); const log = $('td:nth-of-type(2)', tr).textContent.trim(); const verticalPos = log.lastIndexOf('|'); const [bbs, key, date] = log.slice(verticalPos + 2).split(' ', 3); tr.dataset.order = i + 1; tr.dataset.term = $('td:nth-of-type(1)', tr).textContent.trim().slice(1, -1); tr.dataset.date = date; tr.dataset.bbs = bbs; tr.dataset.key = key; tr.dataset.log = log; const match = log.slice(0, verticalPos - 1).match(userRegex); tr.dataset.id = match[2]; tr.dataset.hunter = match[1]; tr.dataset.target = match[3]; $('td:nth-of-type(2)', tr).textContent = $('td:nth-of-type(1)', tr).textContent; $('td:nth-of-type(1)', tr).textContent = tr.dataset.order; appendCell(tr, addWeekdayToDatetime(date)); appendCell(tr, bbs); appendCell(tr); appendCell(tr, key); appendCell(tr, tr.dataset.id); appendCell(tr, tr.dataset.hunter); appendCell(tr, tr.dataset.target); appendCell(tr); }); // Sanitize user input to avoid XSS and other injections const sanitizeRegex = /[^a-zA-Z0-9_:/.\-]/g; const sanitize = (value) => value.replace(sanitizeRegex, ''); const filterSplitRegex = /\s*,\s*/; const noSanitizeKeyRegex = /^(?:log|bbsname|hunter|target|subject)$/; const equalValueKeyRegex = /^(?:term|bbs)$/; const includesValueKeyRegex = /^(?:log|bbsname|subject|date)$/; // Update elements visibility based on filtering criteria const filterRows = (input) => { let count = 0; let total = 0; try { const value = input.value.trim(); if (!value) { Array.from($$('tr', tbody)).forEach((row, i) => { count++; total = i + 1; row.classList.remove('hidden'); return; }); return; } const criteria = value.split(filterSplitRegex).map(item => item.split('=')).reduce((acc, [key, val]) => { if (key && val) { acc[key.trim()] = key.match(noSanitizeKeyRegex) ? val.trim() : sanitize(val.trim()); } return acc; }, {}); Array.from($$('tr', tbody)).forEach((row, i) => { total = i + 1; const isVisible = Object.entries(criteria).every(([key, val]) => { if (key === 'ita') { key = 'bbs'; } if (key === 'dat') { key = 'key'; } if (row.hasAttribute(`data-${key}`)) { if (key.match(equalValueKeyRegex)) { return row.getAttribute(`data-${key}`) === val; } else if (key.match(includesValueKeyRegex)) { return row.getAttribute(`data-${key}`).includes(val); } else { return row.getAttribute(`data-${key}`).indexOf(val) === 0; } } else { return false; } }); if (isVisible) { count++; } if (isVisible) { row.classList.remove('hidden'); } else { row.classList.add('hidden'); } }); } catch (e) {} finally { $('#myfilterResult').textContent = `${count} 件マッチしました / ${total} 件中`; } }; // Initialize the filter input and its functionalities const createFilterInput = () => { const search = document.createElement('search'); const input = document.createElement('input'); input.type = 'text'; input.id = 'myfilter'; input.placeholder = 'Filter (e.g., bbs=av, key=1711038453, date=06/01(土) 01:55, id=ac351e30, log=abesoriさん[97a65812])'; input.style = 'width: 100%; padding: 5px; margin-bottom: 10px;'; const table = $('table'); if (table) { input.addEventListener('input', () => { location.hash = `#${input.value}`; return; }); search.append(input); search.insertAdjacentHTML('afterbegin', '

'); table.parentNode.insertBefore(search, table); if (location.hash) { input.value = decodeURIComponent(location.hash.substring(1)); filterRows(input); } window.addEventListener('hashchange', () => { input.value = decodeURIComponent(location.hash.substring(1)); filterRows(input); }); } }; // Async function to wait until the subject list is loaded const waitForSubject = async (bbs, key) => { let retryCount = 0; while (retryCount < 30 && !(bbs in subjectList && `${key}.dat` in subjectList[bbs])) { await new Promise(resolve => setTimeout(resolve, 100)); retryCount++; } }; // GM_xmlhttpRequest wrapper to handle HTTP Get requests const lastRow = $('tr:last-child', tbody); const getDat = (url, func, mime = 'text/plain; charset=shift_jis', tr = null) => { if (typeof tr === 'object' && url in completedURL) { const date = tr.dataset.date; const bbs = tr.dataset.bbs; const key = tr.dataset.key; (async () => { await waitForSubject(bbs, key); const origin = bbsList[bbs] || "https://origin"; const bbsName = bbsList[`${bbs}_txt`] || "???"; const subject = subjectList[bbs][`${key}.dat`] || "???"; tr.dataset.origin = origin; tr.dataset.bbsname = bbsName; $('td:nth-of-type(5)', tr).textContent = bbsName; tr.dataset.subject = subject; const anchor = document.createElement('a'); anchor.href = `${origin}/test/read.cgi/${bbs}/${key}/?v=pc#date=${date}`; anchor.target = '_blank'; anchor.textContent = subject; $('td:nth-of-type(10)', tr).insertAdjacentElement('beforeend', anchor); })(); return; } GM_xmlhttpRequest({ method: 'GET', url: url, timeout: 86400 * 1000, overrideMimeType: mime, onload: function(response) { if (tr !== null) { const date = tr.dataset.date; const bbs = tr.dataset.bbs; const key = tr.dataset.key; func(response, tr, bbs, key, date); if (Object.is(tr, lastRow) && $('#myfilter').value.indexOf('=') > -1) { filterRows($('#myfilter')); } } else { func(response); } }, onerror: function(error) { console.error('An error occurred during the request:', error); } }); }; const parser = new DOMParser(); const charReferRegex = /&#?[a-zA-Z0-9]+;?/; const crlfRegex = /[\r\n]+/; const logSplitRegex = /\s*<>\s*/; // Process subject line to update subject list and modify the row content const subjectFunc = (response, tr) => { const date = tr.dataset.date; const bbs = tr.dataset.bbs; const key = tr.dataset.key; completedURL[response.finalUrl] = true; if (response.status === 200) { const lastmodify = response.responseText; lastmodify.split(crlfRegex).forEach(line => { let [key, subject] = line.split(logSplitRegex, 2); if (charReferRegex.test(subject)) { subject = parser.parseFromString(subject, 'text/html').documentElement.innerText; } subjectList[bbs][key] = subject; }); const origin = bbsList[bbs] || "https://origin"; const bbsName = bbsList[`${bbs}_txt`] || "???"; const subject = subjectList[bbs][`${key}.dat`] || "???"; tr.dataset.origin = origin; tr.dataset.bbsname = bbsName; $('td:nth-of-type(5)', tr).textContent = bbsName; tr.dataset.subject = subject; const anchor = document.createElement('a'); anchor.href = `${origin}/test/read.cgi/${bbs}/${key}/?v=pc#date=${date}`; anchor.target = '_blank'; anchor.textContent = subject; $('td:nth-of-type(10)', tr).insertAdjacentElement('beforeend', anchor); } else { console.error('Failed to load data. Status code:', response.status); } }; // Function to handle each table row for subject processing const nextFunc = async () => { Array.from($$('tr', tbody)).forEach(tr => { const bbs = tr.dataset.bbs; if (!Object.hasOwn(subjectList, bbs)) { subjectList[bbs] = {}; } getDat(`${bbsList[bbs]}/${bbs}/lastmodify.txt`, subjectFunc, 'text/plain; charset=shift_jis', tr); }); createFilterInput(); document.querySelector('table').addEventListener('dblclick', function(event) { event.preventDefault(); if (!$('#myfilter')) { return; } const target = event.target; if (target.tagName === 'TD') { const index = Array.prototype.indexOf.call(target.parentNode.children, target); const txt = `${column[index]}=${target.textContent}`; location.hash += location.hash.indexOf('=') > -1 ? `,${txt}` : txt; } }); }; const bbsLinkRegex = /\.(?:5ch\.net|bbspink\.com)\/([a-zA-Z0-9_-]+)\/$/; // Function to process the bbsmenu response const bbsmenuFunc = (response) => { if (response.status === 200) { const html = document.createElement('html'); html.innerHTML = response.responseText; $$('a[href*=".5ch.net/"],a[href*=".bbspink.com/"]', html).forEach(bbsLink => { const match = bbsLink.href.match(bbsLinkRegex); if (match) { bbsList[match[1]] = new URL(bbsLink.href).origin; bbsList[`${match[1]}_txt`] = bbsLink.textContent.trim(); } }); if (Object.keys(bbsList).length === 0) { console.error('No boards found.'); return; } nextFunc(); } else { console.error('Failed to fetch bbsmenu. Status code:', response.status); } }; // Initial data fetch from bbsmenu getDat('https://menu.5ch.net/bbsmenu.html', bbsmenuFunc, 'text/html; charset=shift_jis'); })();