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

您需要先安装一款用户脚本管理器扩展,例如 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-06_001
// ==/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>`;

  // !!!! .hidden is required for filters !!!!
  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 = {};
    // Number of attempted requests to lastmodify.txt
    const attemptedXhrURL = new Set();
    // Number of successful requests to lastmodify.txt
    const column = ['order', 'term', 'date', 'bbs', 'bbsname', 'key', 'id', 'hunter', 'target', 'subject'];
    let completedTbodyTrCnt = 0;

    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);
        }
      });
    };

    // Number of 'tbody tr' selectors
    let tbodyTrCnt = 0;
    const userLogRegex = /^(.*)?さん\[([a-f0-9]{8})\]は(.*(?:\[[a-f0-9]{4}\*\*\*\*\])?)?さんを撃った$/;
    // Expand each cell in the tbody
    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(userLogRegex);
      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);
      tbodyTrCnt = i + 1;
    });

    // 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 = (val) => {
      let count = 0;
      let total = 0;
      try {
        const value = val.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));
        }

        window.addEventListener('hashchange', () => {
          input.value = decodeURIComponent(location.hash.substring(1));
          filterRows(input.value);
        });
      }
    };

    // 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 xhrGetDat = (url, bbsmenuFunc) => {
      console.time(url);
      GM_xmlhttpRequest({
        method: 'GET',
        url: url,
        timeout: 3600 * 1000,
        overrideMimeType: 'text/plain; charset=shift_jis',
        onload: function(response) {
          bbsmenuFunc(response);
        },
        onerror: function(error) {
          console.error('An error occurred during the request:', error);
        }
      });
    };

    const getDatOrXhr = (url, addBbsInfo, tr) => {
      if (attemptedXhrURL.has(url)) {
        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);
          // After inserting all cells
          if (++completedTbodyTrCnt === tbodyTrCnt) {
            filterRows($('#myfilter').value);
          }
        })();
        return;
      }
      console.time(url);
      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;
          addBbsInfo(response, tr, bbs, key, date);
        },
        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;
      if (response.status === 200) {
        console.timeEnd(response.finalUrl);
        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);
        // After inserting all cells
        if (++completedTbodyTrCnt === tbodyTrCnt) {
          filterRows($('#myfilter').value);
        }
      } else {
        console.error('Failed to load data. Status code:', response.status);
      }
    };

    // Function to handle each table row for subject processing
    const xhrBbsInfoFromRows = async () => {
      createFilterInput();
      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);
        attemptedXhrURL.add(url);
      });
      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 = 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: /^https?:\/\/[^.\/]+\.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}`);
})();