Weibo Post IP Location

Display a post author's IP location on Weibo feed cards when Weibo exposes it in its own status APIs.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         Weibo Post IP Location
// @version      1.0.0
// @description  Display a post author's IP location on Weibo feed cards when Weibo exposes it in its own status APIs.
// @author       xmazing
// @match        https://weibo.com/*
// @match        https://www.weibo.com/*
// @match        https://*.weibo.com/*
// @run-at       document-start
// @grant        unsafeWindow
// @namespace https://greasyfork.org/users/1600592
// ==/UserScript==

(function () {
  "use strict";

  const W = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
  const MAX_CONCURRENT_DETAIL_FETCHES = 2;
  const DETAIL_FETCH_COOLDOWN_MS = 160;
  const REGION_CACHE_LIMIT = 2400;
  const REGION_FIELD_PATTERN = /"?(region_name|status_region_name|mblog_region_name|ip_location|ipRegion|ip_region)"?\s*:/;

  const regionById = new Map();
  const queuedIds = new Set();
  const pendingIds = new Set();
  const failedIds = new Set();
  const detailQueue = [];
  let activeDetailFetches = 0;
  let scanTimer = 0;

  const style = document.createElement("style");
  style.textContent = `
    .wb-post-ip-location {
      display: inline-flex;
      align-items: center;
      margin-left: 8px;
      color: #939393;
      font-size: 12px;
      line-height: 1.4;
      white-space: nowrap;
      vertical-align: baseline;
    }
    .wb-post-ip-location::before {
      content: "";
      width: 3px;
      height: 3px;
      margin-right: 8px;
      border-radius: 50%;
      background: currentColor;
      opacity: .75;
    }
  `;

  function installStyle() {
    if (style.isConnected) return;
    (document.head || document.documentElement).appendChild(style);
  }

  function cssEscape(value) {
    if (W.CSS && typeof W.CSS.escape === "function") return W.CSS.escape(value);
    return String(value).replace(/["\\]/g, "\\$&");
  }

  function normalizeRegion(value) {
    if (!value) return "";

    let text = "";
    if (Array.isArray(value)) {
      text = value.filter(Boolean).join(" ");
    } else if (typeof value === "object") {
      text = value.name || value.text || value.region_name || value.ip_location || "";
    } else {
      text = String(value);
    }

    return text
      .replace(/<[^>]*>/g, "")
      .replace(/&nbsp;/gi, " ")
      .replace(/^发布于\s*/u, "")
      .replace(/^来自\s*/u, "")
      .replace(/^IP\s*属地\s*[::]?\s*/iu, "")
      .replace(/\s+/g, " ")
      .trim();
  }

  function pickRegion(status) {
    if (!status || typeof status !== "object") return "";

    return normalizeRegion(
      status.region_name ||
        status.status_region_name ||
        status.mblog_region_name ||
        status.ip_location ||
        status.ipLocation ||
        status.ip_region ||
        status.ipRegion ||
        status.region
    );
  }

  function collectIds(status) {
    const ids = [
      status.id,
      status.idstr,
      status.mid,
      status.mblogid,
      status.bid,
      status.url_struct && status.url_struct.mblogid
    ];

    return ids
      .flat()
      .filter(Boolean)
      .map((id) => String(id))
      .filter((id) => /^[A-Za-z0-9_-]{5,}$/.test(id));
  }

  function trimRegionCache() {
    if (regionById.size <= REGION_CACHE_LIMIT) return;
    const deleteCount = Math.floor(REGION_CACHE_LIMIT / 4);
    let deleted = 0;
    for (const id of regionById.keys()) {
      regionById.delete(id);
      deleted += 1;
      if (deleted >= deleteCount) break;
    }
  }

  function rememberStatus(status) {
    if (!status || typeof status !== "object") return false;

    const ids = collectIds(status);
    if (!ids.length) return false;

    const region = pickRegion(status);
    if (!region) return false;

    for (const id of ids) {
      regionById.set(id, region);
      failedIds.delete(id);
    }

    trimRegionCache();
    scheduleScan();
    return true;
  }

  function looksLikeStatusObject(value) {
    if (!value || typeof value !== "object" || Array.isArray(value)) return false;
    if (!(value.id || value.idstr || value.mid || value.mblogid || value.bid)) return false;

    return Boolean(
      value.text ||
        value.text_raw ||
        value.created_at ||
        value.region_name ||
        value.status_region_name ||
        value.mblog_region_name
    );
  }

  function collectStatuses(value, depth = 0, seen = new WeakSet()) {
    if (!value || depth > 8) return;
    if (typeof value !== "object") return;
    if (seen.has(value)) return;
    seen.add(value);

    if (Array.isArray(value)) {
      for (const item of value) collectStatuses(item, depth + 1, seen);
      return;
    }

    if (looksLikeStatusObject(value)) {
      rememberStatus(value);
      if (value.retweeted_status) collectStatuses(value.retweeted_status, depth + 1, seen);
    }

    for (const key of Object.keys(value)) {
      const next = value[key];
      if (next && typeof next === "object") collectStatuses(next, depth + 1, seen);
    }
  }

  function parseJsonText(text) {
    if (!text || typeof text !== "string") return;
    const trimmed = text.trim();
    const first = trimmed.charAt(0);
    if (first !== "{" && first !== "[") return;
    if (!REGION_FIELD_PATTERN.test(trimmed)) return;

    try {
      collectStatuses(JSON.parse(trimmed));
    } catch {
      // Some same-origin responses are not JSON; leave the page untouched.
    }
  }

  function patchFetch() {
    if (!W.fetch || W.fetch.__wbPostIpPatched) return;

    const originalFetch = W.fetch;
    W.fetch = async function patchedFetch(...args) {
      const response = await originalFetch.apply(this, args);
      try {
        const url = String(args[0] && (args[0].url || args[0]));
        if (/\/ajax\//.test(url)) {
          response
            .clone()
            .text()
            .then(parseJsonText)
            .catch(() => {});
        }
      } catch {
        // Keep Weibo's own request path untouched.
      }
      return response;
    };
    W.fetch.__wbPostIpPatched = true;
  }

  function patchXhr() {
    const XHR = W.XMLHttpRequest;
    if (!XHR || XHR.prototype.__wbPostIpPatched) return;

    const originalOpen = XHR.prototype.open;
    XHR.prototype.open = function patchedOpen(method, url, ...rest) {
      this.__wbPostIpUrl = String(url || "");
      return originalOpen.call(this, method, url, ...rest);
    };

    const originalSend = XHR.prototype.send;
    XHR.prototype.send = function patchedSend(...args) {
      this.addEventListener("load", function () {
        try {
          if (/\/ajax\//.test(this.__wbPostIpUrl || "")) parseJsonText(this.responseText);
        } catch {
          // Keep Weibo's own request path untouched.
        }
      });
      return originalSend.apply(this, args);
    };

    XHR.prototype.__wbPostIpPatched = true;
  }

  function getPathParts(href) {
    try {
      const url = new URL(href, location.href);
      if (!/(^|\.)weibo\.com$/i.test(url.hostname)) return [];
      return url.pathname.split("/").filter(Boolean);
    } catch {
      return [];
    }
  }

  function extractPostIdFromHref(href) {
    const parts = getPathParts(href);
    if (parts.length < 2) return "";

    const [first, second] = parts;
    if (/^(u|p|tv|search|ajax|login|signup|messages|settings)$/i.test(first)) return "";
    if (!/^[A-Za-z0-9]{7,}$/.test(second)) return "";
    return second;
  }

  function getArticles(root) {
    const articles = [];
    if (root && root.matches && root.matches("article")) articles.push(root);
    if (root && root.querySelectorAll) articles.push(...root.querySelectorAll("article"));
    return articles;
  }

  function isTimeLikeLink(link) {
    const text = (link.textContent || "").trim();
    const title = (link.getAttribute("title") || "").trim();
    return /分钟前|小时前|昨天|刚刚|\d{1,2}-\d{1,2}|\d{4}-\d{1,2}-\d{1,2}/.test(text + " " + title);
  }

  function findTimeLink(card) {
    const links = Array.from(card.querySelectorAll('a[href*="weibo.com/"], a[href^="/"]'));
    return (
      links.find((link) => link.matches('a[class*="_time_"]') && extractPostIdFromHref(link.href)) ||
      links.find((link) => extractPostIdFromHref(link.href) && isTimeLikeLink(link)) ||
      null
    );
  }

  function getCardIds(card) {
    const idSet = new Set();
    for (const link of card.querySelectorAll("a[href]")) {
      const id = extractPostIdFromHref(link.href);
      if (id) idSet.add(id);
    }
    return Array.from(idSet);
  }

  function getPrimaryCardId(card) {
    const timeLink = findTimeLink(card);
    if (timeLink) return extractPostIdFromHref(timeLink.href);

    const ids = getCardIds(card);
    return ids.find((id) => regionById.has(id)) || ids[0] || "";
  }

  function findBestPostLink(card, id) {
    const links = Array.from(card.querySelectorAll("a[href]")).filter(
      (link) => extractPostIdFromHref(link.href) === id
    );

    return links.find((link) => isTimeLikeLink(link)) || links[0] || null;
  }

  function findSourceNode(card, id) {
    const exactSource = card.querySelector('[class*="_source_"]');
    if (exactSource) return exactSource;

    const postLink = findBestPostLink(card, id);
    if (!postLink) return null;

    const infoRow =
      postLink.closest('[class*="_info"]') ||
      postLink.parentElement ||
      card;

    const candidates = Array.from(infoRow.querySelectorAll("div, span, a")).filter((node) => {
      if (node === postLink || node.classList.contains("wb-post-ip-location")) return false;
      const text = (node.textContent || "").trim();
      const title = (node.getAttribute("title") || "").trim();
      return /^来自\s*/.test(text) || /^来自\s*/.test(title);
    });

    return candidates[candidates.length - 1] || null;
  }

  function setCardRegion(card, id, region) {
    installStyle();

    const existing = card.querySelector(`.wb-post-ip-location[data-wb-post-id="${cssEscape(id)}"]`);
    if (existing) {
      existing.textContent = `IP属地:${region}`;
      return;
    }

    const marker = document.createElement("span");
    marker.className = "wb-post-ip-location";
    marker.dataset.wbPostId = id;
    marker.textContent = `IP属地:${region}`;
    marker.title = "该信息来自微博页面接口返回的微博属地字段";

    const sourceNode = findSourceNode(card, id);
    if (sourceNode) {
      sourceNode.insertAdjacentElement("afterend", marker);
      return;
    }

    const postLink = findBestPostLink(card, id);
    if (postLink) {
      postLink.insertAdjacentElement("afterend", marker);
      return;
    }

    const fallbackTarget =
      card.querySelector("header") ||
      card.querySelector('[class*="head" i]') ||
      card.querySelector('[class*="info" i]') ||
      card.firstElementChild;

    if (fallbackTarget) fallbackTarget.appendChild(marker);
  }

  function enqueueDetailFetch(id) {
    if (!id || regionById.has(id) || queuedIds.has(id) || pendingIds.has(id) || failedIds.has(id)) return;
    queuedIds.add(id);
    detailQueue.push(id);
    pumpDetailQueue();
  }

  function pumpDetailQueue() {
    while (activeDetailFetches < MAX_CONCURRENT_DETAIL_FETCHES && detailQueue.length) {
      const id = detailQueue.shift();
      queuedIds.delete(id);
      pendingIds.add(id);
      activeDetailFetches += 1;

      fetchDetail(id)
        .catch(() => {
          failedIds.add(id);
        })
        .finally(() => {
          pendingIds.delete(id);
          activeDetailFetches -= 1;
          window.setTimeout(pumpDetailQueue, DETAIL_FETCH_COOLDOWN_MS);
        });
    }
  }

  async function fetchDetail(id) {
    const response = await W.fetch(`/ajax/statuses/show?id=${encodeURIComponent(id)}&locale=zh-CN`, {
      credentials: "include",
      headers: {
        accept: "application/json, text/plain, */*"
      }
    });

    if (!response.ok) throw new Error(`HTTP ${response.status}`);

    const data = await response.json();
    collectStatuses(data);

    const directRegion = pickRegion(data);
    if (directRegion) regionById.set(id, directRegion);

    if (!regionById.has(id)) throw new Error("No region in detail response");
    scheduleScan();
  }

  function scan() {
    if (!document.querySelectorAll) return;
    installStyle();

    for (const card of getArticles(document)) {
      const id = getPrimaryCardId(card);
      if (!id) continue;

      const region = regionById.get(id);
      if (region) {
        setCardRegion(card, id, region);
      } else {
        enqueueDetailFetch(id);
      }
    }
  }

  function scheduleScan() {
    window.clearTimeout(scanTimer);
    scanTimer = window.setTimeout(scan, 180);
  }

  function shouldScanForNode(node) {
    if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
    if (!node.matches || !node.querySelector) return false;

    return (
      node.matches("article") ||
      node.matches('a[class*="_time_"], [class*="_source_"]') ||
      Boolean(node.querySelector('article, a[class*="_time_"], [class*="_source_"]'))
    );
  }

  function startObserver() {
    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (mutation.type !== "childList") continue;
        for (const node of mutation.addedNodes) {
          if (shouldScanForNode(node)) {
            scheduleScan();
            return;
          }
        }
      }
    });

    observer.observe(document.documentElement, {
      childList: true,
      subtree: true
    });
  }

  function startAutoScan() {
    scan();

    let ticks = 0;
    const startupTimer = window.setInterval(() => {
      ticks += 1;
      scan();
      if (ticks >= 20) window.clearInterval(startupTimer);
    }, 500);

    window.addEventListener("scroll", scheduleScan, { passive: true });
    window.addEventListener("popstate", scheduleScan);
    document.addEventListener("visibilitychange", scheduleScan);
  }

  try {
    patchFetch();
    patchXhr();

    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", () => {
        startObserver();
        startAutoScan();
      });
    } else {
      startObserver();
      startAutoScan();
    }
  } catch {
    // Silently fail so Weibo itself is never affected.
  }
})();