// ==UserScript==
// @name 5ch.net donguri Hit Response Getter
// @namespace https://gf.qytechs.cn/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-10_002
// ==/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>大砲ログの<em>各セルをダブルクリック</em>して、その内容の<em>条件を追加</em>できます。</li>
<li><em>列ヘッダーをクリック</em>で<em>ソートします。</li>
</ul>`;
const DONGURI_LOG_CSS = `
body {
margin: 0;
padding: 12px;
display: block;
}
table { white-space: nowrap; }
thead {
position: sticky;
top: 0;
z-index: 1;
}
th:not([colspan]):hover, td:hover { background: #ccc; }
th:not([colspan]):active, td:active { background: #ff9; }
th, td { font-size: 15px; }
th:not([colspan]) { position: relative; }
th.sortOrder1::after { content: "▲"; }
th.sortOrder-1::after { content: "▼"; }
th[class^=sortOrder]::after {
font-size: 0.5em;
opacity: 0.5;
vertical-align: super;
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
}
td a:visited { color: #808; }
td a:hover { color: #000; }
.toggleDisplay {
position: fixed;
bottom: 10px;
right: 30px;
opacity: 0.7;
}
`;
const READ_CGI_CSS = `
.dongurihit:target {
background: #fff;
color: #000;
}
.dongurihit:target * {
background: #fff;
color: #000;
}
`;
// 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+)$/) ? location.hash.substring(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(entries => {
waitForTabToBecomeActive().then(() => {
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 = () => {
waitForTabToBecomeActive().then(() => {
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'
});
const toggleDisplayContainer = Object.assign(document.createElement('div'), {
className: 'toggleDisplay'
});
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":"date(投稿時刻)",
"bbs":"bbs",
"bbsname":"bbs名",
"key":"key",
"id":"ハンターID",
"hunter":"ハンター名",
"target":"ターゲット",
"subject":"subject"
};
Object.keys(columns).forEach((key, i) => {
columnSelector[key] = `td:nth-of-type(${i + 1})`;
});
const columnKeys = Object.keys(columns);
const columnValues = Object.values(columns);
const originalTermSelector = 'td:nth-of-type(1)';
const originalLogSelector = 'td:nth-of-type(2)';
let completedRows = 0;
const table = $('table');
if (!table) {
return false;
}
const thead = $('thead', table);
const tbody = $('tbody', table);
const originalTable = Object.assign(table.cloneNode(true), {
className: 'originalLog'
});
// Switch between original and UserScript display depending on checkbox state
toggleDisplayCheckbox.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, id = null) => {
const e = tr.appendChild(document.createElement(tr.parentElement.tagName === 'THEAD' ? 'th' : 'td'));
if (txt !== null) {
e.textContent = txt;
}
if (id !== null) {
e.id = id;
}
return e;
};
if (!$('tr th:nth-of-type(1)', thead)) {
return false;
}
// 順,期,date(投稿時刻),bbs,bbs名,key,ハンターID,ハンター名,ターゲット,subject
// order,term,date,bbs,bbsname,key,id,hunter,target,subject
const tr = $('tr:nth-of-type(1)', thead);
columnValues.slice(0, 2).forEach((txt, i) => {
const th = $(`th:nth-of-type(${i + 1})`, tr);
th.textContent = txt;
th.removeAttribute('style');
});
columnValues.slice(2).forEach(txt => appendCell(tr, txt));
table.insertAdjacentHTML('beforebegin', manual);
const headers = $$('th', thead);
const rows = $$('tr', tbody);
let sortOrder = 1; // 1: 自然順, -1: 逆順
let lastIndex = null;
let lastSortOrder = null;
const rsortKeys = ['term', 'date', 'key'];
// 各列ヘッダーにクリックイベントを設定
headers.forEach((header, index) => {
header.addEventListener('click', (e) => {
if (headers[lastIndex] && headers[lastIndex].classList) {
headers[lastIndex].classList.remove(`sortOrder${lastSortOrder}`);
}
if (lastIndex !== index) {
lastIndex = index;
sortOrder = rsortKeys.indexOf(columnKeys[index]) === -1 ? 1 : -1;
}
lastSortOrder = sortOrder;
// クリックされた列のインデックスに基づいてソート
rows.sort((rowA, rowB) => {
const cellA = rowA.cells[index].textContent;
const cellB = rowB.cells[index].textContent;
// テキストで自然順ソート
return cellA.localeCompare(cellB, 'ja', {
numeric: true
}) * sortOrder;
});
e.target.classList.add(`sortOrder${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);
// columns
$(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 noSanitizeKeys = ['log','bbsname','hunter','target','subject'];
const equalValueKeys = ['term','bbs'];
const includesValueKeys = ['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()] = noSanitizeKeys.indexOf(key) > -1 ? 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 (equalValueKeys.indexOf(key) > -1) {
return row.getAttribute(`data-${key}`) === val;
} else if (includesValueKeys.indexOf(key) > -1) {
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 = () => {
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;
});
const tr = document.createElement('tr');
const th = document.createElement('th');
th.setAttribute('colspan',columnKeys.length);
th.appendChild(input);
th.insertAdjacentHTML('afterbegin', '<b id=myfilterResult></b><br>');
tr.appendChild(th);
thead.insertAdjacentElement('afterbegin', tr);
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 = () => {
for (let bbs of Object.keys(donguriLogBbsRows)) {
const url = `${bbsOriginList[bbs]}/${bbs}/lastmodify.txt`;
attemptedXhrBBS.add(bbs);
xhrGetDat(url, addBbsInfo);
}
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 = `${columnKeys[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}`);
})();