// ==UserScript==
// @name 5ch.net donguri Hit Response Getter
// @namespace http://greasyfork.icu/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_001
// ==/UserScript==
(function() {
'use strict';
const manual = `<ul>外部リンク:
<li><a href="https://nanjya.net/donguri/" target="_blank">5chどんぐりシステムの備忘録</a> どんぐりシステムに関する詳細。</li>
<li><a href="http://dongurirank.starfree.jp/" target="_blank">どんぐりランキング置き場</a> 大砲ログの過去ログ検索やログ統計など。</li>
</ul>
<ul><b>UserScriptの説明:</b>
<li>以下の入力欄に<code>bbs=poverty</code> のように入力すると、該当するログだけを表示します。</li>
<li>カンマ(<code>,</code>) で区切ることで複数の条件が指定できます。</li>
<li>URLのハッシュ(<code>#</code>)より後に条件を指定することでも機能します。</li>
<li>大砲ログの各セルをダブルクリックして、その内容の条件を追加できます。</li>
<li>列ヘッダーをダブルクリックでソートします。</li>
</ul>`;
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 switch between the original-table and the UserScript-table
const toggleDisplayChkbox = Object.assign(document.createElement('input'), {
type: 'checkbox',
checked: 'checked',
id: 'toggleDisplay',
style: 'position:fixed;top:10px;right:10px'
});
const toggleDisplayLabel = Object.assign(document.createElement('label'), {
htmlFor: 'toggleDisplay',
innerText: 'Toggle Table',
id: 'toggleDisplay',
style: 'position:fixed;top:10px;right:30px'
});
$('body').append(toggleDisplayChkbox);
$('body').append(toggleDisplayLabel);
// Storage for bbs list and subject list
const bbsOriginList = {};
const bbsNameList = {};
const subjectList = {};
const logBbsRows = {};
// Number of attempted requests to lastmodify.txt
const attemptedXhrBBS = new Set();
const completedXhrBBS = new Set();
const column = ['order', 'term', 'date', 'bbs', 'bbsname', 'key', 'id', 'hunter', 'target', 'subject'];
let completedRows = 0;
const table = $('table');
const thead = $('thead', table);
const tbody = $('tbody', table);
const originalTable = table.cloneNode(true);
originalTable.classList.add('originalLog');
// Switch between original and UserScript display depending on checkbox state
toggleDisplayChkbox.addEventListener('change', (event) => {
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 = $('td:nth-of-type(2)', row).textContent.trim();
const verticalPos = log.lastIndexOf('|');
const [bbs, key, date] = log.slice(verticalPos + 2).split(' ', 3);
if (Object.hasOwn(logBbsRows, bbs) === false) {
logBbsRows[bbs] = [i];
} else {
logBbsRows[bbs].push(i);
}
row.dataset.order = i + 1;
row.dataset.term = $('td:nth-of-type(1)', 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);
$('td:nth-of-type(2)', row).textContent = $('td:nth-of-type(1)', row).textContent;
$('td:nth-of-type(1)', 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 to avoid XSS and other injections
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) => {
logBbsRows[bbs].forEach(index => {
const row = initialRows[index];
const {
date,
key
} = row.dataset;
const origin = bbsOriginList[bbs] || "https://origin";
const bbsName = bbsNameList[bbs] || "???";
const subject = subjectList[bbs][`${key}.dat`] || "???";
Object.assign(row.dataset, {
origin,
bbsname: bbsName,
subject
});
$('td:nth-of-type(5)', row).textContent = bbsName;
const anchor = Object.assign(document.createElement('a'), {
href: `${origin}/test/read.cgi/${bbs}/${key}/?v=pc#date=${date}`,
target: '_blank',
textContent: subject
});
$('td:nth-of-type(10)', row).insertAdjacentElement('beforeend', anchor);
++completedRows;
});
// After inserting all cells
if (completedRows === rowCount) {
filterRows($('#myfilter').value);
}
};
// 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', '<p><b id=myfilterResult></b></p>');
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) => {
console.time(url);
GM_xmlhttpRequest({
method: 'GET',
url: url,
timeout: 3600 * 1000,
overrideMimeType: 'text/plain; charset=shift_jis',
onload: response => loadFunc(response),
onerror: error => console.error('An error occurred during the request:', error)
});
};
const parser = new DOMParser();
const htmlEntityRegex = /&#?[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 addBbsInfo = (response) => {
if (response.status !== 200) {
console.error('Failed to load data. Status code:', response.status);
return;
}
console.timeEnd(response.finalUrl);
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 (htmlEntityRegex.test(subject)) {
subject = parser.parseFromString(subject, 'text/html').documentElement.innerText;
}
subjectList[bbs][key] = subject;
});
insertCells(bbs);
};
// Function to handle each table row for subject processing
const xhrBbsInfoFromRows = async () => {
Object.keys(logBbsRows).forEach(bbs => {
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 = `${column[index]}=${target.textContent}`;
location.hash += location.hash.indexOf('=') > -1 ? `,${txt}` : txt;
}
});
};
const bbsLinkRegex = /\.5ch\.net\/([a-zA-Z0-9_-]+)\/$/;
// Function to process the bbsmenu response
const bbsmenuFunc = (response) => {
if (response.status === 200) {
console.timeEnd(response.finalUrl);
const html = Object.assign(document.createElement('html'), {
innerHTML: response.responseText
});
$$('a[href*=".5ch.net/"]', html).forEach(bbsLink => {
const match = bbsLink.href.match(bbsLinkRegex);
if (match) {
bbsOriginList[match[1]] = new URL(bbsLink.href).origin;
bbsNameList[match[1]] = bbsLink.textContent.trim();
}
});
if (Object.keys(bbsOriginList).length === 0) {
console.error('No boards found.');
return;
}
xhrBbsInfoFromRows();
} else {
console.error('Failed to fetch bbsmenu. Status code:', response.status);
}
};
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?:\/\/[^.\/]+\.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}`);
})();