Greasy Fork

Greasy Fork is available in English.

5ch.net donguri Hit Response Getter

Fetches and filters hit responses from donguri 5ch boards

当前为 2024-06-04 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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       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
// ==/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=newsplus</code> のように入力すると、マッチするログのみ表示します。</li>
  <li>入力欄の内容をURLのハッシュ(<code>#</code>)より後に同じように指定することでも機能します。</li>
  <li>カンマ(<code>,</code>) で区切ることで複数条件の指定ができます。</li>
  <li>大砲ログの各セルをダブルクリックすることでも、その内容の条件を追加できます。</li>
  <li>列ヘッダーをダブルクリックでソートします。</li>
</ul>`;

  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', '<p><b id=myfilterResult></b></p>');
      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');
})();