// ==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-04_004
// @downloadURL none
// ==/UserScript==
(function() {
'use strict';
const manual = `
UserScriptの説明:
- 以下の入力欄に
bbs=newsplus
のように入力すると、マッチするログのみ表示します。
- 入力欄の内容をURLのハッシュ(
#
)より後に同じように指定することでも機能します。
- カンマ(
,
) で区切ることで複数条件の指定ができます。
- 大砲ログの各セルをダブルクリックすることでも、その内容の条件を追加できます。
- 列ヘッダーをダブルクリックでソートします。
`;
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; }
`;
const readCGICSS = `.dongurihit { 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);
let processed = false;
const scrollActive = () => {
const match = location.hash.match(/(?:&|#)date=([^&=]{10})([^&=]+)/);
const [dateymd, datehms] = match ? [match[1], match[2]] : [null, null];
if (dateymd) {
$$('.date').some(dateElement => {
if (dateElement.textContent.includes(dateymd) && dateElement.textContent.includes(datehms)) {
// Once a matching element is found, scroll to it
const post = dateElement.closest('.post');
if (post) {
history.pushState({
scrollY: window.scrollY
}, '');
post.scrollIntoView({
behavior: 'smooth'
});
post.classList.add('dongurihit');
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setTimeout(() => {
post.classList.remove('dongurihit');
}, 3000);
}
});
});
observer.observe(post);
}
processed = true;
return;
}
});
}
};
const waitForTabToBecomeActive = () => {
return new Promise((resolve) => {
if (!document.hidden && document.visibilityState === 'visible') {
resolve();
} else {
const handleVisibilityChange = () => {
if (!document.hidden && document.visibilityState === 'visible') {
document.removeEventListener('visibilitychange', handleVisibilityChange);
resolve();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
}
});
};
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 = (elem, txt = null, elementName = 'td') => {
if (elem.parentElement.tagName === 'THEAD') {
elementName = 'th';
}
const e = elem.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.trim();
const cellB = rowB.cells[index].textContent.trim();
// テキストで自然順ソート
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.style.display = '';
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++;
}
row.style.display = isVisible ? '' : 'none';
});
} 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');
})();