1. 项目概述:为什么我们需要自动化挂网课?

作为一名常年与代码和数据打交道的程序员,我深知时间就是最宝贵的资源。无论是学生时代为了学分,还是工作后为了完成某些必要的线上培训,被动地坐在电脑前观看冗长的网课视频,都是一种效率极低的时间消耗。手动操作不仅枯燥,还容易因为走神而错过签到或答题,导致前功尽弃。这就是我决定动手用 Python 和 Selenium 来解决这个问题的初衷。

“挂网课”这个需求背后,本质上是将重复、规则明确的网页交互流程自动化。我们需要的不是去破解或攻击平台,而是模拟一个“好学生”的正常操作:登录、找到课程、播放视频、处理可能弹出的互动(如答题、确认弹窗),并在一个视频结束后自动跳转到下一个。Python 作为胶水语言,拥有极其丰富的生态库,而 Selenium 则是浏览器自动化的“瑞士军刀”,它能驱动真实的浏览器(如 Chrome)执行点击、输入、跳转等所有人工能做的操作,完美契合我们的需求。

本次实战,我选择了“经世优学”这个在线教育平台作为案例。它具有一定的代表性,其页面结构、视频播放逻辑和常见的防呆机制(如无操作检测)在很多同类平台中都能找到影子。通过这个案例,你不仅能获得一套可运行的代码,更能掌握一套通用的“侦察-分析-编码-调试”方法论,未来面对其他平台时也能举一反三。无论你是 Python 新手想找个有趣的项目练手,还是被网课所困想解放双手,这篇文章都将提供从环境搭建到代码调试的完整路径。

2. 核心思路与技术选型解析

2.1 自动化挂网课的核心逻辑拆解

在动手写一行代码之前,我们必须把“挂网课”这个模糊的需求,拆解成计算机可以理解的一系列精确步骤。这个过程就像给一个机器人编写工作手册。

首先, 流程梳理 。一个完整的挂课流程通常包括:1) 启动浏览器并打开登录页;2) 输入账号密码完成登录;3) 在课程列表或学习中心定位到目标课程;4) 进入课程章节列表;5) 循环遍历每一个视频节点:a. 点击播放,b. 监控视频播放状态(是否播放、是否卡住、是否结束),c. 处理播放过程中可能出现的弹窗(如“确认继续学习”、“随堂测验”),d. 视频结束后标记为已完成或自动播放下一个。

其次, 状态监控 。这是自动化的核心难点。我们不能简单地点一下播放按钮就放任不管。平台可能有多种防挂机机制:比如,检测页面是否处于前台(即浏览器标签页是否被激活);比如,视频播放到一定时间会弹出选择题;再比如,长时间无鼠标键盘操作会暂停播放。我们的脚本必须能感知这些状态变化并做出正确响应。

最后, 稳健性设计 。网络会波动,页面加载有时快有时慢,元素可能因为页面改版而定位不到。一个健壮的脚本必须包含异常处理、重试机制和足够的等待时间,确保在非理想环境下也能持续运行数小时而不中断。

2.2 为什么是 Python + Selenium?

面对网页自动化,有几个常见的工具选项:Selenium, Playwright, Puppeteer,以及一些桌面自动化工具如 PyAutoGUI。

我选择 Selenium 的原因主要有三点:

  1. 生态成熟,资料丰富 :Selenium 是历史最悠久的浏览器自动化工具之一,拥有最庞大的社区。无论你遇到多奇怪的问题,几乎都能在 Stack Overflow 或相关博客中找到解决方案。这对于快速开发和排查问题至关重要。
  2. 跨浏览器与语言支持 :Selenium 支持 Chrome, Firefox, Edge 等主流浏览器。虽然我们主要用 Chrome,但这份兼容性意味着代码有更好的可移植性。同时,它支持多种语言绑定,Python 的 selenium 包非常易用。
  3. 模拟真实浏览器行为 :Selenium 通过 WebDriver 协议驱动真正的浏览器内核。这意味着它产生的流量和行为与真人操作几乎无异,很难被服务器端轻易区分(当然,平台可以通过检测 WebDriver 特征来反爬,但有应对方法)。相比之下,PyAutoGUI 是模拟鼠标键盘,对前端框架复杂的现代网页控制精度不够,且无法读取页面元素状态。

