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

您需要先安装一款用户脚本管理器扩展,例如 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       *://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_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>大砲ログの各セルをダブルクリックして、その内容の条件を追加できます。</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 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',
      innerText: 'Toggle Table',
      id: 'toggleDisplay'
    });
    const toggleDisplayContainer = Object.assign(document.createElement('div'), {
      style: 'position:fixed;top:10px;right:30px;opacity:0.7'
    });
    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 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
    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) => {
      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(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 = $('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
    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) => {
      for (let obj of donguriLogBbsRows[bbs]) {
        const {
          index,
          key
        } = obj;
        const row = initialRows[index];
        const date = row.dataset.date;
        const origin = bbsOriginList[bbs] || "https://origin";
        const bbsName = bbsNameList[bbs] || "???";
        const subject = subjectList[bbs][key] || "???";
        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, 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) => {
      if (response.status !== 200) {
        console.error('Failed to load data. Status code:', response.status);
        return;
      }
      console.timeEnd(response.finalUrl);

      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).innerText, $('.title', p).innerText ];
        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) => {
      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 (key.includes('.')) { key = key.substring(0, key.lastIndexOf('.')); }
        if (htmlEntityRegex.test(subject)) {
          subject = parser.parseFromString(subject, 'text/html').documentElement.innerText;
        }
        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))]) === true) {
        insertCells(bbs);
      } else {
        // 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 = async () => {
      Object.keys(donguriLogBbsRows).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 = parser.parseFromString(response.responseText, 'text/html').documentElement;
        $$('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;
        }
        xhrBbsInfoFromDonguriRows();
      } 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?:\/\/[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}`);
})();