Bilibili Comment User Location

哔哩哔哩网页版评论区显示用户 IP 归属地

// ==UserScript==
// @name        Bilibili Comment User Location
// @namespace   Hill98
// @description 哔哩哔哩网页版评论区显示用户 IP 归属地
// @version     1.2.1
// @author      Hill-98
// @license     GPL-3.0
// @icon        https://www.bilibili.com/favicon.ico
// @homepageURL https://github.com/Hill-98/userscripts
// @supportURL  https://github.com/Hill-98/userscripts/issues
// @grant       none
// 主站
// @match       https://www.bilibili.com/*
// 直播 (直播间底部的主播动态)
// @match       https://live.bilibili.com/*
// 用户详情页
// @match       https://space.bilibili.com/*
// 动态
// @match       https://t.bilibili.com/*
// @run-at      document-start
// ==/UserScript==

const API_PREFIX = 'https://api.bilibili.com/x/v2/reply';

const console = Object.create(Object.getPrototypeOf(window.console), Object.getOwnPropertyDescriptors(window.console));

const addLocationToReply = function addLocationToReply(rootId, rpId, userId, location, count = 1) {
  const id = rootId === 0 ? rpId : rootId;
  const container = document.querySelector(`.reply-wrap[data-id="${rpId}"]`);
  const containers = document.querySelectorAll(`[data-root-reply-id="${id}"][data-user-id="${userId}"]`);
  const comments = document.querySelector('bili-comments')?.shadowRoot.querySelectorAll('bili-comment-thread-renderer');

  // 如果评论元素未找到,则在一定时间内重复尝试数次。
  if (container === null && containers.length === 0 && (!comments || comments.length === 0)) {
    if (count <= 10) {
      const args = Array.from(arguments).slice(0, arguments.length);
      args.push(count + 1);
      setTimeout(addLocationToReply, 50, ...args);
    }
    return;
  }

  const el = document.createElement('span');
  el.classList.add('reply-location');
  el.textContent = location;

  // old old page: 直接在对应评论元素插入IP位置
  if (container) {
    const info = container.querySelector('.info');
    const time = info.querySelector('.time-location');
    if (time) {
      el.style.marginLeft = '-8px';
      info.insertBefore(el, time.nextSibling);
    } else {
      const tags = container.querySelector('.reply-tags');
      if (tags) {
        info.insertBefore(el, tags);
      } else {
        info.append(el);
      }
    }
  }

  // new page: 由于无法直接定位评论元素,只能先定位其他有标识符的元素(比如用户头像),然后使用其父元素间接定位评论元素。
  if (containers) {
    for (let i = 0; i < containers.length; i++) {
      const container = containers[i];
      let parentElement = container.parentElement;
      const isSub = parentElement.classList.toString().includes('sub-');
      if (isSub) {
        parentElement = parentElement.parentElement;
      }
      const info = parentElement.querySelector(isSub ? '.sub-reply-info' : '.reply-info');
      if (info && !info.querySelector('.reply-location')) {
        const time = info.querySelector('.reply-time,.sub-reply-time');
        el.style.marginRight = '16px';
        if (time) {
          el.style.marginLeft = '-8px';
          info.insertBefore(el, time.nextSibling);
        } else {
          info.append(el);
        }
        break;
      }
    }
  }

  // new video page: 读取自定义元素上面的属性然后插入自定义元素。(这个可以放在单独的监听器里,但是我懒。)
  if (comments) {
    el.style.marginLeft = '16px';
    comments.forEach((comment) => {
      const bComments = [
        comment,
        ...(comment.shadowRoot?.querySelector('#replies bili-comment-replies-renderer')?.shadowRoot?.querySelectorAll('#expander-contents bili-comment-reply-renderer') ?? [])
      ]
      bComments.forEach((bContent) => {
        const action = bContent.tagName.toLowerCase() === 'bili-comment-thread-renderer'
          ? bContent.shadowRoot?.querySelector('#comment')?.shadowRoot?.querySelector('#footer bili-comment-action-buttons-renderer')?.shadowRoot
          : bContent.shadowRoot?.querySelector('#footer bili-comment-action-buttons-renderer')?.shadowRoot;

        if (action) {
          const span = action.querySelector('.reply-location') ?? document.createElement('span');
          span.classList.add('reply-location');
          span.style.marginLeft = '16px';
          span.textContent = bContent.__data.reply_control.location;
          action.querySelector('#pubdate')?.append(span);
        }
      })
    })
  }
};

const handleReplies = function handleReplies(replies) {
  replies.forEach((reply) => {
    const control = reply.reply_control || {};
    if (control.location) {
      try {
        addLocationToReply(reply.root, reply.rpid, reply.mid, control.location);
      } catch (ex) {
        console.error(ex);
      }
    }
    if (reply.replies) {
      handleReplies(reply.replies);
    }
  });
};

const handleResponse = async function handleResponse(url, response) {
  if (!url.startsWith(API_PREFIX)) {
    return;
  }
  const body = response instanceof Response ? await response.clone().text() : response.toString();
  try {
    const json = JSON.parse(body);
    if (json.code === 0) {
      setTimeout(() => {
        handleReplies(Array.isArray(json.data.replies) ? json.data.replies : []);
        handleReplies(Array.isArray(json.data.top_replies) ? json.data.top_replies : []);
      }, 50);
    }
  } catch (ex) {
    console.error(ex);
  }
};

const $fetch = window.fetch;

window.fetch = async function fetchHacker() {
  const response = await $fetch(...arguments);
  if (response.status === 200 && response.headers.get('content-type')?.includes('application/json')) {
    await handleResponse(response.url, response);
  }
  return response;
};

/**
 * @this XMLHttpRequest
 */
const onReadyStateChange = function onReadyStateChange() {
  if (this.readyState === XMLHttpRequest.DONE && this.status === 200 && this.getAllResponseHeaders().split("\n").find((v) => v.toLowerCase().includes('content-type: application/json'))) {
    handleResponse(this.responseURL, this.response);
  }
};

const jsonpHacker = new MutationObserver((mutationList) => {
  mutationList.forEach((mutation) => {
    mutation.addedNodes.forEach((node) => {
      if (node.nodeName.toLowerCase() !== 'script' || node.src.trim() === '') {
        return;
      }
      const u = new URL(node.src);
      if (u.searchParams.has('callback')) {
        const callbackName = u.searchParams.get('callback');
        const callback = window[callbackName];
        window[callbackName] = function (data) {
          handleResponse(u.href, JSON.stringify(data));
          callback(data);
        };
      }
    });
  });
});

document.addEventListener('DOMContentLoaded', () => {
  jsonpHacker.observe(document.head, {
    childList: true,
  });
});

window.XMLHttpRequest = class XMLHttpRequestHacker extends window.XMLHttpRequest {
  constructor() {
    super();
    this.addEventListener('readystatechange', onReadyStateChange.bind(this));
  }
};