为什么不选 Playwright 或 Puppeteer? 它们都是更现代、性能更好的工具,特别是 Playwright,在自动等待和跨浏览器一致性上做得非常出色。但对于这个特定场景,Selenium 的稳定性和广泛的社区支持是更大的优势。且我们的操作并不需要 Playwright 那些先进的网络拦截或移动端模拟特性。Selenium 完全够用,学习曲线也更平缓。

Python 则是自动化脚本的首选语言,语法简洁,库丰富。除了 Selenium,我们还会用到 time 进行简单等待, random 模拟人类操作间隔, logging 记录运行日志方便排查问题。

3. 环境准备与核心工具详解

3.1 搭建 Python 与 Selenium 环境

工欲善其事,必先利其器。环境配置是第一步,也是最容易踩坑的一步。我会详细说明每一步,确保新手也能顺利搭建。

第一步:安装 Python 如果你还没有安装 Python,请前往 Python 官网下载安装包。 强烈建议选择 Python 3.8 及以上版本 ,并记得在安装时勾选 “Add Python to PATH” 选项,这能避免后续在命令行中找不到 Python 的麻烦。安装完成后,打开命令行(CMD 或 Terminal),输入 python --version ,如果能看到版本号,说明安装成功。

第二步:安装 Selenium 库 Python 的包管理工具 pip 让安装库变得极其简单。在命令行中执行以下命令:

pip install selenium

如果下载速度慢,可以使用国内镜像源,例如:

pip install selenium -i https://pypi.tuna.tsinghua.edu.cn/simple

第三步:下载浏览器驱动(WebDriver) 这是最关键也最容易出错的一步。Selenium 需要通过一个名为“WebDriver”的组件来操控浏览器。你需要下载与你电脑上 Chrome 浏览器版本号匹配 的 ChromeDriver。

  1. 查看 Chrome 版本 :打开 Chrome 浏览器,点击右上角三个点 -> 帮助 -> 关于 Google Chrome。记下版本号(例如,120.0.6099.109)。
  2. 下载 ChromeDriver :访问 ChromeDriver 官方下载站或国内镜像站。找到与你的 Chrome 主版本号(例如120)一致的驱动版本进行下载。
  3. 放置驱动 :下载后是一个可执行文件(如 chromedriver.exe chromedriver )。你有两种处理方式:
    • 方式A(推荐) :将其放在一个固定的目录(如 C:\WebDriver\ /usr/local/bin/ ),然后将该目录添加到系统的环境变量 PATH 中。这样 Selenium 就能自动找到它。
    • 方式B :在代码中指定驱动的绝对路径。这种方式更直接,但不利于代码迁移。

注意 :务必保证浏览器版本与驱动版本匹配!不匹配是导致 SessionNotCreatedException 错误的最常见原因。如果 Chrome 自动更新了,你可能需要重新下载对应版本的驱动。

3.2 分析目标网站:经世优学平台

在编写自动化脚本之前,我们必须化身“侦察兵”,手动操作一遍流程,并用开发者工具(F12)仔细分析页面结构。这是整个项目成败的基础。

手动操作与观察

  1. 打开经世优学平台,手动登录。
  2. 进入你的课程学习页面。
  3. 播放一个视频。注意观察:
    • 登录表单 :账号和密码输入框的 id name 是什么?
    • 课程列表 :课程链接是如何排列的?用什么元素( class , tag )包裹?
    • 视频播放器 :播放按钮是普通的 button 还是 div ?它的唯一标识是什么?
    • 进度条与状态 :如何判断视频在播放?是看播放按钮的样式变化,还是页面有特定的状态文本(如“播放中”)?
    • 弹窗与互动 :播放过程中,是否有“确认继续学习”、“随堂测验”等弹窗?它们出现有什么规律?关闭按钮如何定位?
    • 完成标记 :视频看完后,页面是否会显示“已完成”?或者有一个复选框需要勾选?

使用开发者工具(F12) : 这是我们的核心侦察工具。切换到 Elements 面板,你可以看到网页的 HTML 结构。

  • 定位元素 :点击左上角的箭头图标,然后去点击页面上的元素(如播放按钮),代码面板会自动定位到对应的 HTML 代码。你需要找到该元素的 唯一或稳定的选择器
  • 选择器优先级 id > name > class > tag > XPath > CSS Selector 。优先使用 id ,因为它通常是唯一的。如果没有 id ,则观察 class 或其它属性。 XPath 功能强大但可能随页面结构调整而变化,应谨慎使用。
  • 网络请求观察 :切换到 Network 面板,刷新页面或进行播放操作。你可以看到视频流(可能是 m3u8 mp4 文件)的请求,以及标记课程完成时触发的 API 请求。理解这些请求有助于我们更精准地模拟“完成”动作。

