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

// ==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/*/*
// @connect     5ch.net
// @license     MIT License
// @author      pachimonta
// @grant       GM_xmlhttpRequest
// @grant       GM_addStyle
// @version     2024-06-05_003
// ==/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>カンマ(<code>,</code>) で区切ることで複数の条件が指定できます。</li>
  <li>URLのハッシュ(<code>#</code>)より後に条件を指定することでも機能します。</li>
  <li>大砲ログの各セルをダブルクリックして、その内容の条件を追加できます。</li>
  <li>列ヘッダーをダブルクリックでソートします。</li>
</ul>`;

  const DONGURI_LOG_CSS = `
    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; }
    td a:hover { opacity: 0.5; }
    td a:visited { color: #808; }
    .hidden { display: none; }
  `;

  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) {
        $$('.date').some(dateElement => {
          const post = dateElement.closest('.post');
          if (!post) {
            return;
          }
          if (post.id === hashIsNumber || dateymd && dateElement.textContent.includes(dateymd) && dateElement.textContent.includes(datehms)) {
            post.classList.add('dongurihit');
            if (post.id && location.hash !== `#${post.id}`) {
              location.hash = `#${post.id}`;
              history.pushState({
                scrollY: window.scrollY
              }, '');
              history.go(-1);
              return;
            }
            const observer = new IntersectionObserver(async entries => {
              await waitForTabToBecomeActive();
              entries.forEach(entry => {
                if (entry.isIntersecting) {
                  setTimeout(() => {
                    post.classList.remove('dongurihit');
                  }, 1500);
                }
              });
            });
            observer.observe(post);
            return;
          }
        });
      }
    };

    const scrollToElementWhenActive = async () => {
      await waitForTabToBecomeActive();
      scrollActive();
      window.addEventListener('hashchange', scrollActive);
    };
    scrollToElementWhenActive();
    return;
  };

  // Filter Acorn Cannon Logs
  const donguriFilter = () => {
    GM_addStyle(DONGURI_LOG_CSS);

    // 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 = (tr, txt = null, elementName = 'td') => {
      if (tr.parentElement.tagName === 'THEAD') {
        elementName = 'th';
      }
      const e = tr.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;
            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;
    // 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.classList.remove('hidden');
            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++;
          }
          if (isVisible) {
            row.classList.remove('hidden');
          } else {
            row.classList.add('hidden');
          }
        });
      } 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++;
      }
    };

    const lastRow = $('tr:last-child', tbody);

    // GM_xmlhttpRequest wrapper to handle HTTP Get requests
    const xhrGetDat = (url, func) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url: url,
        timeout: 3600 * 1000,
        overrideMimeType: 'text/plain; charset=shift_jis',
        onload: function(response) {
          func(response);
        },
        onerror: function(error) {
          console.error('An error occurred during the request:', error);
        }
      });
    };

    const getDatOrXhr = (url, func, tr) => {
      if (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: 3600 * 1000,
        overrideMimeType: 'text/plain; charset=shift_jis',
        onload: function(response) {
          const date = tr.dataset.date;
          const bbs = tr.dataset.bbs;
          const key = tr.dataset.key;
          func(response, tr, bbs, key, date);
          // After processing the last row
          if (Object.is(tr, lastRow) && $('#myfilter').value.indexOf('=') > -1) {
            filterRows($('#myfilter'));
          }
        },
        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 addBbsInfo = (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 xhrBbsInfoFromRows = async () => {
      Array.from($$('tr', tbody)).forEach(tr => {
        const bbs = tr.dataset.bbs;

        if (!Object.hasOwn(subjectList, bbs)) {
          subjectList[bbs] = {};
        }
        const url = `${bbsList[bbs]}/${bbs}/lastmodify.txt`;
        getDatOrXhr(url, addBbsInfo, tr);
        completedURL[url] = true;
      });
      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\/([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/"]', 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;
        }
        xhrBbsInfoFromRows();
      } else {
        console.error('Failed to fetch bbsmenu. Status code:', response.status);
      }

    };
    // 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: /\/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}`);
})();