// ==UserScript== // @name 抖音粉丝自动移除 // @namespace Violentmonkey Scripts // @version 0.3.0 // @changelog 新增悬浮窗收缩功能,支持将面板收缩为右侧居中的小球,并优化了 UI 视觉效果; // @description 在网页版抖音粉丝弹窗中自动移除粉丝,支持暂停、批量处理和跳过相互关注 // @author Frequenk // @license GPL-3.0 License // @match *://www.douyin.com/user/self* // @grant none // @run-at document-idle // @downloadURL https://update.greasyfork.org/scripts/576245/%E6%8A%96%E9%9F%B3%E7%B2%89%E4%B8%9D%E8%87%AA%E5%8A%A8%E7%A7%BB%E9%99%A4.user.js // @updateURL https://update.greasyfork.org/scripts/576245/%E6%8A%96%E9%9F%B3%E7%B2%89%E4%B8%9D%E8%87%AA%E5%8A%A8%E7%A7%BB%E9%99%A4.meta.js // ==/UserScript== (() => { // src/index.js (function() { "use strict"; const STORAGE_KEY = "douyin-fans-remover-settings"; const PANEL_ID = "dy-fans-remover-panel"; const CONTAINER_SELECTOR = '[data-e2e="user-fans-container"]'; const FOOTER_SELECTOR = '[data-e2e="user-fans-footer"]'; const SELF_PATH_PREFIX = "/user/self"; const DEFAULT_SETTINGS = { delayMs: 0, batchSize: 5, collapsed: false }; const state = { running: false, busy: false, timer: null, settings: loadSettings(), status: "\u7B49\u5F85\u5F00\u59CB", processed: 0, skipped: 0, skippedKeys: /* @__PURE__ */ new Set() }; init(); function init() { injectStyle(); mountPanelIfNeeded(); installRouteWatcher(); window.addEventListener("beforeunload", stopLoop); } function loadSettings() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return { ...DEFAULT_SETTINGS }; const parsed = JSON.parse(raw); return { ...DEFAULT_SETTINGS, ...parsed }; } catch (error) { console.warn("[\u6296\u97F3\u7C89\u4E1D\u81EA\u52A8\u79FB\u9664] \u8BFB\u53D6\u914D\u7F6E\u5931\u8D25", error); return { ...DEFAULT_SETTINGS }; } } function saveSettings() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.settings)); } function injectStyle() { if (document.getElementById(`${PANEL_ID}-style`)) return; const style = document.createElement("style"); style.id = `${PANEL_ID}-style`; style.textContent = ` #${PANEL_ID} { position: fixed; top: 50%; right: 24px; transform: translateY(-50%); z-index: 2147483647; width: 280px; padding: 16px; border-radius: 16px; background: rgba(22, 24, 35, 0.94); color: #fff; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4); font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; backdrop-filter: blur(12px); transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); user-select: none; } #${PANEL_ID}.dyfr-collapsed { width: 48px; height: 48px; padding: 0; right: 12px; border-radius: 24px; cursor: pointer; background: linear-gradient(135deg, #fe2c55, #ff6b6b); box-shadow: 0 8px 24px rgba(254, 44, 85, 0.4); display: flex; align-items: center; justify-content: center; overflow: hidden; } #${PANEL_ID} * { box-sizing: border-box; } #${PANEL_ID} .dyfr-ball { display: none; color: #fff; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2)); } #${PANEL_ID}.dyfr-collapsed .dyfr-ball { display: flex; align-items: center; justify-content: center; } #${PANEL_ID}.dyfr-collapsed > *:not(.dyfr-ball) { display: none; } #${PANEL_ID} .dyfr-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } #${PANEL_ID} .dyfr-title { margin: 0; font-size: 15px; font-weight: 700; background: linear-gradient(to right, #fff, rgba(255,255,255,0.7)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } #${PANEL_ID} .dyfr-collapse-btn { padding: 4px 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 6px; color: rgba(255, 255, 255, 0.5); transition: all 0.2s; font-size: 12px; border: 1px solid rgba(255, 255, 255, 0.1); } #${PANEL_ID} .dyfr-collapse-btn:hover { background: rgba(255, 255, 255, 0.15); color: #fff; border-color: rgba(255, 255, 255, 0.2); } #${PANEL_ID} .dyfr-row { margin-bottom: 12px; } #${PANEL_ID} .dyfr-actions { display: flex; gap: 8px; } #${PANEL_ID} button { width: 100%; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 10px; outline: none; font: inherit; cursor: pointer; padding: 10px 12px; color: #fff; background: rgba(255, 255, 255, 0.08); transition: all 0.2s; } #${PANEL_ID} button:hover:not(:disabled) { background: rgba(255, 255, 255, 0.15); } #${PANEL_ID} button.dyfr-primary { background: linear-gradient(135deg, #fe2c55, #ff6b6b); border-color: transparent; font-weight: 600; } #${PANEL_ID} button.dyfr-primary:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); } #${PANEL_ID} button:disabled { cursor: not-allowed; opacity: 0.4; } #${PANEL_ID} .dyfr-help { margin-top: 8px; color: rgba(255, 255, 255, 0.45); font-size: 12px; line-height: 1.4; } #${PANEL_ID} .dyfr-status { padding: 10px; border-radius: 10px; background: rgba(255, 255, 255, 0.05); color: rgba(255, 255, 255, 0.8); white-space: pre-line; font-size: 12px; border: 1px solid rgba(255, 255, 255, 0.03); } `; document.head.appendChild(style); } function mountPanelIfNeeded() { if (!isSelfPage()) { removePanel(); return; } mountPanel(); } function mountPanel() { if (document.getElementById(PANEL_ID)) return; const panel = document.createElement("div"); panel.id = PANEL_ID; panel.innerHTML = `
\u7C89\u4E1D\u79FB\u9664
\u6536\u8D77
\u7B49\u5F85\u5F00\u59CB
\u81EA\u52A8\u8DF3\u8FC7\u201C\u76F8\u4E92\u5173\u6CE8\u201D\u3002\u5E73\u53F0\u6BCF\u5929\u4E0A\u9650\u79FB\u9664\u7EA6 2000 \u4EBA\u3002
`; document.body.appendChild(panel); const startButton = panel.querySelector('[data-action="start"]'); const pauseButton = panel.querySelector('[data-action="pause"]'); const collapseBtn = panel.querySelector('[data-action="collapse"]'); startButton.addEventListener("click", (e) => { e.stopPropagation(); state.running = true; setStatus("\u5DF2\u5F00\u59CB"); renderPanel(); scheduleNext(0); }); pauseButton.addEventListener("click", (e) => { e.stopPropagation(); stopLoop(); setStatus("\u5DF2\u6682\u505C"); renderPanel(); }); collapseBtn.addEventListener("click", (e) => { e.stopPropagation(); state.settings.collapsed = true; saveSettings(); renderPanel(); }); panel.addEventListener("click", () => { if (state.settings.collapsed) { state.settings.collapsed = false; saveSettings(); renderPanel(); } }); renderPanel(); } function removePanel() { const panel = document.getElementById(PANEL_ID); if (panel) { panel.remove(); } stopLoop(); } function renderPanel() { const panel = document.getElementById(PANEL_ID); if (!panel) return; if (state.settings.collapsed) { panel.classList.add("dyfr-collapsed"); } else { panel.classList.remove("dyfr-collapsed"); } const startButton = panel.querySelector('[data-action="start"]'); const pauseButton = panel.querySelector('[data-action="pause"]'); const statusNode = panel.querySelector(`#${PANEL_ID}-status`); if (startButton) startButton.disabled = state.running; if (pauseButton) pauseButton.disabled = !state.running; if (statusNode) { statusNode.textContent = `${state.status} \u5DF2\u79FB\u9664: ${state.processed} | \u5DF2\u8DF3\u8FC7: ${state.skipped}`; } } function setStatus(message) { state.status = message; renderPanel(); } function stopLoop() { state.running = false; state.busy = false; if (state.timer) { clearTimeout(state.timer); state.timer = null; } } function scheduleNext(delay) { if (!state.running) return; if (!isSelfPage()) { removePanel(); return; } if (state.timer) clearTimeout(state.timer); state.timer = setTimeout(runOnce, Math.max(0, delay)); } async function runOnce() { if (!state.running || state.busy) return; if (!isSelfPage()) { removePanel(); return; } state.busy = true; try { const context = getActiveContext(); if (!context) { setStatus("\u7B49\u5F85\u6253\u5F00\u201C\u7C89\u4E1D\u201D\u5F39\u7A97"); scheduleNext(1e3); return; } const visibleTargets = getVisibleTargets(context); if (!visibleTargets.length) { setStatus("\u5F53\u524D\u6CA1\u6709\u53EF\u5904\u7406\u7684\u7C89\u4E1D\u9879\uFF0C\u53EF\u80FD\u5DF2\u5230\u5E95\u90E8"); scheduleNext(1e3); return; } const visibleMutualTargets = visibleTargets.filter((target) => isMutualFollowRow(target.row)); if (visibleMutualTargets.length > 0) { state.skipped += countNewSkippedKeys(visibleMutualTargets.map((target) => target.key).filter(Boolean)); } const removedNames = []; const batchSize = clampBatchSize(state.settings.batchSize); let lastFailure = null; const batchTargets = getBatchRemovableTargets(getVisibleTargets(context), batchSize); if (batchTargets.length > 0) { const batchResult = await removeTargetsSimultaneously(batchTargets); if (batchResult.removedNames.length > 0) { removedNames.push(...batchResult.removedNames); state.processed += batchResult.removedNames.length; } if (!batchResult.ok && batchResult.message) { lastFailure = batchResult.message; } } if (removedNames.length > 0) { const mutualText = visibleMutualTargets.length > 0 ? `\uFF1B\u5F53\u524D\u53EF\u89C1\u533A\u5DF2\u8DF3\u8FC7 ${visibleMutualTargets.length} \u4E2A\u76F8\u4E92\u5173\u6CE8` : ""; setStatus(`\u672C\u8F6E\u5DF2\u79FB\u9664 ${removedNames.length} \u4EBA\uFF1A${removedNames.join("\u3001")}${mutualText}`); scheduleNext(0); return; } if (lastFailure) { setStatus(lastFailure); scheduleNext(800); return; } if (visibleMutualTargets.length > 0) { setStatus(`\u5F53\u524D\u53EF\u89C1\u533A\u57DF\u53EA\u6709\u201C\u76F8\u4E92\u5173\u6CE8\u201D\u6216\u6CA1\u6709\u53EF\u5220\u76EE\u6807\uFF0C\u8BF7\u624B\u52A8\u6EDA\u52A8\u540E\u7EE7\u7EED`); scheduleNext(600); return; } setStatus("\u5F53\u524D\u53EF\u89C1\u533A\u57DF\u6CA1\u6709\u53EF\u79FB\u9664\u7684\u76EE\u6807"); scheduleNext(600); } catch (error) { console.error("[\u6296\u97F3\u7C89\u4E1D\u81EA\u52A8\u79FB\u9664] \u6267\u884C\u5931\u8D25", error); setStatus(`\u6267\u884C\u5F02\u5E38\uFF1A${error.message || error}`); scheduleNext(1200); } finally { state.busy = false; } } function getActiveContext() { const container = document.querySelector(CONTAINER_SELECTOR); const footer = document.querySelector(FOOTER_SELECTOR); if (!container || !footer || !isVisible(container)) return null; const scrollContainer = findScrollContainer(container); return { container, scrollContainer }; } function getVisibleTargets(context) { const rows = getRows(context.container); if (!rows.length) return []; const viewport = context.scrollContainer.getBoundingClientRect(); return rows.map((row) => buildRowTarget(row)).filter(Boolean).filter((target) => isRowVisible(target.row, viewport)).sort((a, b) => a.row.getBoundingClientRect().top - b.row.getBoundingClientRect().top); } function getFirstRemovableTarget(visibleTargets) { return visibleTargets.find((target) => !isMutualFollowRow(target.row)) || null; } function getBatchRemovableTargets(visibleTargets, batchSize) { return visibleTargets.filter((target) => !isMutualFollowRow(target.row)).slice(0, batchSize); } function getRows(container) { const directRows = Array.from(container.querySelectorAll(".i5U4dMnB")).filter((row) => findRemoveButton(row)); if (directRows.length) return directRows; const fallbackRows = []; const seen = /* @__PURE__ */ new Set(); for (const button of container.querySelectorAll("button")) { if (!isRemoveButton(button)) continue; const row = findRowFromButton(button, container); if (!row || seen.has(row)) continue; seen.add(row); fallbackRows.push(row); } return fallbackRows; } function buildRowTarget(row) { const removeButton = findRemoveButton(row); if (!removeButton) return null; return { row, removeButton, name: getUserName(row), key: getUserKey(row) }; } function findRemoveButton(scope) { return Array.from(scope.querySelectorAll("button")).find((button) => isRemoveButton(button)) || null; } function isRemoveButton(button) { if (!button || !isVisible(button)) return false; const text = normalizeText(button.innerText || button.textContent || ""); return text.includes("\u79FB\u9664"); } function findRowFromButton(button, container) { let node = button; while (node && node !== container && node !== document.body) { if (node.nodeType === 1) { const hasUserAnchor = Array.from(node.querySelectorAll('a[href*="/user/"]')).some((anchor) => normalizeText(anchor.innerText || "").length > 0); if (hasUserAnchor) return node; } node = node.parentElement; } return null; } function getUserName(row) { const anchors = Array.from(row.querySelectorAll('a[href*="/user/"]')); for (const anchor of anchors) { const text = normalizeText(anchor.innerText || anchor.textContent || ""); if (text) return text; } const image = row.querySelector("img[alt]"); if (image) { return normalizeText((image.getAttribute("alt") || "").replace(/头像$/, "")); } return ""; } function getUserKey(row) { const anchor = row.querySelector('a[href*="/user/"]'); if (anchor) { const href = anchor.getAttribute("href") || anchor.href || ""; const normalizedHref = normalizeText(href); if (normalizedHref) return normalizedHref; } return getUserName(row) || ""; } function findScrollContainer(container) { let node = container; while (node && node !== document.body) { const style = window.getComputedStyle(node); const overflowY = style.overflowY; if ((overflowY === "auto" || overflowY === "scroll") && node.scrollHeight > node.clientHeight + 20) { return node; } node = node.parentElement; } return container; } function isRowVisible(row, viewportRect) { if (!isVisible(row)) return false; const rect = row.getBoundingClientRect(); return rect.bottom > viewportRect.top + 4 && rect.top < viewportRect.bottom - 4; } async function ensureConfirmVisible(removeButton, row) { const existing = findVisibleConfirm(row) || findVisibleConfirm(removeButton); if (existing) { return { ok: true, state: "confirm", confirmButton: existing }; } safeClick(removeButton); const result = await waitFor(() => { const confirmButton = findVisibleConfirm(row) || findVisibleConfirm(removeButton); if (confirmButton) { return { ok: true, state: "confirm", confirmButton }; } if (isRowRemoved(row)) { return { ok: true, state: "removed" }; } return null; }, 1800, 80); return result || { ok: false, state: "missing" }; } function findVisibleConfirm(scope) { if (!scope) return null; const candidates = scope.querySelectorAll("span, div, button"); for (const node of candidates) { const text = normalizeText(node.innerText || node.textContent || ""); if (text === "\u786E\u8BA4\u79FB\u9664" && isVisible(node)) { return node; } } return null; } async function removeTarget(target) { const name = target.name || "\u672A\u77E5\u7528\u6237"; setStatus(`\u51C6\u5907\u79FB\u9664\uFF1A${name}`); const prepareResult = await ensureConfirmVisible(target.removeButton, target.row); if (!prepareResult.ok) { return { ok: false, message: `\u672A\u627E\u5230\u786E\u8BA4\u6309\u94AE\uFF1A${name}` }; } if (prepareResult.state === "removed") { return { ok: true }; } const confirmButton = prepareResult.confirmButton || findVisibleConfirm(target.row) || findVisibleConfirm(target.removeButton); if (!confirmButton) { if (isRowRemoved(target.row)) { return { ok: true }; } return { ok: false, message: `\u786E\u8BA4\u6309\u94AE\u4E0D\u53EF\u89C1\uFF1A${name}` }; } safeClick(confirmButton); await waitForRowRemoval(target.row, 1200, 80); return { ok: true }; } async function removeTargetsSimultaneously(targets) { const targetNames = targets.map((target) => target.name || "\u672A\u77E5\u7528\u6237"); setStatus(`\u51C6\u5907\u540C\u65F6\u79FB\u9664\uFF1A${targetNames.join("\u3001")}`); const directRemoved = []; const pendingTargets = []; for (const target of targets) { if (isRowRemoved(target.row)) { directRemoved.push(target.name || "\u672A\u77E5\u7528\u6237"); continue; } const existingConfirm = findVisibleConfirm(target.row) || findVisibleConfirm(target.removeButton); if (!existingConfirm) { safeClick(target.removeButton); } pendingTargets.push(target); } if (pendingTargets.length > 0) { await sleep(160); } const preparedResults = await Promise.all( pendingTargets.map((target) => waitForTargetReady(target)) ); const removedNames = [...directRemoved]; const confirmTargets = []; let lastFailure = null; for (const result of preparedResults) { if (result.state === "removed") { removedNames.push(result.target.name || "\u672A\u77E5\u7528\u6237"); continue; } if (result.state === "confirm") { confirmTargets.push(result); continue; } lastFailure = `\u672A\u627E\u5230\u786E\u8BA4\u6309\u94AE\uFF1A${result.target.name || "\u672A\u77E5\u7528\u6237"}`; } for (const result of confirmTargets) { safeClick(result.confirmButton); } if (confirmTargets.length > 0) { await Promise.all(confirmTargets.map((result) => waitForRowRemoval(result.target.row, 1200, 80))); removedNames.push(...confirmTargets.map((result) => result.target.name || "\u672A\u77E5\u7528\u6237")); } const uniqueRemovedNames = dedupeStrings(removedNames); return { ok: uniqueRemovedNames.length > 0 || !lastFailure, removedNames: uniqueRemovedNames, message: uniqueRemovedNames.length > 0 ? null : lastFailure }; } async function waitForTargetReady(target) { if (isRowRemoved(target.row)) { return { target, state: "removed" }; } const existingConfirm = findVisibleConfirm(target.row) || findVisibleConfirm(target.removeButton); if (existingConfirm) { return { target, state: "confirm", confirmButton: existingConfirm }; } const result = await waitFor(() => { const confirmButton = findVisibleConfirm(target.row) || findVisibleConfirm(target.removeButton); if (confirmButton) { return { target, state: "confirm", confirmButton }; } if (isRowRemoved(target.row)) { return { target, state: "removed" }; } return null; }, 900, 60); return result || { target, state: "missing" }; } function safeClick(element) { if (!element) return; if (typeof element.focus === "function") { element.focus({ preventScroll: true }); } if (typeof element.click === "function") { element.click(); } } function isMutualFollowRow(row) { return Array.from(row.querySelectorAll("button")).some((button) => { const text = normalizeText(button.innerText || button.textContent || ""); return text.includes("\u76F8\u4E92\u5173\u6CE8"); }); } function clampBatchSize(value) { const numeric = Number(value) || 1; return Math.min(5, Math.max(1, Math.round(numeric))); } function isSelfPage() { return window.location.pathname.startsWith(SELF_PATH_PREFIX); } function installRouteWatcher() { let lastHref = window.location.href; const handleRouteChange = () => { const currentHref = window.location.href; if (currentHref === lastHref) return; lastHref = currentHref; mountPanelIfNeeded(); }; const { pushState, replaceState } = window.history; window.history.pushState = function(...args) { const result = pushState.apply(this, args); queueMicrotask(handleRouteChange); return result; }; window.history.replaceState = function(...args) { const result = replaceState.apply(this, args); queueMicrotask(handleRouteChange); return result; }; window.addEventListener("popstate", handleRouteChange); setInterval(handleRouteChange, 1e3); } function countNewSkippedKeys(keys) { let count = 0; for (const key of keys) { if (!key || state.skippedKeys.has(key)) continue; state.skippedKeys.add(key); count += 1; } return count; } function dedupeStrings(items) { const seen = /* @__PURE__ */ new Set(); const result = []; for (const item of items) { if (!item || seen.has(item)) continue; seen.add(item); result.push(item); } return result; } function normalizeText(text) { return String(text || "").replace(/\s+/g, " ").trim(); } function isVisible(element) { if (!element || !element.isConnected) return false; const style = window.getComputedStyle(element); if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) { return false; } const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function waitFor(checker, timeoutMs, intervalMs) { return new Promise((resolve) => { const start = Date.now(); const timer = setInterval(() => { const result = checker(); if (result) { clearInterval(timer); resolve(result); return; } if (Date.now() - start >= timeoutMs) { clearInterval(timer); resolve(null); } }, intervalMs); }); } async function waitForRowRemoval(row, timeoutMs, intervalMs) { const removed = await waitFor(() => isRowRemoved(row) ? true : null, timeoutMs, intervalMs); return Boolean(removed); } function isRowRemoved(row) { return !row || !row.isConnected || !document.contains(row); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } })(); })();