通过这番侦察,我们就能为每个需要操作的元素找到“地址”,从而在代码中指挥 Selenium 去找到并操作它们。

4. 代码实战:从登录到自动播放

4.1 初始化驱动与登录模块

让我们开始编写代码。首先创建一个新的 Python 文件,比如 auto_course.py

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, NoSuchElementException
import time
import random
import logging

# 配置日志,方便查看运行状态和排查问题
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

class AutoCourse:
    def __init__(self, headless=False):
        """
        初始化浏览器驱动
        :param headless: 是否使用无头模式(不显示浏览器界面)
        """
        options = webdriver.ChromeOptions()
        # 添加一些常用选项,使浏览器行为更接近真人
        options.add_argument('--disable-blink-features=AutomationControlled')  # 隐藏自动化特征
        options.add_experimental_option("excludeSwitches", ["enable-automation"])
        options.add_experimental_option('useAutomationExtension', False)
        
        if headless:
            options.add_argument('--headless')  # 无头模式,后台运行
            options.add_argument('--disable-gpu')
        
        # 指定驱动路径(如果已加入PATH,则不需要)
        # self.driver = webdriver.Chrome(executable_path='你的驱动路径', options=options)
        self.driver = webdriver.Chrome(options=options)
        self.wait = WebDriverWait(self.driver, 10)  # 设置显式等待,最多等10秒
        logger.info("浏览器驱动初始化成功。")

    def login(self, username, password, login_url):
        """
        登录功能
        :param username: 用户名
        :param password: 密码
        :param login_url: 登录页面地址
        """
        logger.info(f"正在访问登录页面: {login_url}")
        self.driver.get(login_url)
        
        # 等待页面加载,这里假设登录表单已出现
        # 你需要根据实际页面修改选择器
        try:
            username_input = self.wait.until(
                EC.presence_of_element_located((By.ID, "username"))  # 替换为实际的ID
            )
            password_input = self.driver.find_element(By.ID, "password")  # 替换为实际的ID
            login_button = self.driver.find_element(By.CSS_SELECTOR, "button[type='submit']")  # 替换为实际选择器
            
            # 模拟人类输入,有间隔地输入字符
            for char in username:
                username_input.send_keys(char)
                time.sleep(random.uniform(0.05, 0.2))
            time.sleep(random.uniform(0.5, 1.0))
            
            for char in password:
                password_input.send_keys(char)
                time.sleep(random.uniform(0.05, 0.2))
            time.sleep(random.uniform(0.5, 1.0))
            
            login_button.click()
            logger.info("登录信息已提交,等待页面跳转...")
            
            # 等待登录成功后的页面元素出现,比如用户头像或课程中心入口
            # self.wait.until(EC.presence_of_element_located((By.ID, "user-avatar")))
            time.sleep(3)  # 简单等待,实际应用中应使用上面的显式等待
            
        except TimeoutException:
            logger.error("登录页面元素加载超时,请检查网络或选择器。")
            self.driver.save_screenshot('login_timeout.png')  # 截图保存,方便排查
            raise
        except Exception as e:
            logger.error(f"登录过程中发生未知错误: {e}")
            raise

代码解析与注意事项

  • __init__ 方法 :我们创建了一个 ChromeOptions 对象来配置浏览器。 --disable-blink-features=AutomationControlled 等参数是为了尽可能隐藏 Selenium 的自动化特征,避免被一些网站检测到。 WebDriverWait 是“显式等待”工具,比粗暴的 time.sleep 更智能,它会等待某个条件成立(如元素出现)后再继续,最多等待指定时间。
  • login 方法 :这是登录的核心。我们使用 find_element By 来定位元素。 关键点在于选择器的准确性 ,代码中的 "username" , "password" 等需要你根据实际网页的 HTML 结构进行替换。模拟人类输入( random.uniform 控制间隔)是为了增加行为真实性,对抗简单的行为检测。
  • 异常处理与日志 :我们使用 try...except 捕获可能出现的超时或元素找不到异常,并用 logging 记录信息。出错时自动截图 ( save_screenshot ) 是极其有用的调试手段,能让你看到脚本“眼中”的页面是什么样子。

