专门为 MiddleFun 设计的头像拓展信息工具插件
// ==UserScript==
// @name MiddleFun-Expansion-tools
// @namespace https://www.middlefun.com/
// @version 5.4.1
// @description 专门为 MiddleFun 设计的头像拓展信息工具插件
// @author evan.xin
// @match https://www.middlefun.com/*
// @match https://middlefun.com/*
// @run-at document-idle
// @grant none
// @noframes
// @license MIT
// ==/UserScript==
(() => {
'use strict';
// 通过文档级事件代理处理单个头像悬停,并在站内无刷新换页时主动重置状态。
const HIDE_DELAY = 90;
const HOVER_INTENT_DELAY = 180;
const REQUEST_TIMEOUT = 12 * 1000;
const CACHE_LIMIT = 24;
const CACHE_TTL = 10 * 60 * 1000;
const CACHE_STORAGE_KEY = 'mf-hover-profile-cache-v2';
const BADGE_LIMIT = 12;
const CARD_ID = 'mf-avatar-hover-card';
const memberPath = /^\/members\/([^/?#]+)/;
const joinPattern = /(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日\s*加入/;
const rankPattern = /第\s*(\d+)\s*位会员/;
// 仅保存最近 24 位成员的公开资料;会话内复用,十分钟后按需重新读取以识别更新。
const profileCache = loadProfileCache();
let activeHover = null;
let hideTimer = 0;
let hoverTimer = 0;
let cacheSaveTimer = 0;
let activeController = null;
let positionFrame = 0;
let sequence = 0;
const card = createCard();
injectStyle();
function createCard() {
const node = document.createElement('section');
node.id = CARD_ID;
node.setAttribute('role', 'dialog');
node.setAttribute('aria-label', '成员资料');
node.innerHTML = '<div class="mf-loading" aria-live="polite">读取公开资料…</div>';
node.addEventListener('pointerenter', () => clearTimeout(hideTimer));
node.addEventListener('pointerleave', () => closeCard(true));
(document.body || document.documentElement).append(node);
return node;
}
function injectStyle() {
const style = document.createElement('style');
style.textContent = `
#${CARD_ID}{position:fixed;z-index:2147483647;box-sizing:border-box;width:292px;max-width:calc(100vw - 16px);padding:13px;border:1px solid rgba(0,0,0,.09);border-radius:12px;background:#fff;color:#18181b;box-shadow:0 12px 32px rgba(0,0,0,.16);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;opacity:0;visibility:hidden;transform:translateY(3px);transition:opacity .12s ease,transform .12s ease,visibility .12s;pointer-events:none}
#${CARD_ID}.mf-visible{opacity:1;visibility:visible;transform:translateY(0);pointer-events:auto}
#${CARD_ID} *{box-sizing:border-box}
#${CARD_ID} .mf-top{display:flex;align-items:center;justify-content:space-between;gap:10px;padding-bottom:9px;border-bottom:1px solid rgba(0,0,0,.08)}
#${CARD_ID} .mf-user{min-width:0;font-size:15px;font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#${CARD_ID} .mf-actions{display:flex;align-items:center;gap:2px;flex:0 0 auto}
#${CARD_ID} .mf-site{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:28px;height:28px;border-radius:7px;color:#52525b;text-decoration:none}
#${CARD_ID} .mf-site:hover{background:#f4f4f5;color:#18181b}
#${CARD_ID} .mf-site svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
#${CARD_ID} .mf-meta{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:8px;margin:9px 0;padding:7px 10px;border-radius:8px;background:#fafafa;white-space:nowrap;overflow:hidden}
#${CARD_ID} .mf-meta-item{display:inline-flex;align-items:baseline;gap:2px;min-width:0;color:#71717a;font-weight:400}
#${CARD_ID} .mf-meta-item:first-child{justify-self:start}
#${CARD_ID} .mf-meta-item:last-child{justify-self:end;max-width:100%;overflow:hidden;text-overflow:ellipsis}
#${CARD_ID} .mf-meta-item span:last-child{color:#27272a;font-weight:400}
#${CARD_ID} .mf-meta-date{justify-self:center;color:#27272a;font-weight:400}
#${CARD_ID} .mf-caption{color:#71717a;white-space:nowrap;font-weight:400}
#${CARD_ID} .mf-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;padding:9px 0;border-top:1px solid rgba(0,0,0,.08)}
#${CARD_ID} .mf-stat{display:block;padding:6px 4px;border-radius:8px;background:#fafafa;text-align:center;font-weight:400;color:inherit;text-decoration:none}
#${CARD_ID} a.mf-stat{cursor:pointer}
#${CARD_ID} a.mf-stat:hover{background:#f4f4f5}
#${CARD_ID} .mf-stat-value{display:block;font-size:14px;line-height:1.2;color:#27272a;font-weight:400}
#${CARD_ID} .mf-stat span{display:block;margin-top:2px;color:#71717a;font-size:11px}
#${CARD_ID} .mf-badge-area{display:flex;align-items:flex-start;gap:9px;padding-top:9px;border-top:1px solid rgba(0,0,0,.08)}
#${CARD_ID} .mf-badge-list{display:flex;flex:1;flex-wrap:wrap;gap:4px;min-height:18px}
#${CARD_ID} .mf-badge-list img{display:block;width:18px;height:18px;border-radius:50%;object-fit:contain;background:#f4f4f5}
#${CARD_ID} .mf-empty{color:#a1a1aa}
#${CARD_ID} .mf-loading,#${CARD_ID} .mf-error{padding:6px 2px;color:#71717a}
html.dark #${CARD_ID}{border-color:rgba(255,255,255,.12);background:#18181b;color:#f4f4f5;box-shadow:0 12px 32px rgba(0,0,0,.42)}
html.dark #${CARD_ID} .mf-top,html.dark #${CARD_ID} .mf-stats,html.dark #${CARD_ID} .mf-badge-area{border-color:rgba(255,255,255,.1)}
html.dark #${CARD_ID} .mf-user,html.dark #${CARD_ID} .mf-meta-item span:last-child,html.dark #${CARD_ID} .mf-meta-date,html.dark #${CARD_ID} .mf-stat-value{color:#f4f4f5}
html.dark #${CARD_ID} .mf-site{color:#d4d4d8}html.dark #${CARD_ID} .mf-site:hover,html.dark #${CARD_ID} .mf-stat,html.dark #${CARD_ID} .mf-meta{background:#27272a}
html.dark #${CARD_ID} a.mf-stat:hover{background:#3f3f46}
html.dark #${CARD_ID} .mf-badge-list img{background:#27272a}
`;
(document.head || document.documentElement).append(style);
}
function getRouteKey() {
return `${location.pathname}${location.search}${location.hash}`;
}
function isMemberLink(link) {
if (!(link instanceof HTMLAnchorElement)) return false;
try {
return memberPath.test(new URL(link.href, location.origin).pathname);
} catch {
return false;
}
}
function getAvatarNode(link) {
if (!(link instanceof Element)) return null;
return link.querySelector(':scope > [data-slot="avatar"], :scope [data-slot="avatar"]');
}
function getListAvatarLink(target) {
if (!(target instanceof Element)) return null;
const avatarPart = target.closest('[data-slot="avatar"], [data-slot="avatar-image"], [data-slot="avatar-fallback"]');
const avatarLink = avatarPart?.closest('a[href]');
if (isMemberLink(avatarLink) && getAvatarNode(avatarLink)) return avatarLink;
const link = target.closest('a[href]');
return isMemberLink(link) && getAvatarNode(link) ? link : null;
}
function sameMemberLink(a, b) {
return !!a && !!b && a.href === b.href;
}
function onPointerOver(event) {
if (card.contains(event.target)) return;
const link = getListAvatarLink(event.target);
if (!link) return;
const relatedLink = getListAvatarLink(event.relatedTarget);
if (sameMemberLink(link, relatedLink)) return;
const member = getMember(link);
const avatar = getAvatarNode(link);
if (!member || !avatar) return;
if (activeHover?.anchor === avatar && activeHover.key === member.key) {
clearTimeout(hideTimer);
return;
}
startHover(link, avatar, member);
}
function onPointerOut(event) {
if (!activeHover || card.contains(event.target)) return;
const link = getListAvatarLink(event.target);
if (!sameMemberLink(link, activeHover.link)) return;
if (card.contains(event.relatedTarget)) return;
const relatedLink = getListAvatarLink(event.relatedTarget);
if (sameMemberLink(relatedLink, link)) return;
closeCard();
}
function getMember(anchor) {
const url = new URL(anchor.href, location.origin);
const matched = url.pathname.match(memberPath);
if (!matched) return null;
const username = decodeURIComponent(matched[1]);
if (!username) return null;
return { key: url.pathname, username, url: url.href };
}
function startHover(link, anchor, member) {
clearTimeout(hideTimer);
clearTimeout(hoverTimer);
activeController?.abort();
activeController = null;
const current = { link, anchor, routeKey: getRouteKey(), ...member, id: ++sequence, loading: false };
activeHover = current;
const cached = getCached(current.key);
if (cached) {
renderProfile(cached);
revealNear(anchor);
return;
}
// 只有指针稳定停留后才发请求,避免扫过头像时下载多个完整成员页面。
hoverTimer = window.setTimeout(() => {
if (activeHover !== current || current.id !== sequence || current.routeKey !== getRouteKey()) return;
current.loading = true;
renderLoading(current);
revealNear(anchor);
activeController?.abort();
activeController = new AbortController();
const controller = activeController;
fetchProfile(current, controller.signal)
.then(profile => {
if (activeHover !== current || current.id !== sequence || current.routeKey !== getRouteKey() || !profile) return;
renderProfile(profile);
revealNear(anchor);
})
.catch(error => {
if (error?.name === 'AbortError') return;
if (activeHover === current && current.id === sequence) {
renderError();
revealNear(anchor);
}
})
.finally(() => {
current.loading = false;
if (activeController === controller) activeController = null;
});
}, HOVER_INTENT_DELAY);
}
function closeCard(immediate = false) {
activeHover = null;
sequence += 1;
clearTimeout(hideTimer);
clearTimeout(hoverTimer);
activeController?.abort();
activeController = null;
if (positionFrame) {
cancelAnimationFrame(positionFrame);
positionFrame = 0;
}
if (immediate) {
card.classList.remove('mf-visible');
return;
}
hideTimer = window.setTimeout(() => card.classList.remove('mf-visible'), HIDE_DELAY);
}
function resetForRouteChange() {
closeCard(true);
if (!card.isConnected) (document.body || document.documentElement).append(card);
}
function installRouteListeners() {
const wrap = name => {
const original = history[name];
history[name] = function (...args) {
const result = original.apply(this, args);
resetForRouteChange();
requestAnimationFrame(resetForRouteChange);
return result;
};
};
wrap('pushState');
wrap('replaceState');
window.addEventListener('popstate', () => {
resetForRouteChange();
requestAnimationFrame(resetForRouteChange);
}, { passive: true });
window.addEventListener('hashchange', () => {
resetForRouteChange();
requestAnimationFrame(resetForRouteChange);
}, { passive: true });
window.addEventListener('pageshow', resetForRouteChange, { passive: true });
document.addEventListener('visibilitychange', () => {
if (document.hidden) closeCard(true);
}, { passive: true });
}
function loadProfileCache() {
try {
const stored = JSON.parse(sessionStorage.getItem(CACHE_STORAGE_KEY) || '[]');
const now = Date.now();
const entries = Array.isArray(stored) ? stored.filter(item =>
Array.isArray(item) && item[0] && item[1]?.savedAt && now - item[1].savedAt < CACHE_TTL
).slice(-CACHE_LIMIT) : [];
return new Map(entries);
} catch {
return new Map();
}
}
function saveProfileCache() {
clearTimeout(cacheSaveTimer);
cacheSaveTimer = window.setTimeout(() => {
try {
sessionStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify([...profileCache.entries()]));
} catch {
// 存储不可用时仅使用当前内存缓存,不影响悬停功能。
}
}, 250);
}
function getCached(key) {
const cached = profileCache.get(key);
if (!cached) return null;
if (Date.now() - cached.savedAt >= CACHE_TTL) {
profileCache.delete(key);
saveProfileCache();
return null;
}
profileCache.delete(key);
profileCache.set(key, cached);
return cached.profile;
}
function putCached(key, profile) {
if (profileCache.has(key)) profileCache.delete(key);
profileCache.set(key, { profile, savedAt: Date.now() });
if (profileCache.size > CACHE_LIMIT) profileCache.delete(profileCache.keys().next().value);
saveProfileCache();
}
function fetchProfile(member, signal) {
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT);
const combinedSignal = AbortSignal.any([signal, timeout]);
const request = fetch(member.url, {
credentials: 'same-origin',
headers: { Accept: 'text/html' },
signal: combinedSignal,
})
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
})
.then(html => parseProfile(html, member))
.then(profile => {
putCached(member.key, profile);
return profile;
});
return request;
}
function parseProfile(html, member) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const h1 = doc.querySelector('h1');
const profileRoot = h1?.closest('main') || doc.querySelector('main') || doc.body;
const bodyText = normalize(profileRoot?.textContent || '');
const username = normalize(h1?.textContent || member.username) || member.username;
const rank = bodyText.match(rankPattern)?.[1] || '';
const joined = formatJoinDate(bodyText.match(joinPattern));
const stats = {
posts: findStat(doc, '帖子'),
replies: findStat(doc, '回复'),
equipment: findStat(doc, '装备'),
};
const headerRoot = findHeaderRoot(h1);
// 个人网站只取头部公开字段;社交链接中的 GitHub、LinkedIn 等不再误作个人网站。
const website = findHeaderWebsite(h1, headerRoot);
const location = findPublicLocation(headerRoot, h1);
const badges = extractBadges(doc);
return { username, rank, joined, location, stats, website, badges, memberUrl: member.url };
}
function normalize(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function formatJoinDate(match) {
if (!match) return '';
const [, year, month, day] = match;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}加入`;
}
function findStat(doc, label) {
const pattern = new RegExp(`^${label}\\s*(\\d+(?:\\+)?)$`);
for (const button of doc.querySelectorAll('button')) {
const matched = normalize(button.textContent).match(pattern);
if (matched) return matched[1];
}
return '';
}
function findHeaderRoot(h1) {
let node = h1;
for (let depth = 0; node && depth < 7; depth += 1, node = node.parentElement) {
const text = normalize(node.textContent);
if (joinPattern.test(text) && /帖子\s*\d+/.test(text) && /回复\s*\d+/.test(text)) return node;
}
return h1?.parentElement?.parentElement || null;
}
function firstExternalUrl(root) {
if (!root) return '';
for (const anchor of root.querySelectorAll('a[href]')) {
const url = safeExternalUrl(anchor.href);
if (url) return url;
}
return '';
}
function findHeaderWebsite(h1, headerRoot) {
// parsedDoc 的节点必须与 parsedDoc.body 比较,不能与当前页面 document.body 比较。
if (headerRoot) {
const website = firstExternalUrl(headerRoot);
if (website) return website;
}
const parsedBody = h1?.ownerDocument?.body;
for (let node = h1; node && node !== parsedBody; node = node.parentElement) {
if (!joinPattern.test(normalize(node.textContent))) continue;
const website = firstExternalUrl(node);
if (website) return website;
}
return '';
}
function findPublicLocation(headerRoot, h1) {
// 当前个人页用地图图标表达地区,没有“地区”文字标签。
let node = h1;
for (let depth = 0; node && depth < 7; depth += 1, node = node.parentElement) {
const mapIcon = node.querySelector('svg.lucide-map-pin, svg[class*="lucide-map-pin"]');
const iconValue = normalize(mapIcon?.parentElement?.querySelector('span')?.textContent);
if (iconValue) return iconValue;
}
if (!headerRoot) return '';
const labelPattern = /^(?:地区|所在地|位置)$/;
for (const label of headerRoot.querySelectorAll('span,div,p')) {
if (!labelPattern.test(normalize(label.textContent))) continue;
const value = normalize(label.nextElementSibling?.textContent || label.parentElement?.lastElementChild?.textContent);
if (value && value !== normalize(label.textContent)) return value;
}
const text = normalize(headerRoot.textContent);
const matched = text.match(/(?:地区|所在地|位置)\s*[::]\s*([^\s·•|]{1,40})/);
return matched?.[1] || '';
}
function safeExternalUrl(raw) {
try {
const url = new URL(raw, location.origin);
return (url.protocol === 'https:' || url.protocol === 'http:') && url.origin !== location.origin ? url.href : '';
} catch {
return '';
}
}
function extractBadges(doc) {
const heading = [...doc.querySelectorAll('h1,h2,h3,h4,h5,h6')]
.find(node => normalize(node.textContent) === '徽章');
const section = heading?.parentElement?.parentElement || doc;
const seen = new Set();
const badges = [];
for (const image of section.querySelectorAll('img[src*="/badges/"]')) {
const src = image.getAttribute('src') || '';
if (!src || seen.has(src)) continue;
seen.add(src);
badges.push({ src: new URL(src, location.origin).href, name: normalize(image.alt) || '徽章' });
if (badges.length === BADGE_LIMIT) break;
}
return badges;
}
function renderLoading(member) {
const preview = document.createDocumentFragment();
const top = makeNode('div', 'mf-top');
top.append(makeNode('div', 'mf-user', member.username));
preview.append(top, makeNode('div', 'mf-loading', '正在读取公开资料…'));
card.replaceChildren(preview);
}
function renderError() {
card.replaceChildren(makeNode('div', 'mf-error', '公开资料暂时无法读取'));
}
function renderProfile(profile) {
const fragment = document.createDocumentFragment();
const top = makeNode('div', 'mf-top');
top.append(makeNode('div', 'mf-user', profile.username));
const actions = makeNode('div', 'mf-actions');
const about = document.createElement('a');
about.className = 'mf-site';
about.href = memberTabUrl(profile.memberUrl, 'about');
about.title = '查看个人简介';
about.setAttribute('aria-label', '查看个人简介');
about.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3"></circle><path d="M5.5 19a6.5 6.5 0 0 1 13 0"></path><rect x="3" y="3" width="18" height="18" rx="3"></rect></svg>';
actions.append(about);
if (profile.website) {
const website = document.createElement('a');
website.className = 'mf-site';
website.href = profile.website;
website.target = '_blank';
website.rel = 'noopener noreferrer';
website.title = '打开个人网站';
website.setAttribute('aria-label', '打开个人网站');
website.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"></path></svg>';
actions.append(website);
}
top.append(actions);
fragment.append(top);
const meta = makeNode('div', 'mf-meta');
appendMeta(meta, 'ID', profile.rank);
if (profile.joined) meta.append(makeNode('span', 'mf-meta-date', profile.joined));
if (profile.location) appendMeta(meta, '', profile.location);
fragment.append(meta);
const stats = makeNode('div', 'mf-stats');
appendStat(stats, '帖子', profile.stats.posts, memberTabUrl(profile.memberUrl, 'posts'));
appendStat(stats, '回复', profile.stats.replies, memberTabUrl(profile.memberUrl, 'comments'));
appendStat(stats, '装备', profile.stats.equipment, memberTabUrl(profile.memberUrl, 'gears'));
fragment.append(stats);
const badgeArea = makeNode('div', 'mf-badge-area');
badgeArea.append(makeNode('span', 'mf-caption', '徽章'));
const badgeList = makeNode('div', 'mf-badge-list');
if (profile.badges.length) {
for (const badge of profile.badges) {
const image = document.createElement('img');
image.src = badge.src;
image.alt = badge.name;
image.title = badge.name;
image.loading = 'lazy';
image.decoding = 'async';
badgeList.append(image);
}
} else {
badgeList.append(makeNode('span', 'mf-empty', '暂无公开徽章'));
}
badgeArea.append(badgeList);
fragment.append(badgeArea);
card.replaceChildren(fragment);
}
function appendMeta(parent, label, value) {
if (!value) return;
const item = makeNode('span', 'mf-meta-item');
item.append(makeNode('span', '', label), makeNode('span', '', value));
parent.append(item);
}
function memberTabUrl(memberUrl, tab) {
try {
const url = new URL(memberUrl, location.origin);
url.search = '';
url.searchParams.set('tab', tab);
return url.href;
} catch {
return '';
}
}
function appendStat(parent, label, value, href) {
const item = href ? document.createElement('a') : document.createElement('div');
item.className = 'mf-stat';
if (href) {
item.href = href;
item.title = `查看该用户的${label}`;
item.setAttribute('aria-label', `查看该用户的${label}`);
}
item.append(makeNode('span', 'mf-stat-value', value || '—'), makeNode('span', '', label));
parent.append(item);
}
function makeNode(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
}
function revealNear(anchor) {
clearTimeout(hideTimer);
card.classList.add('mf-visible');
schedulePosition(anchor);
}
function schedulePosition(anchor) {
if (positionFrame) return;
positionFrame = requestAnimationFrame(() => {
positionFrame = 0;
positionCard(anchor);
});
}
function positionCard(anchor) {
if (!anchor?.isConnected || !card.classList.contains('mf-visible')) {
if (activeHover?.anchor === anchor) closeCard(true);
return;
}
const gap = 10;
const margin = 8;
const source = anchor.getBoundingClientRect();
const box = card.getBoundingClientRect();
let left = source.right + gap;
let top = source.top;
if (left + box.width > innerWidth - margin) left = source.left - box.width - gap;
if (left < margin) {
left = Math.min(Math.max(margin, source.left), innerWidth - box.width - margin);
top = source.bottom + gap;
}
top = Math.max(margin, Math.min(top, innerHeight - box.height - margin));
card.style.left = `${Math.round(left)}px`;
card.style.top = `${Math.round(top)}px`;
}
// 单头像模式:只响应成员头像,不响应用户名文字;站内换页时重置旧状态。
installRouteListeners();
document.addEventListener('pointerover', onPointerOver, true);
document.addEventListener('pointerout', onPointerOut, true);
window.addEventListener('scroll', () => {
if (activeHover) schedulePosition(activeHover.anchor);
}, { passive: true });
window.addEventListener('resize', () => {
if (activeHover) schedulePosition(activeHover.anchor);
}, { passive: true });
})();