简介:一套基于 Python 的学习通自动刷课脚本源码,面向希望借助自动化完成平台课程任务、减少重复点击的学习者,也适合想在 Selenium 浏览器操作和 fonttools 字体处理上做实践参考的 Python 开发者。资源包共 27 个文件、约 2.41MB,核心为 17 个 Python 脚本,按登录、IFrame 定位、任务检测、答题识别、章节切换、完成判断等模块独立拆分;另有 pkl/json 字体映射数据、说明文档和界面图片,便于厘清脚本运行逻辑与二次开发。项目还细分了事件检测、播放按钮识别、题目解析等实现,能帮助初学者理解浏览器自动化脚本从页面元素定位到状态判断的完整流程。整体功能模块切割清晰,可抽取登录或答题识别等某一段单独调试,方便改造成其他在线学习场景的自动化脚本。目前已有 8096 人学习浏览,作为可直接调试的完整源码包,是一份兼具工具价值与自动化学习意义的脚本参考。
1. 学习通刷课脚本:selenium 和 fonttools 到底在替你解决哪两件难事
课程积压、视频又长又拖的人,多少都动过“弄个学习通刷课脚本”的念头。标题里两个包分工很清晰:selenium 驱动真实浏览器去打开课程页、定位章节、触发播放、点掉弹窗,把整个手动学习流程替换成自动化操作;fonttools 去解析课件里加密的 woff 字体,把页面中被映射到 Unicode 私有区的乱码字符还原成可读文本,方便脚本做关键词匹配和内容整理。
它解决的不是一个难题,而是两件事:让视频自己按正常节奏播完,让课件文字能当成普通文本处理。适合谁?适合已经选了这门课、愿意花一个晚上把重复劳动固化成代码的人,也适合需要批量整理课件文字的人。前提很清楚——只处理你自己账号下已授权的课程内容,把“自动点击”当成对手动操作的替代,而不是绕过平台规则的捷径。
2. 先看机制再看代码:学习通网页端凭什么判定你“在学”,fonttools 又为什么被挂进来
2.1 学习通网页端课程页的播放逻辑:video 标签、心跳上报与“已完成”判定
学习通网页端课程视频的播放核心是 HTML5 的 video 标签,早期 Flash 播放器基本被主流浏览器淘汰。正因为是标准 video 标签,selenium 才能用execute_script直接读取currentTime、duration、paused、ended这些属性;也正因为这些属性可以靠 JS 修改,很多人才会冒出“直接把 currentTime 调到结尾不就行了”的想法。真这么做,大概率章节还是灰色。
平台端判定一场学习有没有效,不看你截图,而看页面前端有没有持续上报心跳:播放器里的 JavaScript 每隔一段时间把当前播放时间点、用户停留时长、章节 ID 等信息组装成请求发给后端。所谓“刷课成功”,本质是让这串心跳看起来像一个真人从开头看到了结尾。所以脚本的责任不是“跳到结尾”,而是“模拟播放并等待结束”,结束之后还要给页面留几秒上报余量。这也是第四章状态机部分要处理的核心问题。
另一个容易被忽略的点:有些课程视频被拆成多个片段,有的页面里同时存在试听视频和正式视频。脚本如果只抓第一个 video,可能把预览视频当成正课播,结果正课进度原地不动。先理清这个页面里到底有几个播放器、哪个是当前章节真正要播的,比急着写 play 命令更值得花时间。
2.2 fonttools 的真实用途:把页面里的私有区乱码还原成可读文本
直接用 requests 拿学习通章节页的 HTML,经常会发现页面文本里混杂着一堆像输入法特殊符号区的字符,比如出现在私有区的\ue900、\uf62c。这不是普通乱码,而是字体加密:课件用自定义字体把常见汉字重新编码,页面能正常显示是因为浏览器加载了对应字体文件并做了映射,但 HTML 文本本身是一串被替换过的码位。
在这种机制下,脚本里的文本匹配、题干提取、关键词检索都会失效。fonttools(包名fonttools,导入路径是fontTools)的作用就是解析这些 woff/ttf 文件,读取 cmap 表,把私有区码位映射回真实字符。要注意它不能一套映射通吃所有课程:每个课件批次可能用不同字体,解析后必须回到页面里做一次校验,具体做法在 4.4 节会给出代码。
这里也要划清边界:fonttools 处理的是“字符映射”,不是“图片识别”。如果课件直接把文字渲染成图片,那就得上 OCR,那是另一套完全不同的方案了。
2.3 选型理由:requests 为什么刷不动学习通,selenium 为什么够用
requests 能拿到请求响应,但拿不到浏览器渲染后的 DOM,不能执行 JavaScript,也模拟不了播放器上报心跳所需的上下文环境。纯 requests 刷视频,需要逆向前端签名、维护加密参数,学习通页面稍微改版就要重新逆向,维护成本远高于收益。观察同类项目会发现最后都滑向 selenium,不是大家不会 requests,而是真实浏览器才是门槛最低、最抗改版的稳定路径。
selenium 的另一个好处是登录态管理简单:手动登录一次,cookie 存本地,下次启动恢复即可,不需要再造登录接口的签名。缺点也很明确:浏览器自动化特征明显,平台可以做 WebDriver 检测;运行速度比接口调用慢;headless 模式下 video 播放经常不正常。这几个痛点留到第五章逐个给现场。至于 Playwright 或 pyppeteer,也能做同样的事,只是标题既然锁定 selenium,本文就按 selenium 这套来讲。
3. 环境搭建与最小登录脚本:从 pip 报错到浏览器驱动匹配
3.1 安装 Python 依赖:为什么建议用 python -m pip
网上搜“python 安装”的热门问题里,相当一部分是 Windows 下pip命令找不到。原因通常是 Python 装好了,但 Scripts 目录没进 PATH,或者系统里有多个 Python 版本,pip 指向了另一个解释器。这类问题别急着改环境变量,统一用python -m pip可以绕开大部分 PATH 错乱:
python --version python -m pip install fonttools selenium python -m pip list | findstr /i "selenium fonttools"第一条命令确认解释器版本,第二条安装两个核心包,第三条确认安装结果。Linux/macOS 下把python换成python3、findstr换成grep即可。这里顺带说明:包名是fonttools,代码里导入时才写from fontTools.ttLib import TTFont,大小写不一样,写错会直接 ImportError。如果当前用户没有权限写进全局目录,可以加--user参数,装到当前用户的 site-packages 里。
3.2 selenium 驱动版本匹配:Chrome、Edge 与 webdriver-manager 的取舍
selenium 需要对应浏览器的驱动:Chrome 用 chromedriver,Edge 用 msedgedriver。驱动和浏览器版本差太多,启动就会直接报 session 创建失败。常见选择有三类:
| 方式 | 命令/配置 | 特点 |
|---|---|---|
| 手动下载驱动 | 下载后放进 PATH 或写死 executable_path | 最稳,浏览器升级时要手动换 |
| webdriver-manager | python -m pip install webdriver-manager | 自动匹配当前浏览器版本 |
| Selenium Manager | Selenium 4.6 起内置自动管理 | 免配置,但部分网络环境下自动下载容易失败 |
我一般习惯用 webdriver-manager,它能把“浏览器升级后驱动不匹配”这个坑自动填上。示例:
from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service)这段代码先通过 ChromeDriverManager 拿到与本机 Chrome 版本匹配的驱动路径,再构造 Service 传给 Chrome。如果用的是 Edge,把导入路径换成webdriver_manager.microsoft里的EdgeChromiumDriverManager,逻辑完全一样。这样就不需要写死 executable_path,以后浏览器升级也不用动这行代码。注意自动下载依赖外网 CDN,跑不动时直接手动下载驱动、放到项目目录,再把 service 指向本地文件即可。
3.3 能跑通的最小登录脚本:手动登录、保存 cookie、维持会话
刷课脚本最麻烦的不是播放,是登录。学习通的登录流程里带验证码和扫码,自动化成本高,常见做法是“半自动”:第一次打开浏览器让你手动登录,保存 cookie,以后复用。最小脚本如下:
import pickle from selenium import webdriver from selenium.webdriver.chrome.options import Options opts = Options() opts.add_argument("--window-size=1280,800") # 去掉自动化控制条,减少被前端识别的特征 opts.add_experimental_option("excludeSwitches", ["enable-automation"]) driver = webdriver.Chrome(options=opts) driver.get("https://www.chaoxing.com/") input("请在浏览器里完成登录,然后回到终端按回车继续…") with open("chaoxing.pkl", "wb") as f: pickle.dump(driver.get_cookies(), f) print("cookies saved:", len(driver.get_cookies()))代码逻辑很直白:启动浏览器打开学习通首页,input()阻塞脚本等人手动登录;登录完成后把当前浏览器的 cookies 序列化到本地 pickle 文件。这里有个坑:只看 cookie 数量不等于登录成功,学习通有多个子域名,有些 cookie 只在特定域名下生效。所以保存前先在浏览器里进一次课程列表页,确认登录态完整。
下次启动先访问一次域名,再注入 cookies,因为 selenium 不允许在未访问对应域名时直接add_cookie:
driver.get("https://www.chaoxing.com/") with open("chaoxing.pkl", "rb") as f: cookies = pickle.load(f) for c in cookies: driver.add_cookie(c) driver.refresh()运行后立刻访问课程列表页验证登录态。cookie 过期后删掉本地 pkl 重新手动登录一次即可。这个阶段不要加花活,登录通路跑通,后面的脚本才有意义。
4. 从登录到刷完一个视频:课程定位、自动播放与防掉线循环
4.1 定位课程和章节:选择器怎么写才能扛住改版
登录后进入课程列表,常见元素是带courseId参数的链接。定位课程最稳妥的办法不是用绝对 xpath,而是先缩小范围再过滤文本:
from selenium.webdriver.common.by import By def click_course_by_name(driver, name): links = driver.find_elements(By.XPATH, "//a[contains(@href, 'courseId')]") for link in links: if name in link.get_attribute("innerText"): driver.execute_script("arguments[0].click()", link) return True return False这里用contains(@href, 'courseId')圈出可能是课程入口的链接,再用页面文本匹配课程名。很多课程卡片本身嵌在 iframe 里,执行这个函数前要确认已经切到对应 frame,否则find_elements只能看到外层空壳。定位不到时,用开发者工具右键元素看父级 iframe,比盲改 xpath 快得多。还有一个小技巧:课程名匹配用“包含”而不是“等于”,因为页面文本里可能掺着空格或特殊标记。
进入章节列表后,章节链接一般也带章节 ID。我常用同样的方式过滤:先找到包含“章节测试”“视频”字样的条目,再点进当前需要播放的章节。这里最容易翻车的点是“默认展开的是上一次学习的章节”,而不是课程第一章节。如果脚本每次都从第一个章节开始,可能会打乱你的学习记录,所以定位时有记忆上次位置的能力很重要。
4.2 视频播放的完整状态机:进入 iframe、触发 play、等待 ended
章节页的视频播放器通常套在多层 iframe 里,先切进去再找 video,顺序不能反:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def enter_player_frame(driver): for _ in range(3): frames = driver.find_elements(By.TAG_NAME, "iframe") if frames: driver.switch_to.frame(frames[0]) else: break driver.switch_to.default_content() enter_player_frame(driver) video = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "video")) )切入后如果视频没有自动播放,用 JS 触发而不是去点播放按钮:
driver.execute_script("arguments[0].play()", video) time.sleep(2)点播放按钮容易被播放器遮罩或自定义控件拦截,play()是原生方法,绕开了 UI 层。注意enter_player_frame每次都从default_content开始逐层切入,避免之前切到内层 frame 导致后续查找失效。如果页面结构固定,也可以写死切入第几层,但逐层找 iframe 的写法更抗改版。
等待视频结束,可以轮询 video 的三个状态:
import time def wait_video_end(driver, video, timeout=3600): start = time.time() while time.time() - start < timeout: state = driver.execute_script( "return {ended: arguments[0].ended, " "t: arguments[0].currentTime, " "d: arguments[0].duration}", video, ) if state["ended"] or (state["d"] and state["t"] >= state["d"] - 1): return state time.sleep(3) raise TimeoutError("video 播放超时")这个循环每 3 秒读一次播放状态,t >= d - 1是兼容某些视频不到ended就停住的情况,误差控制在 1 秒内。这里还能顺带做“进度不变检测”:如果连续多次t没有变化,说明被弹窗拦住,需要调用下一节的弹窗清理函数。把“播放进度”和“心跳”分开看,是刷课脚本不翻车的关键。
4.3 弹窗轮询与挂机保活:用一条循环兜住所有“你还在吗”
视频播放过程中,学习通会不定期弹出“已暂停,请点击继续”之类的提示,弹窗不点,进度就原地不动。这类弹窗不是严格定时出现的,只能靠轮询点击:
def dismiss_popups(driver, keywords=("继续学习", "点击继续", "知道了")): """点掉播放期间的弹窗,找不到按钮就忽略。""" for keyword in keywords: try: btn = driver.find_element( By.XPATH, f"//*[contains(normalize-space(.), '{keyword}')]" ) btn.click() time.sleep(1) return True except Exception: continue return Falsenormalize-space(.)会折叠元素文本里的空格和换行,避免文本中间有空白导致匹配失败。调用时机放在wait_video_end的循环里,每轮检测完进度后调用一次dismiss_popups。这样弹窗无论出现在第几分钟,都能在几秒内被点掉。注意点击“继续学习”后,播放器通常需要重新触发一次 play,否则提示框虽然没了,视频仍然 pause。所以弹窗处理后要再补一句 JS 判断paused,仍为 True 就调一次play()。
轮询频率不需要太密,3~5 秒一次足够,太频繁反而容易点错页面里的其他元素。另外,弹窗按钮的 DOM 每次可能重建,别缓存元素,每轮现查现点。
4.4 用 fonttools 解析课件 woff 字体:把私有区字符还原成可读文本
当页面文本混入大量私有区字符时,脚本拿到的字符串没法用于匹配。常见做法是把 CSS 里内嵌的 base64 woff 数据取出来,用 fonttools 建一张码位映射表:
import base64 import io import re from fontTools.ttLib import TTFont def parse_base64_woff(css_text): match = re.search(r"url\(\s*['\"]?data:font/woff[^,]*;base64,([^)'\"\s]+)", css_text) if not match: return None return base64.b64decode(match.group(1)) def build_char_map(woff_bytes): font = TTFont(io.BytesIO(woff_bytes)) char_map = {} for table in font["cmap"].tables: for code, glyph_name in table.cmap.items(): # 常见字形名是 uniXXXX 或 uXXXX,code 是页面里的私有区字符 if glyph_name.startswith("uni"): char_map[chr(code)] = chr(int(glyph_name[3:], 16)) elif glyph_name.startswith("u"): char_map[chr(code)] = chr(int(glyph_name[1:], 16)) return char_map拿到char_map之后,把页面文本里的每个私有区字符替换成映射后的真实字符,就能继续做关键词匹配。两个前提要注意:第一,字体文件要下载当前章节实际加载的那份,页面里可能有多批字体;第二,如果字形名是glyph00001这种无意义命名,说明字体做过子集化,这类要靠 OCR 识别字形轮廓,属于另一套方案。fonttools 解决的是“映射已知但文本不可读”的常见场景,不是万能解药。解析完可以用页面标题做一次校准:找一个浏览器里显示正常的中文标题,看映射表能不能把它还原对。
5. 学习通刷课脚本避坑指南:进度不走、弹窗拦截和 selenium 被检测的 5 个现场
5.1 视频播完了章节还是灰色:差在最后一次心跳上报
现象:脚本日志显示视频已经ended,也调用了dismiss_popups,但回到学习通章节列表,状态仍是未完成。原因:播放结束后页面立即切章或刷新,前端最后一段心跳还没发出去,后端没收到“看完”的信号。解决:wait_video_end返回后不要马上进入下一章,先停 8~10 秒,让播放器把最后的进度上报完,再刷新一次章节列表确认状态。这个“播完后静置”的习惯能减少一大批进度丢失,尤其适合网络延迟偏高的场景。
5.2 播放途中弹出“点击继续”,脚本却没反应
现象:脚本进程还在跑,但视频停了,日志里currentTime连续多次不变。原因:弹窗出现在某个新增 iframe 或遮罩里,之前保存的元素引用已经失效,或者dismiss_popups的匹配词没覆盖当次文案。解决:把弹窗检测放进进度的轮询循环里,每次读状态前先执行一次dismiss_popups;匹配词不要只写“继续学习”,把“点击继续”“知道了”“重新播放”都放进去;点完弹窗后检查paused,为 True 就再补一次play()。记住:弹窗按钮的 DOM 每次可能重建,别缓存元素,每次现查现点。
5.3 被 WebDriver 特征检测拦住,出现滑块或拒绝访问
现象:正常手动访问没问题,脚本一打开页面,没过多久就弹滑块验证,甚至直接提示访问异常。原因:navigator.webdriver标识、自动化控制条、窗口尺寸过于规整这些特征叠加在一起,被前端识别出来。解决:启动参数里加excludeSwitches,手动设置窗口尺寸,不要开 headless;如果还不够,可以引入undetected_chromedriver:
import undetected_chromedriver as uc driver = uc.Chrome()同样的登录、定位、播放流程不用改。需要强调的是,过度隐身配置反而更反常,一个普通窗口在正常播放,才是长期运行更稳妥的状态。另外,检测不一定发生在进入页面时,也可能发生在某个章节切换动作之后,所以日志里多留一步“当前 URL”会方便事后定位是被哪一步拦住的。
5.4 headless 模式下视频不播放或进度不涨
现象:为了省资源在服务器上跑 headless Chrome,课程页能打开,但 video 一直停在 0 秒或直接黑屏。原因:headless 下视频解码和音频输出策略与正常浏览器不同,播放器拿不到足够的媒体回放能力,play()返回的 Promise 可能一直不落定。解决:不要为了“低调”开 headless,用一个最小化的有头窗口配合桌面环境运行。如果必须 headless,常见做法是加--mute-audio并显式调用 play,但仍不能保证稳定。这不算玄学,而是浏览器媒体栈在无头模式下的设计取舍。
5.5 fonttools 映射出来的文本仍然是乱码
现象:解析了 woff 并做了替换,页面里的段落还是半对半错。原因:页面加载了多批字体,脚本只解析了其中一份;或者字形命名不是uniXXXX,cmap 信息不足以还原。解决:先看 CSS 里有几个字体源,逐个解析、合并映射表后再做替换;再拿页面里的已知标题文案反查映射是否正确。比如某个标题在浏览器里固定显示为“第一章”,在 HTML 里对应的私有区字符就可以当作校准样本。样本校准能立刻分辨是映射表不全还是字体解析错误。
6. 脚本刷完怎么验收、怎么续跑:日志、任务列表和中断恢复
6.1 用日志记录心跳,验证脚本真的在带进度
验收不是看脚本跑完,而是看学习通的章节状态。我的习惯是每轮循环写一条结构化日志:
import logging logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") def log_progress(chapter, state): logging.info( "chapter=%s endpoint=%.0f current=%.0f pause=%s", chapter, state["d"], state["t"], state["paused"], )跑完后从日志里能看到每个视频是否一路推进,也能对比学习通页面的完成状态来确认“真的刷完了”。如果某章日志里current长时间不动,说明踩了第五章里的某个坑,直接重跑那一章就行,不需要全量重来。
6.2 给脚本加“断点续跑”和任务队列
把所有章节做成列表,完成一个就写进本地 JSON,下次启动先读列表,跳过已完成章节。这个机制是血泪教训换来的:网络波动、弹窗漏点、平台临时维护都会让长任务中断,没有断点续跑就只能从头再来。任务列表加日志,就能在第二天早晨看清昨晚哪个任务在哪一步翻车,只修那一处,而不是删掉整个进度重刷。技术含量不高,但它是这套脚本能真正落地长期使用的保障。
我自己的习惯是把日志文件名带上当天日期,跑完再看一眼章节状态,心里才有底。刷课脚本的本质是“替你把可控的重复劳动自动化”,写代码时把失败可恢复排在第一位,比一时跑通更重要。希望帮到你。
本文还有配套的精品资源,点击获取