4.2 导航课程与章节列表

登录成功后,我们需要导航到具体的课程和章节。这部分逻辑因平台而异,但思路相通。

    def navigate_to_course(self, course_link_selector):
        """
        导航到指定课程页面
        :param course_link_selector: 课程链接的CSS选择器或XPath
        """
        logger.info("正在寻找课程链接...")
        try:
            # 方法1:如果课程链接可以直接点击
            course_link = self.wait.until(
                EC.element_to_be_clickable((By.CSS_SELECTOR, course_link_selector))
            )
            course_link.click()
            logger.info("已进入课程页面。")
            time.sleep(2)  # 等待课程页面加载
            
        except TimeoutException:
            logger.warning("未找到可点击的课程链接,尝试通过URL直接进入...")
            # 方法2:如果你知道课程页面的直接URL,可以直接get
            # self.driver.get("你的课程直链URL")
            # 或者,可能需要先点击“我的课程”、“学习中心”等入口
            # 这里需要你根据实际页面结构添加更多导航逻辑
            pass

    def get_chapter_list(self, chapter_container_selector):
        """
        获取课程的所有章节/视频列表
        :param chapter_container_selector: 包裹所有章节项的容器选择器
        :return: 章节元素列表
        """
        logger.info("正在获取章节列表...")
        try:
            # 等待章节列表容器加载
            chapter_container = self.wait.until(
                EC.presence_of_element_located((By.CSS_SELECTOR, chapter_container_selector))
            )
            # 假设每个章节项都在容器内,有共同的类名,比如 'chapter-item'
            # 你需要根据实际情况调整子元素选择器
            chapter_items = chapter_container.find_elements(By.CLASS_NAME, 'chapter-item')
            logger.info(f"共找到 {len(chapter_items)} 个章节。")
            return chapter_items
        except TimeoutException:
            logger.error("获取章节列表超时。")
            self.driver.save_screenshot('chapter_list_timeout.png')
            return []

实操心得

  • 页面结构多变 :不同平台的课程入口千差万别。你可能需要先点击“学习中心”,再在“进行中的课程”列表里找,或者平台有一个统一的“课程列表”页面。 务必使用开发者工具仔细分析路径
  • 动态加载 :很多现代网站使用异步加载(Ajax)。你点击“我的课程”后,课程列表可能稍后才渲染出来。这时必须使用 WebDriverWait 等待列表元素出现,而不是用固定的 time.sleep
  • 列表获取 find_elements (注意是复数)会返回一个元素列表。遍历这个列表,就能逐个处理每个章节或视频。

4.3 视频播放与状态监控

