Display a post author's IP location on Weibo feed cards when Weibo exposes it in its own status APIs.
// ==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(/ /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.
}
})();