Custom Bilibili Auto Follow/Unfollow

A script to automatically follow/unfollow on Bilibili with enhanced UI and controls.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Custom Bilibili Auto Follow/Unfollow
// @namespace    https://github.com/IGCrystal/Custom-Bilibili-Auto-Follow-Unfollow/
// @version      5.9.3
// @description  A script to automatically follow/unfollow on Bilibili with enhanced UI and controls.
// @author       Larch4, IGCrystal
// @match        https://space.bilibili.com/*
// @grant        none
// @license      GNU Affero General Public License v3.0
// ==/UserScript==

(function () {
    'use strict';

    const FOLLOW_INTERVAL_DEFAULT = 2000; // 关注时间间隔(默认2秒)
    const UNFOLLOW_INTERVAL_DEFAULT = 2000; // 取消关注时间间隔(默认2秒)
    const MAX_LOGS = 20; // 日志最大数量为10条
    const logQueue = []; // 存储日志的数组

    let isRunning = false;
    let useRandomInterval = true; // 是否使用随机间隔
    let followUnfollowTimeout = null; // 关注/取消关注的定时器
    let modalCheckInterval = null; // 弹窗检查的定时器
    let captchaModalInterval = null; //
    let modalCheckIntervalIfNeeded = null; //验证码弹窗检查定时器
    let networkCheckInterval = null; // 网络状态检测的定时器
    let errorCheckInterval = null;
    let followInterval = FOLLOW_INTERVAL_DEFAULT;
    let unfollowInterval = UNFOLLOW_INTERVAL_DEFAULT;
    let previousState = null;
    let checkButtonIfRuning = null;
    let checkIntervaling = 3000; // 每3秒检查一次
    let maxWaitTime = 54000; // 最大等待时间为12秒
    let elapsedTime = 0;

    // 检测页面是否是刷新加载
    let isPageReload = performance.navigation.type === 1;

    // 确保页面刷新后创建面板并根据情况恢复状态
    window.addEventListener('load', () => {
        // 检查面板是否已经存在,避免重复创建
        if (!document.getElementById('bilibili-auto-follow-panel')) {
            createPanel(); // 每次页面加载时创建面板
        }

        // 仅当页面是刷新时,才恢复脚本运行状态
        if (isPageReload && localStorage.getItem('scriptRunning') === 'true') {
            isRunning = true;
            startAutoFollowUnfollow();
        } else {
            // 页面重新打开时,脚本默认不运行
            isRunning = false;
            localStorage.removeItem('scriptRunning');
        }
    });

    // 在页面刷新或关闭前保存当前的运行状态
    window.addEventListener('beforeunload', () => {
        localStorage.setItem('scriptRunning', isRunning.toString());
    });


    function createPanel() {
        const panel = document.createElement('div');
        panel.id = 'bilibili-auto-follow-panel';
        panel.style.cssText = `
            position: fixed; bottom: 20px; right: 20px;
            background-color: #fff; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
            color: #333; padding: 15px; border-radius: 10px; z-index: 2147483699;
            display: flex; flex-direction: column; align-items: stretch; width: 240px;
            font-family: Arial, sans-serif; transition: max-height 0.3s ease, opacity 0.3s ease;
            overflow: hidden; max-height: 400px;
            opacity: 1;
        `;

        panel.innerHTML = `
<div id="panelHeader" 
     style="
       display: flex; 
       align-items: center; 
       justify-content: space-between; 
       margin-bottom: 10px;
     "
>
  <!-- 左侧:标题 -->
  <div style="
    font-size: 16px;
    font-weight: bold;
    color: #00a1d6;
    margin-bottom: 0;
  ">
    Bilibili 自动关注脚本
  </div>

  <!-- 右侧:展开/收缩按钮 -->
  <button
    id="foldButton"
    style="
      background-color: #00a1d6;
      color: #fff;
      border: none;
      padding: 6px 10px;
      border-radius: 6px;
      cursor: pointer;
      font-size: 14px;
      transition: background-color 0.3s;
      margin-left: 10px;
    "
  >
    展开
  </button>
</div>

<!-- 第二行:开始/暂停按钮 + 状态文字 -->
<div 
  style="
    display: flex; 
    justify-content: space-between; 
    align-items: center; 
    margin-bottom: 5px;
  "
>
  <button 
    id="toggleButton" 
    style="
      flex: 1; 
      background-color: #00a1d6; 
      color: #fff; 
      border: none; 
      padding: 8px 12px;
      border-radius: 6px; 
      cursor: pointer; 
      font-size: 14px; 
      transition: background-color 0.3s;
    "
  >
    开始
  </button>
  <span 
    id="statusText" 
    style="
      flex: 1; 
      margin-left: 95px; 
      font-size: 14px; 
      color: #666;
    "
  >
    未运行
  </span>
</div>

<!-- 第三行:可折叠区域 -->
<div 
  id="additionalContent" 
  style="
    max-height: 0; 
    opacity: 0; 
    overflow: hidden; 
    transition: max-height 0.5s ease, opacity 0.5s ease;
  "
>
  <div style="margin-bottom: 20px; display: flex; flex-direction: column;">
    <label>关注间隔(秒):</label>
    <input 
      id="followIntervalInput" 
      type="number" 
      min="1" 
      value="${FOLLOW_INTERVAL_DEFAULT / 1000}" 
      style="
        width: 95%;
        padding: 5px;
        margin-top: 5px;
        border: 1px solid #ddd;
        border-radius: 4px;
      "
    />
  </div>
  <div style="margin-bottom: 20px; display: flex; flex-direction: column;">
    <label>取消关注间隔(秒):</label>
    <input 
      id="unfollowIntervalInput" 
      type="number" 
      min="1" 
      value="${UNFOLLOW_INTERVAL_DEFAULT / 1000}" 
      style="
        width: 95%;
        padding: 5px;
        margin-top: 5px;
        border: 1px solid #ddd;
        border-radius: 4px;
      "
    />
  </div>
  <div style="display: flex; align-items: center; margin-bottom: 20px;">
    <input 
      id="useRandomInterval" 
      type="checkbox" 
      checked 
      style="margin-right: 9px;"
    />
    <label 
      for="useRandomInterval" 
      style="
        font-size: 14px; 
        color: #333; 
        cursor: pointer;
      "
    >
      使用随机时间间隔
    </label>
  </div>
  <div 
    id="logContainer" 
    style="
      max-height: 120px;
      overflow-y: auto;
      border: 1px solid #ddd;
      padding: 5px;
      margin-top: 20px;
      border-radius: 4px;
      font-size: 12px;
      color: #333;
      background-color: #f9f9f9;
    "
  ></div>
</div>

<!-- 错误消息提示区 -->
<div 
  id="errorMessage" 
  style="
    color: red; 
    font-size: 12px; 
    margin-top: 20px;
  "
></div>
        `;

        document.body.appendChild(panel);
        makePanelDraggable(panel);

        const toggleButton = panel.querySelector('#toggleButton');
        toggleButton.removeEventListener('click', toggleScript); // 先移除,再添加
        toggleButton.addEventListener('click', toggleScript);

        const foldButton = panel.querySelector('#foldButton');
        const additionalContent = panel.querySelector('#additionalContent');
        let isFolded = true;

        foldButton.addEventListener('click', () => {
            if (isFolded) {
                additionalContent.style.maxHeight = '400px';
                additionalContent.style.opacity = '1';
                foldButton.textContent = '收缩';
            } else {
                additionalContent.style.maxHeight = '0';
                additionalContent.style.opacity = '0';
                foldButton.textContent = '展开';
            }
            isFolded = !isFolded;
        });

        document.getElementById('toggleButton').addEventListener('click', toggleScript);
        document.getElementById('useRandomInterval').addEventListener('change', toggleRandomInterval);

        panel.querySelector('#followIntervalInput').addEventListener('input', (e) => {
            followInterval = parseInt(e.target.value, 10) * 1000;
        });
        panel.querySelector('#unfollowIntervalInput').addEventListener('input', (e) => {
            unfollowInterval = parseInt(e.target.value, 10) * 1000;
        });

        return {
            toggleButton,
            statusText: panel.querySelector('#statusText'),
            errorMessage: panel.querySelector('#errorMessage'),
            logContainer: panel.querySelector('#logContainer')
        };
    }

    function makePanelDraggable(panel) {
        let isDragging = false;
        let offsetX = 0;
        let offsetY = 0;

        panel.addEventListener('mousedown', (e) => {
            if (e.target.tagName === 'BUTTON' || e.target.tagName === 'SPAN' || e.target.tagName === 'INPUT') return;

            isDragging = true;
            offsetX = e.clientX - panel.getBoundingClientRect().left;
            offsetY = e.clientY - panel.getBoundingClientRect().top;
            e.preventDefault();

            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);
        });

        function onMouseMove(e) {
            if (isDragging) {
                panel.style.right = 'auto';
                panel.style.bottom = 'auto';
                panel.style.left = `${e.clientX - offsetX}px`;
                panel.style.top = `${e.clientY - offsetY}px`;
            }
        }

        function onMouseUp() {
            if (isDragging) {
                isDragging = false;
                document.removeEventListener('mousemove', onMouseMove);
                document.removeEventListener('mouseup', onMouseUp);
            }
        }
    }

    function toggleScript() {
        if (isRunning) {
            stopAutoFollowUnfollow();
        } else {
            startAutoFollowUnfollow();
        }
        isRunning = !isRunning;
    }

    function startAutoFollowUnfollow() {
        localStorage.setItem('scriptRunning', 'true'); // 存储运行状态
        toggleFollowState(1);
        modalCheckInterval = setInterval(removeModal, 1000);
        captchaModalInterval = setInterval(geetest, 1000);
        modalCheckIntervalIfNeeded = setInterval(removeAllModalsIfNeeded, 1000);
        networkCheckInterval = setInterval(checkButtonStatus, 5000); // 每10秒检查一次
        errorCheckInterval = setInterval(checkForErrorPopup, 5000); // 每5秒检查一次弹窗
        checkButtonIfRuning = setInterval(checkButtonStatus, checkIntervaling);
        updateUI('暂停', '#f25d8e', '运行中');
    }

    function stopAutoFollowUnfollow() {
        localStorage.removeItem('scriptRunning'); // 清除运行状态
        clearTimeout(followUnfollowTimeout);
        clearInterval(modalCheckInterval);
        clearInterval(captchaModalInterval);
        clearInterval(modalCheckIntervalIfNeeded);
        clearInterval(networkCheckInterval); // 停止网络状态检测
        clearInterval(errorCheckInterval); // 停止检查
        clearInterval(checkButtonIfRuning);
        updateUI('开始', '#00a1d6', '未运行');
    }

    function removeModal() { 
        const modals = document.querySelectorAll('.modal-container');
        modals.forEach(modal => {
            const modalContent = modal.querySelector('.modal-body');
            // 检查内容是否包含 "已经关注用户,无法重复关注" 或 "-352"
            if (modalContent && (modalContent.textContent.includes('已经关注用户,无法重复关注') || modalContent.textContent.includes('-352'))) 
                {
                modal.remove(); // 仅移除符合条件的弹窗
                showErrorMessage("指定的弹窗已被移除!");
            }
        });
    }

    function geetest() {
        const captchaModal = document.querySelector('.geetest_panel, .geetest_wind');
        const myPanel = document.getElementById('bilibili-auto-follow-panel');
        if (captchaModal && myPanel) {
            captchaModal.style.zIndex = '-2147483648'; // 保持验证码在你的面板之下
            myPanel.style.zIndex = '2147483647'; // 保证你的面板在最上层
        }
    }

    function removeAllModalsIfNeeded() {
        const modals = document.querySelectorAll('.be-toast'); // 选择的容器
        modals.forEach(modal => modal.remove()); // 移除所有匹配的面板
    }    


    function checkButtonStatus() { 
        const followBtn = document.querySelector('.follow-btn-wrapper .space-follow-btn');
        let currentState;
    
        if (!followBtn) {
            // 如果页面里连这个按钮都找不到,可能是还没加载或 DOM 改动
            currentState = 'error';
        } else {
            // 读取文本
            const btnText = followBtn.textContent.trim();
            // 举例:若包含“关注”,则判定为未关注
            if (btnText.includes('关注') && !btnText.includes('已关注')) {
                currentState = '未关注';
            }
            // 若包含“已关注”,则判定为已关注
            else if (btnText.includes('已关注')) {
                currentState = '已关注';
            } 
            // 如果以上都没匹配到,就可能是 B 站又改了文案,或网络加载中
            else {
                currentState = 'unknown';
            }
        }
    
        // 下面保持你原先的逻辑不变
        if (currentState !== previousState) {
            previousState = currentState;
            elapsedTime = 0; // 状态改变,重置时间
            console.log('状态发生变化:', currentState);
        } else {
            // 状态没有变化,增加经过时间
            elapsedTime += checkIntervaling;
            logMessage(`状态“${currentState}”未变化,已等待时间: ${elapsedTime / 1000} 秒`);
            
            // 如果超过最大等待时间,触发网络错误处理
            if (elapsedTime >= maxWaitTime) {
                handleNetworkError();
            }
        }
    }
    
    
    function handleNetworkError() {
        showErrorMessage("关注按钮未按预期变化,即将刷新页面");
        // 处理网络问题
        window.location.reload(); // 自动刷新页面
    }

    function checkForErrorPopup() {
        const modals = document.querySelectorAll('.modal-container'); // 获取弹窗元素
        modals.forEach(modal => {
            if (modal) {
                const modalContent = modal.querySelector('.modal-body'); // 获取弹窗内容
                if (modalContent && (modalContent.textContent.includes('获取用户关系数据失败,网络错误') || modalContent.textContent.includes('已经关注用户,无法重复关注'))) 
                    {
                    showErrorMessage('检测到网络问题弹窗,刷新页面...');
                    window.location.reload(); // 刷新页面以解决网络问题
                }
            }
        });
    }

    function toggleRandomInterval() {
        useRandomInterval = document.getElementById('useRandomInterval').checked;
    }

    function updateUI(buttonText, buttonColor, statusTextValue) {
        const toggleButton = document.getElementById('toggleButton');
        const statusText = document.getElementById('statusText');
        toggleButton.textContent = buttonText;
        toggleButton.style.backgroundColor = buttonColor;
        statusText.textContent = statusTextValue;
    }

    function toggleFollowState(action) {
        followOrUnfollow(action);
        const nextAction = action === 0 ? 1 : 0;
        const delay = getInterval(action === 0 ? unfollowInterval : followInterval);

        followUnfollowTimeout = setTimeout(() => toggleFollowState(nextAction), delay);
    }

    function followOrUnfollow(action) {
        try {
          if (action === 1) {
            // 1 表示“关注”
            const followBtn = findFollowButton(); // 如上定义
            if (followBtn) {
              followBtn.click();
              logMessage('已进行关注');
            } else {
              showErrorMessage('未找到关注按钮');
            }
          } else {
            // 0 表示“取消关注”
            // 这里先点击“已关注”按钮,再点下拉的“取消关注”
            const followBtn = document.querySelector('.space-follow-btn');
            if (followBtn) {
              followBtn.click(); 
              setTimeout(() => {
                const menuItem = findUnfollowMenuItem();
                if (menuItem) {
                  menuItem.click();
                  logMessage('已进行取消关注');
                } else {
                  showErrorMessage('未找到“取消关注”菜单项');
                }
              }, 500);
            } else {
              showErrorMessage('未找到已关注按钮');
            }
          }
        } catch (error) {
          showErrorMessage("执行关注/取消关注时出错: " + error.message);
        }
      }
      

    function getInterval(baseInterval) {
        let interval = useRandomInterval ? baseInterval + Math.random() * 1000 : baseInterval;
        console.log(`当前间隔时间: ${interval} 毫秒`);
        return interval;
    }

    function logMessage(message) {
        const logContainer = document.getElementById('logContainer');
        const logEntry = document.createElement('div');
        logEntry.textContent = message;

        if (logQueue.length >= MAX_LOGS) {
            logQueue.shift(); // 移除最早的日志,保持日志数量为10条
            logContainer.removeChild(logContainer.firstChild); // 删除页面中的最早日志
        }
        logQueue.push(message); // 添加日志到队列

        logContainer.appendChild(logEntry);
        logContainer.scrollTop = logContainer.scrollHeight; // 滚动到最底部

        // 同时限制控制台日志数量
        console.clear(); // 每次清空控制台
        logQueue.forEach(log => console.log(log)); // 重新输出最新的日志
    }

    function showErrorMessage(message) {
        const errorMessage = document.getElementById('errorMessage');
        if (errorMessage) {
            errorMessage.textContent = message;
            setTimeout(() => {
                errorMessage.textContent = '';
            }, 3000);
        }
    }

    createPanel();
})();