这是最核心的部分。我们需要自动播放视频,并判断何时播完,以进入下一个。

    def play_video_and_monitor(self, video_item_element):
        """
        播放单个视频并监控其状态直至结束
        :param video_item_element: 代表一个视频章节的WebElement对象
        """
        logger.info("开始处理一个视频章节。")
        try:
            # 1. 点击进入该视频详情页或直接播放
            # 假设视频标题是一个可点击的链接
            video_link = video_item_element.find_element(By.CLASS_NAME, 'video-title')
            video_link.click()
            time.sleep(3)  # 等待视频页面加载
            
            # 2. 查找并点击播放按钮
            # 播放按钮的选择器需要你仔细分析页面
            play_button_selector = "div.video-player button.play"  # 示例
            play_button = self.wait.until(
                EC.element_to_be_clickable((By.CSS_SELECTOR, play_button_selector))
            )
            play_button.click()
            logger.info("视频开始播放。")
            
            # 3. 监控视频播放状态
            video_duration = self.get_video_duration()  # 假设有方法获取视频总时长
            if video_duration:
                logger.info(f"视频总时长约为 {video_duration} 秒。")
                # 方法A:基于时间的简单等待(不推荐,无法处理暂停、卡顿)
                # time.sleep(video_duration + 10)  # 多等10秒缓冲
                
                # 方法B:轮询检查播放进度或完成状态(推荐)
                self.monitor_by_progress(video_duration)
            else:
                # 方法C:如果无法获取时长,则检查“已完成”状态或下一个按钮是否出现
                self.monitor_by_completion_flag()
                
            # 4. 视频结束后,可能需要进行“标记完成”操作
            self.mark_as_completed()
            
            # 5. 返回章节列表页,准备播放下一个
            self.driver.back()
            time.sleep(2)
            
        except Exception as e:
            logger.error(f"处理视频章节时发生错误: {e}")
            self.driver.save_screenshot('video_play_error.png')
            # 可以选择跳过当前视频,继续下一个
            # 或者尝试刷新页面重试

    def get_video_duration(self):
        """
        尝试从页面获取视频总时长(秒)
        需要根据平台页面实际元素调整
        """
        try:
            # 示例:时长可能显示在一个类名为 'duration' 的span里,格式为 "12:34"
            duration_text = self.driver.find_element(By.CLASS_NAME, 'duration').text
            mins, secs = map(int, duration_text.split(':'))
            total_seconds = mins * 60 + secs
            return total_seconds
        except:
            logger.warning("无法获取视频时长,将使用备用监控方法。")
            return None

    def monitor_by_progress(self, expected_duration):
        """
        通过轮询播放进度来监控
        :param expected_duration: 预期总时长(秒)
        """
        logger.info("开始通过进度监控视频播放。")
        start_time = time.time()
        last_progress = 0
        stuck_count = 0
        
        while True:
            time.sleep(10)  # 每10秒检查一次
            elapsed = time.time() - start_time
            
            try:
                # 尝试获取当前播放时间(需要根据播放器元素调整)
                # 示例:当前时间在一个类名为 'current-time' 的元素里
                current_time_text = self.driver.find_element(By.CLASS_NAME, 'current-time').text
                current_seconds = self._time_text_to_seconds(current_time_text)
                
                progress = current_seconds
                logger.info(f"已播放 {elapsed:.0f} 秒,视频进度 {current_seconds} 秒。")
                
                # 判断是否卡住:进度在两次检查间没有变化
                if abs(progress - last_progress) < 2:  # 2秒内没变化
                    stuck_count += 1
                    logger.warning(f"视频进度可能卡住,计数: {stuck_count}")
                    if stuck_count >= 3:  # 连续3次检查没变化
                        logger.warning("视频可能已暂停或卡住,尝试点击播放按钮恢复...")
                        self._click_play_button()
                        stuck_count = 0
                else:
                    stuck_count = 0
                    
                last_progress = progress
                
                # 判断是否结束:当前时间接近总时长,或者页面出现“已完成”标志
                if current_seconds >= expected_duration - 5:  # 提前5秒判断
                    logger.info("视频进度已接近总时长,判断为播放结束。")
                    break
                    
                # 防止无限循环
                if elapsed > expected_duration * 2:  # 如果实际耗时远超预期时长
                    logger.warning("播放时间已远超预期,强制结束监控。")
                    break
                    
            except NoSuchElementException:
                # 可能播放器元素变了,或者视频已结束进入新页面
                logger.info("无法找到播放进度元素,可能视频已结束。")
                break
            except Exception as e:
                logger.error(f"监控进度时发生错误: {e}")
                # 可以在这里加入更多的恢复逻辑,比如刷新页面
                break

    def _time_text_to_seconds(self, time_str):
        """将 '12:34' 格式的时间字符串转换为秒数"""
        parts = list(map(int, time_str.split(':')))
        if len(parts) == 2:
            return parts[0] * 60 + parts[1]
        elif len(parts) == 3:
            return parts[0] * 3600 + parts[1] * 60 + parts[2]
        return 0

    def _click_play_button(self):
        """尝试点击播放按钮(用于恢复播放)"""
        try:
            play_btn = self.driver.find_element(By.CSS_SELECTOR, "button.play, div.play-button")
            play_btn.click()
            logger.info("已尝试点击播放按钮。")
        except:
            pass

