// ==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 *://donguri.5ch.net/cannonlogs // @match *://*.5ch.net/test/read.cgi/*/* // @connect 5ch.net // @license MIT License // @author pachimonta // @grant GM_xmlhttpRequest // @grant GM_addStyle // @version 2024-06-07_003 // @downloadURL none // ==/UserScript== (function() { 'use strict'; const manual = ` `; const DONGURI_LOG_CSS = ` body { margin: 0; padding: 12px; display: block; } table { white-space: nowrap; } th:hover, td:hover { background: #ccc; } th:active, td:active { background: #ff9; } th, td { font-size: 15px; } td a:visited { color: #808; } td a:hover { color: #f66; } `; const READ_CGI_CSS = ` .dongurihit:target { background: #daa; } `; // Helper functions const $ = (selector, context = document) => context.querySelector(selector); const $$ = (selector, context = document) => [...context.querySelectorAll(selector)]; // Scroll and highlight the relevant post in read.cgi const readCgiJump = () => { GM_addStyle(READ_CGI_CSS); 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 [dateymd, datehms] = (location.hash.match(/(?:&|#)date=([^&=]{10})([^&=]+)/) || [null, null, null]).slice(1); if (!hashIsNumber && !dateymd) { return; } $$('.date').some(dateElement => { const post = dateElement.closest('.post'); if (!post) { return false; } const isMatchingPost = post.id === hashIsNumber || (dateymd && dateElement.textContent.includes(dateymd) && dateElement.textContent.includes(datehms)); if (!isMatchingPost) { return false; } post.classList.add('dongurihit'); if (post.id && location.hash !== `#${post.id}`) { location.hash = `#${post.id}`; history.pushState({ scrollY: window.scrollY }, ''); history.go(-1); return true; } const observer = new IntersectionObserver(async entries => { await waitForTabToBecomeActive(); entries.forEach(entry => { if (entry.isIntersecting) { setTimeout(() => post.classList.remove('dongurihit'), 1500); } }); }); observer.observe(post); return true; }); }; if (!window.donguriInitialized) { window.addEventListener('hashchange', scrollActive); window.donguriInitialized = true; } const scrollToElementWhenActive = async () => { await waitForTabToBecomeActive(); scrollActive(); }; scrollToElementWhenActive(); return; }; // Filter Acorn Cannon Logs const donguriFilter = () => { GM_addStyle(DONGURI_LOG_CSS); // Create a checkbox to toggle the display between the original table and the UserScript-generated table const toggleDisplayCheckbox = Object.assign(document.createElement('input'), { type: 'checkbox', checked: 'checked', id: 'toggleDisplay' }); const toggleDisplayLabel = Object.assign(document.createElement('label'), { htmlFor: 'toggleDisplay', textContent: 'Toggle Table', id: 'toggleDisplay' }); const toggleDisplayContainer = Object.assign(document.createElement('div'), { style: 'position:fixed;top:10px;right:30px;opacity:0.7' }); toggleDisplayContainer.append(toggleDisplayCheckbox, toggleDisplayLabel); $('body').append(toggleDisplayContainer); // Storage for bbs list and post titles list const bbsOriginList = {}; const bbsNameList = {}; // post titles const subjectList = {}; // Index list of tbody tr selectors for each BBS const donguriLogBbsRows = {}; // Number of attempted requests to lastmodify.txt const attemptedXhrBBS = new Set(); const completedXhrBBS = new Set(); const columnSelector = {}; const columns = ['order', 'term', 'date', 'bbs', 'bbsname', 'key', 'id', 'hunter', 'target', 'subject']; for (let i=0; i { if (event.target.checked) { // Change display to UserScript $('table.originalLog').setAttribute('hidden', 'hidden'); table.removeAttribute('hidden'); } else { // Change to original display table.setAttribute('hidden', 'hidden'); if (!$('table.originalLog')) { table.insertAdjacentElement('afterend', originalTable); } $('table.originalLog').removeAttribute('hidden'); } }); 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) => { const e = tr.appendChild(document.createElement(tr.parentElement.tagName === 'THEAD' ? 'th' : 'td')); 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); ['順', '期'].forEach((text, i) => { const th = $(`th:nth-of-type(${i + 1})`, tr); th.textContent = text; th.removeAttribute('style'); }); ['date(投稿時刻)', 'bbs', 'bbs名', 'key', 'ハンターID', 'ハンター名', 'ターゲット', 'subject'].forEach(txt => appendCell(tr, txt)); table.insertAdjacentHTML('beforebegin', manual); const headers = $$('th', thead); const rows = $$('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; const sanitizeText = (content) => { return content.replace(rloRegex, match => `[U+${match.codePointAt(0).toString(16).toUpperCase()}]`); }; // Regular expression to detect and replace unwanted characters const replaceTextRecursively = (element) => { element.childNodes.forEach(node => { if (node.nodeType === Node.TEXT_NODE) { node.textContent = sanitizeText(node.textContent); } else if (node.nodeType === Node.ELEMENT_NODE) { replaceTextRecursively(node); } }); }; const initialRows = $$('tr', tbody); // Number of 'tbody tr' selectors const rowCount = initialRows.length; const userLogRegex = /^(.*)?さん\[([a-f0-9]{8})\]は(.*(?:\[[a-f0-9]{4}\*\*\*\*\])?)?さんを(?:[撃打]っ|外し)た$/u; // Expand each cell in the tbody initialRows.forEach((row, i) => { replaceTextRecursively(row); const log = $(originalLogSelector, row).textContent.trim(); const verticalPos = log.lastIndexOf('|'); const [bbs, key, date] = log.slice(verticalPos + 2).split(' ', 3); if (Object.hasOwn(donguriLogBbsRows, bbs) === false) { donguriLogBbsRows[bbs] = [{index:i,key:key}]; } else { donguriLogBbsRows[bbs].push({index:i,key:key}); } row.dataset.order = i + 1; row.dataset.term = $(originalTermSelector, row).textContent.trim().slice(1, -1); Object.assign(row.dataset, { date, bbs, key, log }); [row.dataset.hunter, row.dataset.id, row.dataset.target] = log.slice(0, verticalPos - 1).match(userLogRegex).slice(1, 4); $(columnSelector.term, row).textContent = $(originalTermSelector, row).textContent; $(columnSelector.order, row).textContent = row.dataset.order; appendCell(row, addWeekdayToDatetime(date)); appendCell(row, bbs); appendCell(row); appendCell(row, key); appendCell(row, row.dataset.id); appendCell(row, row.dataset.hunter); appendCell(row, row.dataset.target); appendCell(row); }); // Sanitize user input const sanitize = (value) => value.replace(/[^a-zA-Z0-9_:/.\-]/g, ''); 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 = (val) => { let count = 0; const rows = $$('tr', tbody); const total = rows.length; const value = val.trim(); if (!value) { rows.forEach(row => row.removeAttribute('hidden')); $('#myfilterResult').textContent = `${total} 件 / ${total} 件中`; 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; }, {}); rows.forEach(row => { const isVisible = Object.entries(criteria).every(([key, val]) => { if (key === 'ita') { key = 'bbs'; } if (key === 'dat') { key = 'key'; } if (!row.hasAttribute(`data-${key}`)) { return false; } 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; } }); if (isVisible) { count++; row.removeAttribute('hidden'); } else { row.setAttribute('hidden', 'hidden'); } }); $('#myfilterResult').textContent = `${count} 件 / ${total} 件中`; }; // Insert the data of each BBS thread list const insertCells = (bbs) => { for (let obj of donguriLogBbsRows[bbs]) { ++completedRows; const { index, key } = obj; const row = initialRows[index]; if (Object.hasOwn(row.dataset, 'subject') === true && row.dataset.subject.length) { continue; } const { date, origin } = row.dataset; const subject = subjectList[bbs][key] || "???"; Object.assign(row.dataset, { subject }); const anchor = Object.assign(document.createElement('a'), { href: `${origin}/test/read.cgi/${bbs}/${key}/?v=pc#date=${date}`, target: '_blank', textContent: subject }); $(columnSelector.subject, row).insertAdjacentElement('beforeend', anchor); } // After inserting all cells if (completedRows === rowCount) { filterRows($('#myfilter').value); } }; const insertCellsNotCount = (bbs) => { for (let obj of donguriLogBbsRows[bbs]) { const { index, key } = obj; if (Object.hasOwn(subjectList[bbs], key) === false) { continue; } const row = initialRows[index]; const { date, origin } = row.dataset; const subject = subjectList[bbs][key]; Object.assign(row.dataset, { subject }); const anchor = Object.assign(document.createElement('a'), { href: `${origin}/test/read.cgi/${bbs}/${key}/?v=pc#date=${date}`, target: '_blank', textContent: subject }); $(columnSelector.subject, row).insertAdjacentElement('beforeend', anchor); } }; const insertBbsnameCells = (bbs) => { for (let obj of donguriLogBbsRows[bbs]) { const { index } = obj; const row = initialRows[index]; const origin = bbsOriginList[bbs] || "https://origin"; const bbsName = bbsNameList[bbs] || "???"; Object.assign(row.dataset, { origin, bbsname: bbsName }); $(columnSelector.bbsname, row).textContent = bbsName; } }; // Initialize the filter input and its functionalities const createFilterInput = () => { if (!table) { return false; } const search = document.createElement('search'); const input = Object.assign(document.createElement('input'), { type: 'text', id: 'myfilter', placeholder: 'Filter (e.g., bbs=av, key=1711038453, date=06/01(土) 01:55, id=ac351e30, log=abesoriさん[97a65812])', style: 'width: 100%; padding: 5px; margin-bottom: 10px;' }); 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)); } window.addEventListener('hashchange', () => { input.value = decodeURIComponent(location.hash.substring(1)); filterRows(input.value); }); }; // GM_xmlhttpRequest wrapper to handle HTTP Get requests const xhrGetDat = (url, loadFunc, mime = 'text/plain; charset=shift_jis') => { console.time(url); GM_xmlhttpRequest({ method: 'GET', url: url, timeout: 3600 * 1000, overrideMimeType: mime, onload: response => loadFunc(response), onerror: error => console.error('An error occurred during the request:', error) }); }; const arrayContainsArray = (superset, subset) => { return subset.every(value => superset.includes(value)); }; const arrayDifference = (array1, array2) => { return array1.filter(value => !array2.includes(value)); }; const parser = new DOMParser(); const htmlEntityRegex = /&#?[a-zA-Z0-9]+;?/; const crlfRegex = /[\r\n]+/; const logSplitRegex = /\s*<>\s*/; // Process post titles line to update subjectList and modify the table-cells const addBbsPastInfo = (response) => { console.timeEnd(response.finalUrl); if (response.status !== 200) { console.error('Failed to load data. Status code:', response.status); return false; } const url = response.finalUrl; const pathname = new URL(url).pathname; const slashIndex = pathname.indexOf('/'); const secondSlashIndex = pathname.indexOf('/', slashIndex+1); const bbs = pathname.substring(slashIndex+1,secondSlashIndex); completedXhrBBS.add(bbs); const html = parser.parseFromString(response.responseText, 'text/html').documentElement; $$('[class="main_odd"],[class="main_even"]', html).forEach(p => { let [key, subject] = [ $('.filename', p).textContent, $('.title', p).textContent ]; if (key.includes('.')) { key = key.substring(0, key.lastIndexOf('.')); } if (Object.hasOwn(subjectList[bbs], key) === true) { return; } subjectList[bbs][key] = subject; }); if (arrayContainsArray(Object.keys(subjectList[bbs]), [...new Set(donguriLogBbsRows[bbs].map(item => item.key))]) === false) { console.info("Subject not found. bbs: %s, key: %s", bbs, arrayDifference([...new Set(donguriLogBbsRows[bbs].map(item => item.key))], Object.keys(subjectList[bbs]))); } insertCells(bbs); }; // Process post titles line to update subjectList and modify the table-cells const addBbsInfo = (response) => { console.timeEnd(response.finalUrl); if (response.status !== 200) { console.error('Failed to load data. Status code:', response.status); return false; } const url = response.finalUrl; const lastSlashIndex = url.lastIndexOf('/'); const secondLastSlashIndex = url.lastIndexOf('/', lastSlashIndex - 1); const bbs = url.substring(secondLastSlashIndex + 1, lastSlashIndex); completedXhrBBS.add(bbs); const lastmodify = response.responseText; subjectList[bbs] = {}; lastmodify.split(crlfRegex).forEach(line => { let [key, subject] = line.split(logSplitRegex, 2); if (key.includes('.')) { key = key.substring(0, key.lastIndexOf('.')); } if (htmlEntityRegex.test(subject)) { subject = parser.parseFromString(subject, 'text/html').documentElement.textContent; } subjectList[bbs][key] = subject; }); // All subjects corresponding to the keys in the cell were confirmed if (arrayContainsArray(Object.keys(subjectList[bbs]), [...new Set(donguriLogBbsRows[bbs].map(item => item.key))])) { insertCells(bbs); } else { insertCellsNotCount(bbs); // Check past log xhrGetDat(new URL("./kako/", url), addBbsPastInfo, 'text/plain; charset=utf-8'); } }; // Function to process post titles by XHRing lastmodify.txt from the BBS list in the donguri log table const xhrBbsInfoFromDonguriRows = async () => { for (let bbs of Object.keys(donguriLogBbsRows)) { const url = `${bbsOriginList[bbs]}/${bbs}/lastmodify.txt`; attemptedXhrBBS.add(bbs); xhrGetDat(url, addBbsInfo); } 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 = `${columns[index]}=${target.textContent}`; location.hash += location.hash.length > 1 ? `,${txt}` : txt; } }); }; const bbsLinkRegex = /\.5ch\.net\/([a-zA-Z0-9_-]+)\/$/; // Function to process the bbsmenu response const bbsmenuFunc = (response) => { console.timeEnd(response.finalUrl); if (response.status !== 200) { console.error('Failed to fetch bbsmenu. Status code:', response.status); return false; } const html = parser.parseFromString(response.responseText, 'text/html').documentElement; for (let bbsLink of $$('a[href*=".5ch.net/"]', html)) { const match = bbsLink.href.match(bbsLinkRegex); if (match) { const bbs = match[1]; if (Object.hasOwn(donguriLogBbsRows, bbs) === false) { continue; } bbsOriginList[bbs] = new URL(bbsLink.href).origin; bbsNameList[bbs] = bbsLink.textContent.trim(); } } if (Object.keys(bbsOriginList).length === 0) { console.error('No boards found.'); return; } for (let bbs of Object.keys(donguriLogBbsRows)) { insertBbsnameCells(bbs); } xhrBbsInfoFromDonguriRows(); }; createFilterInput(); // Initial data fetch from bbsmenu xhrGetDat('https://menu.5ch.net/bbsmenu.html', bbsmenuFunc); }; const processMap = { donguriLog: { regex: /^https?:\/\/donguri\.5ch\.net\/cannonlogs$/, handler: donguriFilter }, readCgi: { regex: /^https?:\/\/[a-z0-9]+\.5ch\.net\/test\/read\.cgi\/\w+\/\d+.*$/, handler: readCgiJump } }; const processBasedOnUrl = (url) => { for (const key in processMap) { if (processMap[key].regex.test(url)) { processMap[key].handler(); break; } } }; processBasedOnUrl(`${location.origin}${location.pathname}`); })();