// ==UserScript==
// @name Weibo FollowFlow
// @name:zh-CN Weibo FollowFlow - 微博信息流关注/取关助手
// @namespace https://github.com/haorui-lab/weibo-follow-flow
// @version 0.7.2
// @description Add minimalist native-style Follow / Unfollow icon button directly to the left of the top-right dropdown menu on Weibo cards with 1-click action (optional 2-step) and instant state sync.
// @description:zh-CN 在微博卡片右上角下拉菜单左侧增加无缝原生风格关注/取关 (微点/加号) 按钮,支持单次点击直接取关与全流同步。
// @author haorui
// @homepageURL https://github.com/haorui-lab/weibo-follow-flow
// @supportURL https://github.com/haorui-lab/weibo-follow-flow/issues
// @downloadURL https://raw.githubusercontent.com/haorui-lab/weibo-follow-flow/main/weibo-follow-flow.user.js
// @updateURL https://raw.githubusercontent.com/haorui-lab/weibo-follow-flow/main/weibo-follow-flow.user.js
// @match https://weibo.com/*
// @match https://www.weibo.com/*
// @match https://d.weibo.com/*
// @match https://s.weibo.com/*
// @icon https://weibo.com/favicon.ico
// @run-at document-start
// @grant none
// ==/UserScript==
(function () {
'use strict';
// ==========================================
// Configuration
// ==========================================
const CONFIG = {
twoStepUnfollow: false, // 是否启用二次确认取关(默认 false:单次点击直接取关,手感丝滑顺畅)
confirmTimeoutMs: 3000,
scanDebounceMs: 50,
maxActionWaitMs: 800
};
const RESERVED_ROUTES = new Set([
'home',
'hot',
'newhot',
'friends',
'fav',
'search',
'status',
'detail',
'tv',
'video',
'chat',
'message',
'setting',
'logout',
'login',
'signup',
'help',
'all',
'special',
'top',
'category',
'feed',
'unread',
'trending'
]);
function isProfilePage(urlOrPath) {
const path = (urlOrPath || (typeof window !== 'undefined' ? window.location.pathname : ''))
.replace(/^https?:\/\/[^\/]+/, '')
.split('?')[0]
.split('#')[0];
const segments = path.split('/').filter(Boolean);
if (segments.length === 0) return false;
const firstSegment = segments[0].toLowerCase();
if (RESERVED_ROUTES.has(firstSegment)) return false;
// Direct /u/UID format: e.g. /u/1642634100
if (firstSegment === 'u' && segments.length >= 2 && /^\d+$/.test(segments[1])) {
return true;
}
// Direct /n/Nickname format: e.g. /n/haorui
if (firstSegment === 'n' && segments.length >= 2) {
return true;
}
// Custom domain: e.g. /kaifulee or /kaifulee/home
// Single segment or with profile tabs (not /status/ or /detail/)
if (segments.length === 1 && /^[a-zA-Z0-9_-]{1,30}$/.test(firstSegment)) {
return true;
}
return false;
}
function extractUidFromProfileUrl(urlOrHref) {
if (!urlOrHref) return null;
const match = String(urlOrHref).match(/\/u\/(\d+)/);
if (match) return match[1];
return null;
}
function removeAllFollowIcons() {
const containers = document.querySelectorAll('.weibo-followflow-container');
for (const container of containers) {
if (container._unsubscribe) {
try {
container._unsubscribe();
} catch (e) {}
}
container.remove();
}
}
// Delicate Micro-UI Icons tailored to Weibo's native aesthetics
const ICONS = {
// Delicate thin Plus (+) for Follow
follow: `
`,
// Subtle, fine micro-dot (•) for Following
following: `
`,
// Delicate thin Minus (−) for Confirming Unfollow / Hover
unfollowConfirm: `
`,
// Delicate native-like Spinner
loading: `
`,
// Delicate Failed cross
failed: `
`
};
// ==========================================
// 1. Follow State Manager (Single Source of Truth)
// ==========================================
class FollowStateManager {
constructor() {
// Map
this.cache = new Map();
// Map
this.nameToUid = new Map();
// Map 用户主动交互的时间戳锁,防异步陈旧数据覆写
this.userActionLocks = new Map();
this.listeners = new Set();
}
get(uidOrName) {
if (!uidOrName) return null;
const key = String(uidOrName).toLowerCase();
if (this.cache.has(key)) {
return this.cache.get(key);
}
const mappedUid = this.nameToUid.get(key);
if (mappedUid && this.cache.has(mappedUid)) {
return this.cache.get(mappedUid);
}
return null;
}
set(uid, state, isUserAction = false) {
if (!uid) return;
const strUid = String(uid);
const now = Date.now();
// 若为网络被动劫持或轮询扫描更新,但该博主在 15 秒内有用户主动操作记录:
// 坚决不接受被动陈旧的 conflicting 关注状态,保护用户的主动操作
if (!isUserAction) {
const lastAction = this.userActionLocks.get(strUid);
if (lastAction && (now - lastAction < 15000)) {
if (state.following !== undefined) {
const current = this.cache.get(strUid);
if (current && current.following !== undefined && current.following !== state.following) {
return current;
}
}
}
} else {
this.userActionLocks.set(strUid, now);
}
const prev = this.cache.get(strUid) || {};
const updated = {
...prev,
...state,
uid: strUid,
updatedAt: now
};
this.cache.set(strUid, updated);
if (updated.screen_name) {
this.nameToUid.set(updated.screen_name.toLowerCase(), strUid);
}
this.notify(strUid, updated);
return updated;
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
notify(uid, state) {
for (const listener of this.listeners) {
try {
listener(uid, state);
} catch (err) {
console.warn('[Weibo-FollowFlow] Error in listener:', err);
}
}
}
}
const stateManager = new FollowStateManager();
// ==========================================
// 2. Comprehensive Network Interceptor (Fetch + XHR)
// ==========================================
function extractUsersFromWeiboData(data) {
if (!data || typeof data !== 'object') return;
// Case 1: Direct user object with idstr or id and following
if (data.idstr || data.id) {
const uid = String(data.idstr || data.id);
const hasFollowing = data.following !== undefined || data.is_following !== undefined;
if (hasFollowing && (data.screen_name || data.name || data.avatar_hd || data.profile_url)) {
const following = Boolean(data.following ?? data.is_following);
stateManager.set(uid, {
uid,
screen_name: data.screen_name || data.name || '',
following,
follow_me: Boolean(data.follow_me)
});
}
}
// Case 2: Status with user object
if (data.user && typeof data.user === 'object') {
extractUsersFromWeiboData(data.user);
}
// Case 3: Retweeted status
if (data.retweeted_status && typeof data.retweeted_status === 'object') {
extractUsersFromWeiboData(data.retweeted_status);
}
// Case 4: Array of statuses or items
if (Array.isArray(data)) {
for (const item of data) {
extractUsersFromWeiboData(item);
}
} else {
// Recurse into common nested wrappers: statuses, data, cards, items
for (const key of Object.keys(data)) {
if (typeof data[key] === 'object' && data[key] !== null) {
extractUsersFromWeiboData(data[key]);
}
}
}
}
function initNetworkInterceptor() {
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
if (!win || win.__weibo_follow_network_hooked) return;
win.__weibo_follow_network_hooked = true;
// 1. Hook Fetch
const originalFetch = win.fetch;
if (originalFetch) {
win.fetch = async function (...args) {
const response = await originalFetch.apply(this, args);
try {
const url = String(args[0]?.url || args[0] || '');
// 排除关注/取关 API 的直接响应,避免其陈旧用户快照污染 stateManager,锁定由 executeFollowToggle 统一权威管理
if (url.includes('/ajax/friendships/create') || url.includes('/ajax/friendships/destory')) {
return response;
}
if (url.includes('/ajax/') || url.includes('/statuses/') || url.includes('/feed/')) {
const clone = response.clone();
clone.json().then(data => {
if (data) extractUsersFromWeiboData(data);
}).catch(() => {});
}
} catch (err) {}
return response;
};
}
// 2. Hook XMLHttpRequest
const originalOpen = win.XMLHttpRequest?.prototype?.open;
const originalSend = win.XMLHttpRequest?.prototype?.send;
if (originalOpen && originalSend) {
win.XMLHttpRequest.prototype.open = function (...args) {
this._weibo_url = String(args[1] || '');
return originalOpen.apply(this, args);
};
win.XMLHttpRequest.prototype.send = function (...args) {
this.addEventListener('load', function () {
try {
if (this._weibo_url) {
if (this._weibo_url.includes('/ajax/friendships/create') || this._weibo_url.includes('/ajax/friendships/destory')) {
return;
}
if (this._weibo_url.includes('/ajax/') || this._weibo_url.includes('/statuses/') || this._weibo_url.includes('/feed/')) {
const data = JSON.parse(this.responseText);
if (data) extractUsersFromWeiboData(data);
}
}
} catch (e) {}
});
return originalSend.apply(this, args);
};
}
}
// ==========================================
// 3. Current User & Vue Component Inspection
// ==========================================
let cachedCurrentUserUid = null;
function getCurrentUserUid() {
if (cachedCurrentUserUid) return cachedCurrentUserUid;
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
if (win.$CONFIG && win.$CONFIG.uid) {
cachedCurrentUserUid = String(win.$CONFIG.uid);
return cachedCurrentUserUid;
}
// Check top navigation avatar or profile link
const profileLink = document.querySelector('header a[href*="/u/"], nav a[href*="/u/"], a[class*="woo-avatar"][href*="/u/"]');
if (profileLink) {
const uid = extractUidFromProfileUrl(profileLink.getAttribute('href'));
if (uid) {
cachedCurrentUserUid = uid;
return uid;
}
}
return null;
}
function searchVueComponentForAuthor(element) {
if (!element) return null;
let comp = element.__vueParentComponent;
let depth = 0;
while (comp && depth < 15) {
const props = comp.props || {};
const setup = comp.setupState || {};
const candidates = [
props.article,
props.status,
props.mblog,
props.item,
props.card,
setup.article,
setup.status,
setup.mblog,
setup.item
];
for (const item of candidates) {
if (item && typeof item === 'object') {
const user = item.user || (item.idstr && item.screen_name ? item : null);
if (user) {
const uid = String(user.idstr || user.id || '');
if (uid) {
return {
uid,
screen_name: user.screen_name || '',
following: user.following !== undefined ? Boolean(user.following) : undefined
};
}
}
}
}
comp = comp.parent;
depth++;
}
return null;
}
function extractAuthorFromWeiboCard(cardElement) {
if (!cardElement) return null;
// 1. Check Vue component hierarchy
const fromVue = searchVueComponentForAuthor(cardElement);
if (fromVue && fromVue.uid) {
return fromVue;
}
// 2. Check author links in header / head-info / avatar (excluding retweet area)
const header = cardElement.querySelector('header, div[class*="Feed_header"]');
const searchScope = header || cardElement;
const links = searchScope.querySelectorAll('header a[href*="/u/"], a[class*="name"][href*="/u/"], a[class*="head-info"][href*="/u/"], a[class*="woo-avatar"][href*="/u/"], a[href*="/u/"]');
for (const link of links) {
if (link.closest('.wbpro-feed-ogText, [class*="Feed_retweet"], [class*="retweet"]')) {
continue;
}
const href = link.getAttribute('href') || '';
const uid = extractUidFromProfileUrl(href);
if (uid) {
const text = link.textContent?.trim() || '';
return {
uid,
screen_name: text,
following: undefined
};
}
}
return null;
}
// ==========================================
// 4. Action Executor (Weibo Native AJAX API)
// ==========================================
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getCookie(name) {
const match = document.cookie.match(new RegExp('(^|;\\s*)(' + name + ')=([^;]*)'));
return match ? decodeURIComponent(match[3]) : null;
}
async function executeFollowViaAPI(uid) {
const xsrf = getCookie('XSRF-TOKEN');
const endpoint = 'https://weibo.com/ajax/friendships/create';
const params = new URLSearchParams();
// 微博 create 接口要求必须传递 friend_uid 参数(与取关接口的 uid 不对称)
params.append('friend_uid', uid);
params.append('uid', uid);
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
'x-xsrf-token': xsrf || '',
'x-requested-with': 'XMLHttpRequest'
},
body: params.toString(),
credentials: 'include'
});
if (!response.ok) return false;
const data = await response.json().catch(() => null);
if (data) {
if (data.ok === 0 || data.error || data.error_code || data.errno) {
return false;
}
if (data.ok === 1 || data.ok === true || data.id || data.user) {
return true;
}
}
return false;
} catch (e) {
return false;
}
}
async function executeUnfollowViaAPI(uid) {
const xsrf = getCookie('XSRF-TOKEN');
// Weibo internal API uses intentionally misspelled "destory"
const endpoint = 'https://weibo.com/ajax/friendships/destory';
const params = new URLSearchParams();
params.append('uid', uid);
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
'x-xsrf-token': xsrf || '',
'x-requested-with': 'XMLHttpRequest'
},
body: params.toString(),
credentials: 'include'
});
if (!response.ok) return false;
const data = await response.json().catch(() => null);
if (data) {
if (data.ok === 0 || data.error || data.error_code || data.errno) {
return false;
}
if (data.ok === 1 || data.ok === true) {
return true;
}
}
return false;
} catch (e) {
return false;
}
}
async function executeFollowToggle(cardElement, uid, targetFollowState) {
const isFollow = targetFollowState === true;
// 优先走静默底层 API,彻底规避官方原生的侵入式大弹窗打扰
let success = isFollow
? await executeFollowViaAPI(uid)
: await executeUnfollowViaAPI(uid);
// 容灾托底:若 API 调用因网络/风控未能成功,且存在原生按钮,则尝试触发原生关注按钮
if (!success && isFollow && cardElement) {
const nativeFollowBtn = cardElement.querySelector('button[class*="woo-button"][class*="primary"], div[action-type="follow"], [class*="head-info"] button');
if (nativeFollowBtn && nativeFollowBtn.textContent && nativeFollowBtn.textContent.includes('关注') && !nativeFollowBtn.textContent.includes('已关注')) {
nativeFollowBtn.click();
await sleep(300);
success = true;
}
}
if (success) {
// 记录用户主动交互(isUserAction = true),开启时间戳保护锁,防止被异步陈旧网络响应翻转
stateManager.set(uid, { following: isFollow }, true);
showToast(isFollow ? '关注成功' : '已取消关注');
return true;
}
return false;
}
// ==========================================
// 5. UI Component & Styling
// ==========================================
function injectStyles() {
if (document.getElementById('weibo-followflow-styles')) return;
const style = document.createElement('style');
style.id = 'weibo-followflow-styles';
style.textContent = `
/* Container inside Weibo card header */
.weibo-followflow-container {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: center !important;
height: 24px;
user-select: none;
margin-right: 8px;
line-height: 1;
cursor: pointer;
/* 视觉对齐补偿 (Optical Alignment Compensation):
下拉箭头 (∨) 为倒三角形,其人眼感知的视觉重心在图形中上方。
向上微调 2px 即可消除几何居中带来的“下沉感”,达到与下拉箭头的完美视觉对齐。 */
transform: translateY(-2px);
}
/* Base Icon Button */
.weibo-followflow-icon-btn {
background: transparent !important;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
outline: none;
display: inline-flex;
align-items: center;
justify-content: center;
color: #939393;
transition: color 0.15s ease, opacity 0.15s ease;
position: relative;
line-height: 1;
}
/* Pure transparent wrapper, ZERO background circles */
.weibo-followflow-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent !important;
background-color: transparent !important;
border: none;
padding: 2px;
transition: color 0.15s ease, transform 0.15s ease, opacity 0.15s ease;
}
.weibo-followflow-icon-wrapper svg {
display: block;
}
/* NOT_FOLLOWING (+): Delicate gray plus, Weibo orange on hover (NO circle background) */
.weibo-followflow-icon-btn.wb-state-follow {
color: #939393;
}
.weibo-followflow-icon-btn.wb-state-follow:hover {
color: #ff8200;
}
/* FOLLOWING (•): Subtle soft dot (matches Weibo sub-text color) */
.weibo-followflow-icon-btn.wb-state-following {
color: #939393;
opacity: 0.85;
}
.weibo-followflow-icon-btn.wb-state-following .wb-icon-normal {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
}
.weibo-followflow-icon-btn.wb-state-following .wb-icon-hover {
display: none;
align-items: center;
justify-content: center;
line-height: 0;
}
.weibo-followflow-icon-btn.wb-state-following:hover .wb-icon-normal {
display: none;
}
.weibo-followflow-icon-btn.wb-state-following:hover .wb-icon-hover {
display: inline-flex;
}
.weibo-followflow-icon-btn.wb-state-following:hover {
color: #f4212e;
opacity: 1;
}
/* CONFIRMING UNFOLLOW (−): Red warning glyph with breathing animation, ZERO circle background */
.weibo-followflow-icon-btn.wb-state-confirm {
color: #f4212e;
}
.weibo-followflow-icon-btn.wb-state-confirm .weibo-followflow-icon-wrapper {
animation: weibo-follow-pulse 1s infinite alternate;
}
@keyframes weibo-follow-pulse {
from { transform: scale(1); opacity: 0.8; }
to { transform: scale(1.25); opacity: 1; }
}
/* LOADING SPINNER */
.weibo-followflow-icon-btn.wb-state-loading {
cursor: wait;
opacity: 0.7;
pointer-events: none;
}
.weibo-follow-spinner {
animation: weibo-spin 0.85s linear infinite;
}
@keyframes weibo-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* FAILED */
.weibo-followflow-icon-btn.wb-state-failed {
color: #f4212e;
}
/* Floating Toast Notification matching Weibo dark frosted glass style */
.weibo-followflow-toast {
position: fixed;
top: 76px;
left: 50%;
transform: translate(-50%, -10px);
background: rgba(30, 30, 30, 0.9);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: #ffffff;
font-size: 13.5px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-weight: 500;
line-height: 1.4;
padding: 7px 18px;
border-radius: 9999px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
display: inline-flex;
align-items: center;
gap: 6px;
z-index: 999999;
pointer-events: none;
opacity: 0;
transition: opacity 0.22s cubic-bezier(0.16, 1, 0.3, 1), transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
}
.weibo-followflow-toast.wb-toast-show {
opacity: 1;
transform: translate(-50%, 0);
}
.weibo-followflow-toast .wb-toast-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: #00ba7c;
}
.weibo-followflow-toast .wb-toast-text {
white-space: nowrap;
}
`;
(document.head || document.documentElement).appendChild(style);
}
let activeToast = null;
let activeToastTimer = null;
function showToast(text, duration = 2000) {
if (typeof document === 'undefined') return;
if (activeToast) {
clearTimeout(activeToastTimer);
if (activeToast.parentElement) {
activeToast.parentElement.removeChild(activeToast);
}
activeToast = null;
}
const toast = document.createElement('div');
toast.className = 'weibo-followflow-toast';
toast.setAttribute('role', 'alert');
toast.innerHTML = `
${text}
`;
(document.body || document.documentElement).appendChild(toast);
activeToast = toast;
requestAnimationFrame(() => {
toast.classList.add('wb-toast-show');
});
activeToastTimer = setTimeout(() => {
toast.classList.remove('wb-toast-show');
setTimeout(() => {
if (toast.parentElement) {
toast.parentElement.removeChild(toast);
}
if (activeToast === toast) {
activeToast = null;
}
}, 250);
}, duration);
}
function createFollowIconButton(cardElement, uid, initialFollowing) {
const container = document.createElement('div');
container.className = 'weibo-followflow-container';
container.setAttribute('data-wb-author-uid', uid);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'weibo-followflow-icon-btn';
const wrapper = document.createElement('div');
wrapper.className = 'weibo-followflow-icon-wrapper';
btn.appendChild(wrapper);
container.appendChild(btn);
let currentState = initialFollowing ? 'FOLLOWING' : 'NOT_FOLLOWING';
let confirmTimer = null;
function renderUI() {
btn.className = 'weibo-followflow-icon-btn';
wrapper.innerHTML = '';
if (currentState === 'NOT_FOLLOWING') {
btn.classList.add('wb-state-follow');
wrapper.innerHTML = ICONS.follow;
btn.title = '+ 关注博主';
} else if (currentState === 'FOLLOWING') {
btn.classList.add('wb-state-following');
const normalSpan = document.createElement('span');
normalSpan.className = 'wb-icon-normal';
normalSpan.innerHTML = ICONS.following;
const hoverSpan = document.createElement('span');
hoverSpan.className = 'wb-icon-hover';
hoverSpan.innerHTML = ICONS.unfollowConfirm;
wrapper.appendChild(normalSpan);
wrapper.appendChild(hoverSpan);
btn.title = CONFIG.twoStepUnfollow ? '• 已关注 (点击进入取关确认)' : '• 已关注 (点击取消关注)';
} else if (currentState === 'CONFIRMING_UNFOLLOW') {
btn.classList.add('wb-state-confirm');
wrapper.innerHTML = ICONS.unfollowConfirm;
btn.title = '再次点击确认取消关注';
} else if (currentState === 'LOADING') {
btn.classList.add('wb-state-loading');
wrapper.innerHTML = ICONS.loading;
btn.title = '处理中...';
} else if (currentState === 'FAILED') {
btn.classList.add('wb-state-failed');
wrapper.innerHTML = ICONS.failed;
btn.title = '操作失败';
}
}
renderUI();
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (currentState === 'NOT_FOLLOWING') {
currentState = 'LOADING';
renderUI();
const success = await executeFollowToggle(cardElement, uid, true);
if (success) {
currentState = 'FOLLOWING';
} else {
currentState = 'FAILED';
renderUI();
setTimeout(() => {
if (currentState === 'FAILED') {
currentState = 'NOT_FOLLOWING';
renderUI();
}
}, 1500);
return;
}
renderUI();
} else if (currentState === 'FOLLOWING') {
if (CONFIG.twoStepUnfollow) {
currentState = 'CONFIRMING_UNFOLLOW';
renderUI();
if (confirmTimer) clearTimeout(confirmTimer);
confirmTimer = setTimeout(() => {
if (currentState === 'CONFIRMING_UNFOLLOW') {
currentState = 'FOLLOWING';
renderUI();
}
}, CONFIG.confirmTimeoutMs);
} else {
// 单次点击直接执行取关 (1-Click Direct Unfollow)
currentState = 'LOADING';
renderUI();
const success = await executeFollowToggle(cardElement, uid, false);
if (success) {
currentState = 'NOT_FOLLOWING';
} else {
currentState = 'FAILED';
renderUI();
setTimeout(() => {
if (currentState === 'FAILED') {
currentState = 'FOLLOWING';
renderUI();
}
}, 1500);
return;
}
renderUI();
}
} else if (currentState === 'CONFIRMING_UNFOLLOW') {
if (confirmTimer) clearTimeout(confirmTimer);
currentState = 'LOADING';
renderUI();
const success = await executeFollowToggle(cardElement, uid, false);
if (success) {
currentState = 'NOT_FOLLOWING';
} else {
currentState = 'FAILED';
renderUI();
setTimeout(() => {
if (currentState === 'FAILED') {
currentState = 'FOLLOWING';
renderUI();
}
}, 1500);
return;
}
renderUI();
}
});
btn.addEventListener('mousedown', (e) => e.stopPropagation());
btn.addEventListener('mouseup', (e) => e.stopPropagation());
const unsubscribe = stateManager.subscribe((changedUid, state) => {
if (changedUid !== uid) return;
if (confirmTimer) {
clearTimeout(confirmTimer);
confirmTimer = null;
}
currentState = state.following ? 'FOLLOWING' : 'NOT_FOLLOWING';
renderUI();
});
container._unsubscribe = unsubscribe;
container._updateState = (following) => {
currentState = following ? 'FOLLOWING' : 'NOT_FOLLOWING';
renderUI();
};
return container;
}
// ==========================================
// 6. Placement (Top-Right Header, Inside Right Dropdown Container)
// ==========================================
function insertFollowIcon(cardElement, buttonContainer) {
const header = cardElement.querySelector('header, div[class*="Feed_header"]');
if (!header) {
cardElement.insertBefore(buttonContainer, cardElement.firstChild);
return;
}
// Hide native +关注 button in header if present to avoid dual buttons
const nativeBtn = header.querySelector('button[class*="woo-button"][class*="primary"], [class*="_followbtn"], div[action-type="follow"]');
if (nativeBtn && nativeBtn !== buttonContainer) {
if (!nativeBtn.textContent || nativeBtn.textContent.includes('关注')) {
nativeBtn.style.display = 'none';
}
}
// Look for more dropdown wrap inside header
const popWrap = header.querySelector('div[class*="woo-pop-wrap"], span.woo-pop-ctrl, i[class*="angleDown" i], i[class*="angle-down" i], [title="更多"], [title="负反馈"]');
if (popWrap) {
// Find the direct child of header that wraps the dropdown (the right flex container)
let rightFlex = popWrap;
while (rightFlex.parentElement && rightFlex.parentElement !== header) {
rightFlex = rightFlex.parentElement;
}
if (rightFlex && rightFlex.parentElement === header) {
// Ensure rightFlex centers its children vertically
rightFlex.classList.add('woo-box-alignCenter');
rightFlex.style.display = 'flex';
rightFlex.style.alignItems = 'center';
buttonContainer.style.alignSelf = 'center';
// Find the element inside rightFlex right before which we insert
let popDirect = popWrap;
while (popDirect.parentElement && popDirect.parentElement !== rightFlex) {
popDirect = popDirect.parentElement;
}
rightFlex.insertBefore(buttonContainer, popDirect);
return;
}
}
// Fallback: append inside header's last child
if (header.lastElementChild) {
header.lastElementChild.style.display = 'flex';
header.lastElementChild.style.alignItems = 'center';
header.lastElementChild.insertBefore(buttonContainer, header.lastElementChild.firstChild);
return;
}
header.appendChild(buttonContainer);
}
async function processCard(cardElement) {
if (!cardElement || !cardElement.isConnected) return;
if (isProfilePage()) {
const existing = cardElement.querySelector('.weibo-followflow-container');
if (existing) {
if (existing._unsubscribe) {
try { existing._unsubscribe(); } catch (e) {}
}
existing.remove();
}
return;
}
const author = extractAuthorFromWeiboCard(cardElement);
if (!author || !author.uid) return;
const currentUid = getCurrentUserUid();
if (currentUid && author.uid === currentUid) {
const existing = cardElement.querySelector('.weibo-followflow-container');
if (existing) existing.remove();
return;
}
const existingContainer = cardElement.querySelector('.weibo-followflow-container');
if (existingContainer) {
if (existingContainer.getAttribute('data-wb-author-uid') === author.uid) {
// 同步缓存中最新的关注状态至已有容器,防卡片状态脱节
const cached = stateManager.get(author.uid);
if (cached && cached.following !== undefined && existingContainer._updateState) {
existingContainer._updateState(cached.following);
}
return;
}
if (existingContainer._unsubscribe) existingContainer._unsubscribe();
existingContainer.remove();
}
// Determine initial follow state
let following = false;
let resolved = false;
// A. Check State Manager cache
const cached = stateManager.get(author.uid);
if (cached && cached.following !== undefined) {
following = cached.following;
resolved = true;
}
// B. Check Vue component state
if (!resolved && author.following !== undefined) {
following = author.following;
resolved = true;
stateManager.set(author.uid, { following, screen_name: author.screen_name });
}
// C. Check native card UI (if native +关注 button is present, author is not followed)
if (!resolved) {
const nativeFollowBtn = cardElement.querySelector('button[class*="woo-button"][class*="primary"], div[action-type="follow"]');
if (nativeFollowBtn && nativeFollowBtn.textContent && nativeFollowBtn.textContent.includes('关注') && !nativeFollowBtn.textContent.includes('已关注')) {
following = false;
resolved = true;
stateManager.set(author.uid, { following: false, screen_name: author.screen_name });
}
}
const buttonContainer = createFollowIconButton(cardElement, author.uid, following);
insertFollowIcon(cardElement, buttonContainer);
}
let scanScheduled = false;
function scheduleScan() {
if (scanScheduled) return;
scanScheduled = true;
setTimeout(() => {
scanScheduled = false;
if (isProfilePage()) {
removeAllFollowIcons();
return;
}
// Query Weibo feed cards (top-level only, never inside retweeted container)
const cards = document.querySelectorAll('article, div[class*="Feed_wrap"]');
const processed = new Set();
for (const card of cards) {
if (card.closest('.wbpro-feed-ogText, [class*="Feed_retweet"], [class*="retweet"]')) {
continue;
}
processed.add(card);
processCard(card);
}
// If no article / Feed_wrap found, fallback to content containers
if (processed.size === 0) {
const fallbacks = document.querySelectorAll('div[class*="wbpro-feed-content"], div[action-type="feed_list_item"]');
for (const fb of fallbacks) {
if (fb.closest('.wbpro-feed-ogText, [class*="Feed_retweet"], [class*="retweet"]')) {
continue;
}
let target = fb;
if (fb.classList.contains('wbpro-feed-content') && fb.closest('article, div[class*="Feed_wrap"]')) {
target = fb.closest('article, div[class*="Feed_wrap"]');
}
if (!processed.has(target)) {
processed.add(target);
processCard(target);
}
}
}
}, CONFIG.scanDebounceMs);
}
function initObserver() {
const observer = new MutationObserver((mutations) => {
let shouldScan = false;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches && (node.matches('article, div[class*="Feed_wrap"], div[class*="wbpro-feed-content"]') || node.querySelector('article, div[class*="Feed_wrap"]'))) {
shouldScan = true;
break;
}
}
}
}
if (shouldScan) break;
}
if (shouldScan) {
scheduleScan();
}
});
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true
});
}
// ==========================================
// 7. SPA Navigation Support
// ==========================================
function initSPAHandler() {
const handleUrlChange = () => {
cachedCurrentUserUid = null;
if (isProfilePage()) {
removeAllFollowIcons();
}
scheduleScan();
};
const originalPushState = history.pushState;
history.pushState = function (...args) {
const res = originalPushState.apply(this, args);
handleUrlChange();
return res;
};
const originalReplaceState = history.replaceState;
history.replaceState = function (...args) {
const res = originalReplaceState.apply(this, args);
handleUrlChange();
return res;
};
window.addEventListener('popstate', handleUrlChange);
}
// ==========================================
// 8. Bootstrap
// ==========================================
function bootstrap() {
initNetworkInterceptor();
initSPAHandler();
const startDOM = () => {
injectStyles();
initObserver();
scheduleScan();
window.addEventListener('resize', () => {
scheduleScan();
}, { passive: true });
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startDOM);
} else {
startDOM();
}
}
bootstrap();
})();