核心难点与解决方案

  1. 如何判断视频结束? 这是最大的挑战。上面代码提供了三种思路:

    • 基于已知时长等待 :如果页面显示了视频总长,可以简单等待对应时间。但这种方法无法处理视频卡顿、暂停或中途弹出的测验。
    • 轮询播放进度 :不断读取播放器的当前时间,当它接近总时长时判断为结束。更可靠,但需要页面元素暴露当前时间。
    • 检测完成状态 :视频播完后,页面可能会出现“已完成”标签、勾选框变亮或“下一节”按钮激活。检测这些元素是最直接的方式。 最佳实践是组合使用 :优先检测“已完成”标志,如果没有,则轮询进度,最后才用固定时长兜底。
  2. 防挂机检测 :有些平台会检测页面是否处于非活动状态。我们的脚本可以通过 随机间隔执行一些无害操作 来模拟活动,比如轻微移动鼠标(通过 JavaScript)、切换标签页( driver.switch_to.window )再切回来、或者定时刷新页面。但要注意,刷新可能导致播放进度丢失。

4.4 处理弹窗与异常情况

没有哪个自动化脚本能一帆风顺,我们必须为各种“惊喜”做好准备。

    def handle_popups_during_playback(self):
        """
        在视频播放过程中,周期性检查并处理可能出现的弹窗
        这个方法应该在监控循环中定期调用
        """
        popup_selectors = [
            ("div.confirm-dialog button.confirm", "click"),  # 确认继续学习弹窗
            ("div.quiz-modal", "answer_and_close"),         # 随堂测验弹窗
            ("div.alert button.close", "click"),            # 普通警告弹窗
        ]
        
        for selector, action in popup_selectors:
            try:
                # 快速查找,不等待,因为弹窗可能不存在
                popup = self.driver.find_element(By.CSS_SELECTOR, selector)
                logger.warning(f"检测到弹窗,选择器: {selector}")
                
                if action == "click":
                    # 找到弹窗内的确认或关闭按钮并点击
                    confirm_btn = popup.find_element(By.TAG_NAME, "button")
                    confirm_btn.click()
                    logger.info("已点击弹窗确认按钮。")
                    time.sleep(1)
                elif action == "answer_and_close":
                    # 处理测验:这里需要更复杂的逻辑,比如随机选择答案
                    self._handle_quiz(popup)
                    
            except NoSuchElementException:
                continue  # 没找到这个弹窗,检查下一个
            except Exception as e:
                logger.error(f"处理弹窗 {selector} 时出错: {e}")

    def _handle_quiz(self, quiz_popup_element):
        """
        处理随堂测验(简单版:随机选择第一个选项并提交)
        实际应用可能需要更智能的逻辑,比如读取题目
        """
        logger.info("正在处理随堂测验...")
        try:
            # 假设每个选项是一个 radio input 或 checkbox
            options = quiz_popup_element.find_elements(By.CSS_SELECTOR, "input[type='radio'], input[type='checkbox']")
            if options:
                # 随机选择一个(或者选第一个)
                import random
                option_to_select = random.choice(options) if len(options) > 1 else options[0]
                # 如果元素不可点击,可能需要通过JavaScript点击
                self.driver.execute_script("arguments[0].click();", option_to_select)
                logger.info("已选择一个答案。")
                time.sleep(1)
            
            # 查找并点击提交按钮
            submit_btn = quiz_popup_element.find_element(By.CSS_SELECTOR, "button.submit, input[type='submit']")
            submit_btn.click()
            logger.info("已提交测验答案。")
            time.sleep(2)  # 等待结果提交
        except Exception as e:
            logger.error(f"处理测验失败: {e}")
            # 如果处理失败,可以尝试直接关闭弹窗(如果有关闭按钮)
            try:
                close_btn = quiz_popup_element.find_element(By.CLASS_NAME, "close")
                close_btn.click()
            except:
                pass

避坑指南

  • 弹窗处理顺序 :将最常见的弹窗选择器放在列表前面,提高检测效率。
  • 非阻塞检测 :使用 find_element 而不是 WebDriverWait 来检测弹窗,因为弹窗不是一直存在。 find_element 找不到会立刻抛出异常,我们可以捕获这个异常并继续。
  • 测验处理 :自动答题有风险。简单的随机选择可能答错,导致需要重看。更稳妥的方法是:1) 记录下题目和正确答案,建立本地题库,下次遇到直接选;2) 如果允许,先暂停脚本,手动答题后再继续。这涉及到更复杂的交互,需要权衡自动化程度与稳定性。

5. 完整脚本整合与优化策略

5.1 主循环与脚本入口

