209 lines
7.5 KiB
JavaScript
209 lines
7.5 KiB
JavaScript
// ==UserScript==
|
|
// @name 蜂巢 - 帖子/回复显示 IP 属地
|
|
// @namespace https://pting.club/
|
|
// @version 1.0.1
|
|
// @description 在帖子、回复及头像悬浮资料卡中显示用户资料页已公开的 IP 属地。
|
|
// @author Stardream
|
|
// @match https://pting.club/*
|
|
// @grant none
|
|
// @run-at document-idle
|
|
// @license MIT
|
|
// ==/UserScript==
|
|
|
|
(() => {
|
|
'use strict';
|
|
const CACHE_KEY = 'pting-ip-location-cache-v1';
|
|
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
|
|
const cache = new Map();
|
|
const pending = new Map();
|
|
const unavailable = new Set();
|
|
const badgeByTarget = new WeakMap();
|
|
let scanTimer;
|
|
|
|
try {
|
|
const stored = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}');
|
|
for (const [username, item] of Object.entries(stored)) {
|
|
if (item && item.location && Date.now() - item.updatedAt < CACHE_TTL) cache.set(username, item);
|
|
}
|
|
} catch {}
|
|
|
|
function saveCache() {
|
|
try { localStorage.setItem(CACHE_KEY, JSON.stringify(Object.fromEntries(cache))); } catch {}
|
|
}
|
|
|
|
function getUsername(link) {
|
|
try {
|
|
const url = new URL(link.href, location.href);
|
|
if (url.origin !== location.origin) return null;
|
|
const match = url.pathname.match(/^\/users\/([^/?#]+)\/?$/);
|
|
return match ? decodeURIComponent(match[1]) : null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
function getProfileButtonUsername(button) {
|
|
const label = button.getAttribute('aria-label') || '';
|
|
const match = label.match(/^查看\s+(.+?)\s+的资料卡$/);
|
|
return match ? match[1].trim() : null;
|
|
}
|
|
|
|
function hasNearbyUserLink(button) {
|
|
const parent = button.parentElement;
|
|
return !!parent && [...parent.querySelectorAll('a[href]')].some(getUsername);
|
|
}
|
|
|
|
function isPrimaryProfileLink(link) {
|
|
const card = link.closest('[data-slot="popover-content"]');
|
|
if (!card) return true;
|
|
const firstUserLink = [...card.querySelectorAll('a[href]')].find(getUsername);
|
|
return link === firstUserLink;
|
|
}
|
|
|
|
function isUserSearch() {
|
|
return location.pathname === '/search' && new URLSearchParams(location.search).get('type') === 'users';
|
|
}
|
|
|
|
function getSearchUsernameElement(link, username) {
|
|
if (!isUserSearch()) return null;
|
|
return [...link.querySelectorAll('p')].find(
|
|
element => element.textContent.trim() === '@' + username
|
|
) || null;
|
|
}
|
|
|
|
function extractLocation(html) {
|
|
// IP 属地位于个人页“加入”文字之后的同一资料行。
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
const joined = [...doc.querySelectorAll('body *')].find(
|
|
el => el.children.length === 0 && (el.textContent || '').trim().endsWith('加入')
|
|
);
|
|
if (!joined) return null;
|
|
|
|
// “加入”和城市不一定处于同一个最小元素内;向上寻找资料行,
|
|
// 再取“加入”之后最近的纯中文叶子节点(例如“北京”)。
|
|
for (let scope = joined.parentElement; scope; scope = scope.parentElement) {
|
|
const leaves = [...scope.querySelectorAll('*')]
|
|
.filter(el => el.children.length === 0);
|
|
const index = leaves.indexOf(joined);
|
|
if (index < 0) continue;
|
|
const locationName = leaves
|
|
.slice(index + 1, index + 5)
|
|
.map(el => (el.textContent || '').trim())
|
|
.find(text => /^[\u4e00-\u9fff]{2,12}$/.test(text));
|
|
if (locationName) return locationName;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function fetchLocation(username) {
|
|
const cached = cache.get(username);
|
|
if (cached) return Promise.resolve(cached.location);
|
|
if (pending.has(username)) return pending.get(username);
|
|
const task = fetch('/users/' + encodeURIComponent(username), { credentials: 'same-origin' })
|
|
.then(response => response.ok ? response.text() : '')
|
|
.then(extractLocation)
|
|
.catch(() => null)
|
|
.then(locationName => {
|
|
pending.delete(username);
|
|
if (locationName) {
|
|
cache.set(username, { location: locationName, updatedAt: Date.now() });
|
|
saveCache();
|
|
} else unavailable.add(username);
|
|
return locationName;
|
|
});
|
|
pending.set(username, task);
|
|
return task;
|
|
}
|
|
|
|
function appendBadge(target, username) {
|
|
const knownBadge = badgeByTarget.get(target);
|
|
if (knownBadge?.isConnected) return;
|
|
|
|
const usernameElement = getSearchUsernameElement(target, username);
|
|
// 用户搜索结果的卡片链接是网格直接子项,只能把徽标放入 @username 行。
|
|
if (isUserSearch() && !usernameElement) return;
|
|
|
|
if (usernameElement) {
|
|
const existingBadge = [...usernameElement.children].find(
|
|
element => element.classList.contains('tm-pting-ip-location') &&
|
|
element.dataset.username === username
|
|
);
|
|
if (existingBadge) {
|
|
badgeByTarget.set(target, existingBadge);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const next = target.nextElementSibling;
|
|
if (!usernameElement && next && next.classList.contains('tm-pting-ip-location') &&
|
|
next.dataset.username === username) {
|
|
badgeByTarget.set(target, next);
|
|
return;
|
|
}
|
|
const badge = document.createElement('span');
|
|
badge.className = 'tm-pting-ip-location';
|
|
badge.dataset.username = username;
|
|
badge.textContent = 'IP · …';
|
|
badge.title = 'IP 属地';
|
|
badgeByTarget.set(target, badge);
|
|
if (usernameElement) usernameElement.append(badge);
|
|
else target.insertAdjacentElement('afterend', badge);
|
|
fetchLocation(username).then(locationName => {
|
|
if (!badge.isConnected) return;
|
|
if (locationName) badge.textContent = 'IP · ' + locationName;
|
|
else {
|
|
badgeByTarget.delete(target);
|
|
badge.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
function scan() {
|
|
// 个人页原生显示属地,因此不重复插入。
|
|
if (location.pathname.startsWith('/users/')) return;
|
|
if (isUserSearch()) {
|
|
// 清理旧版错误插入到网格同级的徽标,恢复搜索结果的两栏布局。
|
|
for (const badge of document.querySelectorAll('.tm-pting-ip-location')) {
|
|
const parent = badge.parentElement;
|
|
if (parent?.classList.contains('grid') && parent.querySelector(':scope > a[href^="/users/"]')) {
|
|
badge.remove();
|
|
}
|
|
}
|
|
}
|
|
for (const link of document.querySelectorAll('a[href]')) {
|
|
const username = getUsername(link);
|
|
if (username && isPrimaryProfileLink(link) && !unavailable.has(username)) {
|
|
appendBadge(link, username);
|
|
}
|
|
}
|
|
|
|
// “作者复言”等条目只提供资料卡按钮,没有用户链接。
|
|
for (const button of document.querySelectorAll('[data-user-profile-preview-trigger]')) {
|
|
if (!button.textContent.trim() || hasNearbyUserLink(button)) continue;
|
|
const username = getProfileButtonUsername(button);
|
|
if (username && !unavailable.has(username)) appendBadge(button, username);
|
|
}
|
|
}
|
|
|
|
function scheduleScan() {
|
|
clearTimeout(scanTimer);
|
|
scanTimer = setTimeout(() => scan(), 120);
|
|
}
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = [
|
|
'.tm-pting-ip-location {',
|
|
'display:inline-flex;align-items:center;vertical-align:middle;',
|
|
'margin-left:.35em;padding:.08em .42em;',
|
|
'border:1px solid color-mix(in srgb,currentColor 22%,transparent);',
|
|
'border-radius:999px;color:inherit;font-size:.78em;line-height:1.35;',
|
|
'opacity:.7;pointer-events:none;white-space:nowrap;',
|
|
'}',
|
|
'.tm-pting-ip-location:hover { opacity:1; }'
|
|
].join('');
|
|
document.documentElement.append(style);
|
|
scan();
|
|
new MutationObserver(scheduleScan).observe(document.documentElement, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
})();
|