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-03 提交的版本,查看 最新版本

// ==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
// @connect     5ch.net
// @license     MIT License
// @author      pachimonta
// @grant       GM_xmlhttpRequest
// @grant       GM_addStyle
// @version     2024-06-04_001
// ==/UserScript==
(function() {
  'use strict';

  // 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'];

  // Helper functions
  const $ = (selector, context = document) => context.querySelector(selector);
  const $$ = (selector, context = document) => [...context.querySelectorAll(selector)];

  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 appendEl = (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 ($('table thead tr th:nth-of-type(1)')) {
    // 順,期,date(投稿時刻),bbs,bbs名,key,ハンターID,ハンター名,ターゲット,subject
    // order,term,date,bbs,bbsname,key,id,hunter,target,subject
    const tr = $('table thead tr:nth-of-type(1)');
    $('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');
    appendEl(tr, 'date(投稿時刻)');
    appendEl(tr, 'bbs');
    appendEl(tr, 'bbs名');
    appendEl(tr, 'key');
    appendEl(tr, 'ハンターID');
    appendEl(tr, 'ハンター名');
    appendEl(tr, 'ターゲット');
    appendEl(tr, 'subject');

    $('table').insertAdjacentHTML('beforebegin', `<p>
  <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>
</p>
<p>
  <ul><b>UserScriptの説明:</b>
    <li>以下の入力欄に<code>bbs=newsplus</code> のように入力すると、マッチするログのみ表示します。
    <li>入力欄の内容をURLのハッシュ(<code>#</code>)以降に同じように指定することでも機能します。
    <li>カンマ(<code>,</code>) で区切ることで複数条件の指定(論理積)ができます。
    <li>大砲ログの各セルをダブルクリックすることでも、その内容の条件を追加できます。
    <li>列ヘッダーをダブルクリックでソートします。
  </ul>
</p>`);
    const table = $('table');
    const headers = $$('th', table);
    const tbody = $('tbody', table);
    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));
      });
    });
  }

  // Regular expression to detect and replace unwanted characters
  const rloRegex = /[\x00-\x1F\x7F\u200E\u200F\u202A\u202B\u202C\u202D\u202E]/g;
  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);
      }
    });
  };



  $$('tr', $('table 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(/^(.*)?さん\[([a-f0-9]{8})\]は(.*(?:\[[a-f0-9]{4}\*\*\*\*\])?)?さんを撃った$/);
    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;
    appendEl(tr, addWeekdayToDatetime(date));
    appendEl(tr, bbs);
    appendEl(tr);
    appendEl(tr, key);
    appendEl(tr, tr.dataset.id);
    appendEl(tr, tr.dataset.hunter);
    appendEl(tr, tr.dataset.target);
    appendEl(tr);
  });

  GM_addStyle('body { margin: 0; padding: 12px; display: block; }');
  GM_addStyle('thead, tbody { white-space: nowrap; }');
  GM_addStyle('th { user-select: none; }');

  // Sanitize user input to avoid XSS and other injections
  const sanitize = (value) => value.replace(/[^a-zA-Z0-9_:/.\-]/g, '');

  // Update elements visibility based on filtering criteria
  const filterRows = (input) => {
    const value = input.value.trim();
    if (!value) {
      $$('tr', $('table tbody')).forEach(row => {
        row.style.display = '';
        return;
      });
      return;
    }

    const criteria = value.split(',').map(item => item.split('=')).reduce((acc, [key, val]) => {
      if (key && val) {
        acc[key.trim()] = key.match(/^(?:log|bbsname|hunter|target|subject)$/) ? val.trim() : sanitize(val.trim());
      }
      return acc;
    }, {});

    $$('tr', $('table tbody')).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}`)) {
          if (key.match(/^(?:term|bbs)$/)) {
            return row.getAttribute(`data-${key}`) === val;
          } else if (key.match(/^(?:log|bbsname|subject|date)$/)) {
            return row.getAttribute(`data-${key}`).includes(val);
          } else {
            return row.getAttribute(`data-${key}`).indexOf(val) === 0;
          }
        } else {
          return false;
        }
      });
      row.style.display = isVisible ? '' : 'none';
    });
  };

  // Initialize the filter input and its functionalities
  const createFilterInput = () => {
    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) {
      table.parentNode.insertBefore(input, table);
      input.addEventListener('input', () => {
        location.hash = '#' + input.value;
        return;
      });

      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 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}/#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);
        } else {
          func(response);
        }
      },
      onerror: function(error) {
        console.error('An error occurred during the request:', error);
      }
    });
  };

  const parser = new DOMParser();

  // 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(/[\r\n]+/).forEach(line => {
        let [key, subject] = line.split(/\s*<>\s*/, 2);
        if (/&#?[a-zA-Z0-9]+;?/.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}/#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 () => {
    $$('tr', $('table 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();
      const target = event.target;
      if (target.tagName === 'TD') {
        const index = Array.prototype.indexOf.call(target.parentNode.children, target);
        const txt = `${column[index]}=${target.textContent}`;
        if (!$('#myfilter')) {
          return;
        }
        location.hash += location.hash.indexOf('=') > -1 ? `,${txt}` : txt;
      }
    });
  };

  // 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(/\.(?:5ch\.net|bbspink\.com)\/([a-zA-Z0-9_-]+)\/$/);
        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');
})();