将上述所有模块整合起来,形成一个可以完整运行的脚本。

    def run(self, username, password, login_url, course_selector):
        """
        主运行流程
        """
        try:
            # 1. 登录
            self.login(username, password, login_url)
            
            # 2. 进入课程
            self.navigate_to_course(course_selector)
            
            # 3. 获取章节列表
            # 这里的章节容器选择器需要你根据实际页面确定
            chapters = self.get_chapter_list("div.chapter-list-container")
            
            if not chapters:
                logger.error("未找到任何章节,脚本退出。")
                return
                
            # 4. 遍历所有章节/视频
            for idx, chapter in enumerate(chapters):
                logger.info(f"开始处理第 {idx + 1}/{len(chapters)} 个章节。")
                
                # 检查是否已完成(避免重复学习)
                if self.is_chapter_completed(chapter):
                    logger.info("该章节已完成,跳过。")
                    continue
                    
                # 处理视频播放
                self.play_video_and_monitor(chapter)
                
                # 在处理每个视频后,随机等待一段时间,模拟人类行为
                wait_time = random.uniform(5, 15)
                logger.info(f"随机等待 {wait_time:.1f} 秒...")
                time.sleep(wait_time)
                
            logger.info("所有章节处理完毕!")
            
        except Exception as e:
            logger.error(f"主流程运行失败: {e}")
            self.driver.save_screenshot('main_flow_error.png')
        finally:
            # 无论成功与否,最后都关闭浏览器
            input("按回车键结束程序并关闭浏览器...")
            self.driver.quit()

    def is_chapter_completed(self, chapter_element):
        """
        检查章节是否已标记为完成
        :return: True 如果已完成
        """
        try:
            # 查找表示“已完成”的标识,比如一个勾选图标、特定的类名或文本
            completed_marker = chapter_element.find_element(By.CSS_SELECTOR, ".completed, .finished, .checked")
            # 或者检查文本内容
            # if "已完成" in chapter_element.text:
            #     return True
            return True
        except NoSuchElementException:
            return False

if __name__ == "__main__":
    # 配置你的信息
    YOUR_USERNAME = "你的账号"
    YOUR_PASSWORD = "你的密码"
    LOGIN_URL = "经世优学平台的登录页面URL"
    COURSE_SELECTOR = "CSS选择器,用于定位你要学习的课程链接"
    
    # 创建实例并运行
    # headless=True 表示无头模式(不显示浏览器界面)
    bot = AutoCourse(headless=False)  # 调试时建议设为False,可以看到浏览器操作
    bot.run(YOUR_USERNAME, YOUR_PASSWORD, LOGIN_URL, COURSE_SELECTOR)

5.2 对抗反自动化策略与稳健性提升

平台不会坐视不管,它们会升级反自动化策略。我们的脚本也需要不断进化。

  1. 特征隐藏 :我们已经在初始化选项中添加了部分参数来隐藏 WebDriver 特征。更进一步的,可以尝试:

    # 在初始化后执行JavaScript,覆盖navigator.webdriver属性
    self.driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
    
  2. 行为模拟

    • 随机延迟 :在所有关键操作(点击、输入)前后加入随机等待时间,避免固定节奏。
    • 鼠标移动 :使用 ActionChains 模拟更自然的鼠标移动轨迹,而不是直接从A点跳到B点。
    from selenium.webdriver.common.action_chains import ActionChains
    element = driver.find_element(...)
    actions = ActionChains(driver)
    actions.move_to_element(element).perform()
    time.sleep(random.uniform(0.1, 0.5))
    actions.click().perform()
    
    • 页面滚动 :偶尔滚动一下页面,模拟阅读行为。
  3. 断点续学与状态保存 :脚本可能因为网络、弹窗或其他异常中断。一个健壮的脚本应该能记录进度,下次从中断处继续。可以将已完成的章节ID或标题保存到一个本地文件(如JSON或TXT),每次运行前先读取这个文件,跳过已完成的章节。

  4. 多账号与调度 :如果你需要管理多个账号,可以将账号密码保存在一个配置文件中,用循环遍历。更高级的,可以结合任务调度器(如 Windows 的 Task Scheduler 或 Linux 的 cron)在指定时间自动运行脚本。

  5. 日志与通知 :完善的日志 ( logging 模块) 至关重要。你还可以集成邮件或消息推送(如 Server酱、PushPlus),在脚本完成、出错或遇到无法处理的弹窗时通知你。

6. 常见问题排查与调试技巧

即使代码写得再仔细,运行时也总会遇到各种问题。这里记录了我踩过的一些坑和解决方法。

6.1 元素定位失败(NoSuchElementException)

这是最常见的问题,意味着 Selenium 在页面中找不到你指定的元素。

可能原因及解决方案:

问题原因 排查方法 解决方案
页面未加载完成 查看截图,元素是否在页面上? 在操作前增加等待。使用 WebDriverWait 配合 EC.presence_of_element_located EC.element_to_be_clickable
iframe 嵌套 检查目标元素是否在 <iframe> 标签内。 使用 driver.switch_to.frame(frame_element_or_id) 切换到对应的 iframe 后再定位元素。操作完后用 driver.switch_to.default_content() 切回。
元素属性动态变化 检查元素的 id class 是否每次加载都不同。 使用更稳定的定位方式,如 XPath 基于文本内容 ( //button[contains(text(),‘登录’)] ) 或相对路径。
页面结构已更新 对比之前保存的页面HTML和现在的页面。 更新你的选择器。这是维护自动化脚本的常态。
选择器写错了 在浏览器控制台用 document.querySelector(‘你的选择器’) 测试。 修正 CSS 选择器或 XPath 语法。

调试技巧 :当定位失败时,立刻截图 ( driver.save_screenshot(‘error.png’) ) 并打印当前页面的源代码 ( driver.page_source )。这能帮你直观地看到脚本“眼中”的页面状态。

6.2 脚本被检测到

如果登录失败,或者进行某些操作时被重定向到验证页面,可能是触发了平台的反爬机制。

应对策略:

  1. 使用更真实的浏览器环境 :尝试去掉 headless 模式(无头模式更容易被检测)。或者使用 undetected-chromedriver 这类专门修改过的驱动。
  2. 降低操作频率 :大幅增加操作间的随机等待时间,模拟真人犹豫和阅读时间。
  3. 模拟真人浏览 :在播放视频的间隙,随机执行一些浏览操作,如滚动页面、移动鼠标到不相关区域等。
  4. Cookie 复用 :先手动登录一次,用 pickle 库保存登录后的 Cookies。下次脚本启动时,先加载 Cookies 再访问页面,可能绕过登录检测。但需注意 Cookies 有效期。

6.3 视频监控失灵

视频播放监控是最容易出错的环节,因为播放器实现五花八门。

排查清单:

  1. 进度元素找不到 :播放器的当前时间、总时间可能不是通过标准HTML元素显示的,而是画在 Canvas 里或通过复杂的 JavaScript 控制。这时, 基于时间的等待 检测完成标志 是更可靠的备选方案。
  2. 视频卡住但进度在变 :有些播放器在缓冲时,当前时间数字会卡住,但视频实际已暂停。除了检测进度,还可以尝试定期(比如每分钟)检查视频的 paused 属性(通过JavaScript执行 document.querySelector(‘video’).paused ),如果为 true 则点击播放按钮。
  3. 弹窗打断监控 :确保你的 handle_popups_during_playback 函数在监控循环中被高频调用(比如每次检查进度时都调用一次)。

6.4 网络不稳定与重试机制

网络波动可能导致页面加载失败或请求超时。

增强稳健性:

from selenium.common.exceptions import WebDriverException

def robust_find_element(driver, by, selector, retries=3):
    """带有重试机制的元素查找"""
    for i in range(retries):
        try:
            element = driver.find_element(by, selector)
            return element
        except NoSuchElementException:
            if i == retries - 1:
                raise
            logger.warning(f"元素 {selector} 未找到,第 {i+1} 次重试...")
            time.sleep(2 ** i)  # 指数退避等待
        except WebDriverException as e:
            logger.warning(f"查找元素时发生网络错误: {e},第 {i+1} 次重试...")
            time.sleep(2 ** i)
    return None

将核心的 find_element click 操作包裹在重试逻辑中,可以显著提升脚本在恶劣网络环境下的生存能力。

最后,记住自动化挂网课的目的是 提升效率,解放时间用于更有价值的学习或工作 。这个脚本是一个起点,你需要根据目标平台的具体情况不断调整和优化。从简单的等待播放,到处理弹窗,再到对抗检测,每一步的深入都让你对 Web 自动化的理解更深一层。在实际使用中,请务必遵守平台的使用条款,合理使用自动化工具。

更多推荐