☰
Python爬虫实战:BlackboardDownloader批量下载黑板课件与课程资料
2026/10/14 20:17:05 网站建设 项目流程

简介:BlackboardDownloader 是一款基于 Java 开发的自动化下载工具,面向使用 Blackboard 在线教学平台的学生与教师,帮助解决课程文档分散、手动逐个下载耗时的痛点。它通过用户输入账号密码,自动抓取当前账号下所有课程的全部文档,并按照平台原有结构生成“教学大纲”“作业”“课程内容”等文件夹,下载时仅深入一个子文件夹,便于资料整理与离线查阅。资源包整体约 51.56MB,内含可运行的 JAR 程序、Java 源代码及登录信息示例等,源码便于二次开发或学习 Java 网络编程与解析逻辑。该工具支持安全存储用户名和密码,读取后即时删除内存中的敏感内容,兼顾便捷与隐私。目前已有 234 人学习,适合需要批量备份黑板书架资料、构建本地课程归档的学习者使用,无论是日常备份还是学期末归档,都能节省大量手动操作时间。

1. 一门课点开四五个目录才凑齐材料,BlackboardDownloader 帮你一次打包

每学期最后一次课结束,总有一波人开始疯狂单击浏览器里的下载按钮。课件一份、实验指导三份、参考论文两篇,分散在黑板的不同模块里,点完还要在下载栏里确认文件到底存到了哪个目录。BlackboardDownloader 就是为这种重复劳动准备的:在黑板系统里登录一次,脚本自动遍历你有权限访问的所有课程,把内容区里的全部附件按课程归集下载到本地硬盘。它适合课程多到懒得手动整理的在校生,也适合需要批量备份自己课程资料的助教。跟着后文的分层解析和代码骨架,你能在一个晚上跑通整个流程。

2. 摸清黑板结构并顺利登录:四个层级、两种登录方式、一个 Session

2.1 门户-课程-内容区-附件:层级决定脚本的取数顺序

Blackboard 的页面层级高度模板化。以常见的传统版本为例:学生登录后先进入门户页,门户列出当前学期的课程卡片;点进课程,左侧是课程菜单,右侧内容区挂载了教学大纲、课件、作业等模块;模块里的文档链接才是最终要下载的东西。少数学校使用 Ultra 版本,页面由 JavaScript 大量渲染,但后端的 URL 参数体系基本保留着 course_id 和 content_id 两个标识。

这个层级决定了脚本必须是“自上而下”的:先拿课程列表,再进每个课程页面取模块里的附件链接,最后对链接做解包。跳过哪一层都会漏文件。另一个关键点是注意区分课程菜单导航和内容区正文,课程菜单里也有很多跳转 href,但它们不是文件资源,粗筛的时候会把菜单链接捞进来。

这里有一个设计取舍要说清楚。黑板的传统版本页面是服务端渲染的,Python 的 requests 完全够用;但 Ultra 版本的内容区大量由前端框架在浏览器里组装,初始 HTML 只有空壳。我一般把浏览器方案推迟到确认 requests 解析真的拿不到数据之后再上,原因很实际:requests 脚本能跑在纯命令行环境,部署成本几乎为零,而 Playwright 必须额外安装浏览器内核,后面接定时任务时维护成本明显更高。

2.2 认证与 Session:为什么整个脚本只应该有一个 Session 对象

Blackboard 是全局会话制,登录后颁发一个包含用户身份的 cookie,后续所有请求都靠它维持。大多数学校还会把登录接到统一身份认证上,可能是 CAS、Shibboleth 或其他第三方认证体系。requests 模拟登录的思路大同小异:先 GET 登录页,解析表单里的隐藏 token,再 POST 用户名密码;如果学校走的是第三方认证,中途会有多次重定向和参数传递,脚本会变得很脆。

由于各家接入方式差异太大,做通用工具时我一般不会把“模拟一次统一认证登录”当作首选方案。更可靠的做法是让用户用浏览器登录一次,把有效 cookie 导出成 Netscape 格式的文本文件,脚本启动时直接加载。这类 cookie 文件在很多浏览器插件里都叫 cookies.txt。下面代码会给出表单登录和 cookie 文件登录两条路径,实际部署时按学校情况二选一即可。

2.3 最小登录代码骨架:先跑通登录,再做任何事

直接给登录这个最小可运行版本,它包括两条登录路径和一个会话探测函数:

import requests from bs4 import BeautifulSoup # 两条登录路径共用同一个 Session,这是整个脚本的关键 session = requests.Session() session.headers.update({ "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36") }) def login_by_form(username: str, password: str) -> bool: """路径一:模拟浏览器表单登录(适用于传统登录页)""" login_url = "https://lms.example.edu/webapps/login/" r = session.get(login_url, timeout=15) soup = BeautifulSoup(r.text, "html.parser") # 黑板登录页有一个隐藏的 csrf_token,字段名不同版本略有差异 token_node = soup.find("input", {"name": "csrf_token"}) csrf = token_node["value"] if token_node else "" payload = { "user_id": username, "password": password, "csrf_token": csrf, "action": "login", } resp = session.post(login_url, data=payload, timeout=15) # 登录成功后一般会跳到首页或机构页,而不是停留在 login return "login" not in resp.url.lower() or resp.status_code == 200 def login_by_cookie_file(path: str) -> None: """路径二:加载浏览器导出的 cookies.txt,绕过统一认证模拟""" import http.cookiejar cj = http.cookiejar.MozillaCookieJar(path) cj.load(ignore_discard=True, ignore_expires=True) for c in cj: session.cookies.set(c.name, c.value, domain=c.domain, path=c.path)

登录方式的选用逻辑很简单:学校登录页是传统表单,用 login_by_form;学校接了大厂的统一认证,或者表单里有动态加密参数,就用 login_by_cookie_file。cookie 方式几乎适用于所有学校,也是我做排障时的兜底手段。

有两个参数值得单独说明。User-Agent 必须以完整的浏览器标识出现,不能留空也不能是 python-requests 默认值,很多学校的应用网关会按 UA 拦截请求,用默认 UA 时经常出现偶发 403。timeout 全链路给 15 秒,保证某个接口卡住时整个脚本不至于无限等下去。csrf_token 的字段名偶见变化,如果登录后拿不到合法会话,先打印登录页 HTML,用正则搜 name 里含 token 的 input,按实际字段名修改代码即可。

账号密码不要硬编码在脚本里。常见做法是从环境变量读取,既避免密钥泄露,也方便后面接定时任务时不影响其他人:

import os username = os.environ.get("BB_USER", "") password = os.environ.get("BB_PASS", "")

登录成功后,所有请求统一走同一个 session.get(url),不要为每个请求重建 Session。Session 对象内部的 cookie jar 会随重定向自动更新,这是后续下载不断链的基础。补充一个会话探测函数,放在每次遍历课程前调用,能提前发现会话失效,避免跑一半才报错:

def ensure_login(session, login_func): probe = session.get( "https://lms.example.edu/webapps/portal/execute/tabs/tabAction?tab_tab_group_id=_1_1", timeout=15, allow_redirects=True, ) if "login" in probe.url.lower(): login_func()

3. 遍历课程与解析附件链接:三种链接形态各自怎么解包

3.1 从门户页剥离课程清单:先精确匹配,再全页面兜底

门户页顶部充满工具栏链接,直接对全页面解析会捞进来一堆导航垃圾。黑板课程列表的 HTML 通常固定在两个位置:id 为 courseMenu 的列表,或者 li.liJTree 下带 course_id 参数的链接。为了兼容不同校内版本,我一般写一个带优先级的解析函数:

from urllib.parse import urljoin def get_course_list(session, portal_url): """返回 [(课程名, 课程内容页URL), ...]""" resp = session.get(portal_url, timeout=15) soup = BeautifulSoup(resp.text, "html.parser") course_links = [] # 优先解析 courseMenu 区块,这个区块里只有课程 menu = soup.find(id="courseMenu") or soup.find("ul", class_="nav") if menu: for a in menu.find_all("a", href=True): course_links.append(a) else: # 退回全页面匹配 course_id 参数 for a in soup.find_all("a", href=True): if "course_id=" in a["href"]: course_links.append(a) result = [] seen = set() for a in course_links: name = a.get_text(strip=True) href = urljoin(portal_url, a["href"]) # 同一门课可能被列为"当前学期"和"所有课程"两处,按完整 URL 去重 if "course_id=" not in href or href in seen: continue seen.add(href) result.append((name, href)) return result

这个函数把课程名和内容页 URL 一起返回。有两个容易踩的点:一是课程名不要取 a 标签父节点的文本,父节点里往往包含课程编号和学期备注,混进来会让目录名变得很长;二是按完整 href 去重,有些学校会把同一门课同时挂在“本学期”和“历史课程”下面,它们的 course_id 大概率不同,按 href 去重不会误杀不同学期的同名课程。

选择器失效的情况也要有心理准备。黑板的 courseMenu 这个 id 传承了很多年,绝大多数传统版本都能命中,但个别学校做过界面定制后选择器会失效。此时不要硬改脚本,打开课程页按 F12 看元素,找到包含 course_id 链接的最小容器,把对应的选择器填进 get_course_list 即可。

3.2 解包附件链接:直接型、跳转型、iframe 型

进入课程内容页之后,文档链接大体有三种形态,处理方式完全不同。直接型链接的 URL 以文件扩展名或 /bbcswebdav/ 开头,这类链接可以直接拿来用。跳转型链接指向一个中间处理页,常见路径里带 downloadDocument 或 launchFrame 字样,需要先跟随一次重定向拿到真实的文件地址。iframe 型链接是黑板把文档包在预览播放器里,预览 iframe 的 src 才是文档地址。

统一的解包函数如下,它把三种形态收敛成一组干净的文件名和 URL:

import re import time from urllib.parse import urljoin, unquote FILE_EXT = r"\.(pdf|ppt|pptx|doc|docx|xls|xlsx|zip|rar|7z|txt|csv|md|png|jpe?g)(\?|$)" def unwrap_links(session, page_url: str): """返回 [(展示文件名, 真实文件URL), ...]""" resp = session.get(page_url, timeout=15) soup = BeautifulSoup(resp.text, "html.parser") items = [] for a in soup.find_all("a", href=True): href = a["href"] text = a.get_text(strip=True) # 直接型:黑板的文件存储统一走 bbcswebdav 路径 if "/bbcswebdav/" in href or re.search(FILE_EXT, href, re.I): real_url = urljoin(page_url, href) name = smart_filename(text, real_url) items.append((name, real_url)) continue # 跳转型:跟随重定向拿最终地址 if "downloadDocument" in href or "launchFrame" in href or "resolveContent" in href: real_url = follow_until_file(session, urljoin(page_url, href)) if real_url: items.append((smart_filename(text, real_url), real_url)) # iframe 预览型:预览播放器里的 src 往往是真实文档地址 for iframe in soup.find_all("iframe", src=True): src = iframe["src"] if "bbcswebdav" in src or re.search(FILE_EXT, src, re.I): real_url = urljoin(page_url, src) name = smart_filename(iframe.get("title", ""), real_url) items.append((name, real_url)) return items def follow_until_file(session, url: str): """跟随跳转直到出现文件特征地址""" resp = session.get(url, stream=True, allow_redirects=True, timeout=15) resp.close() # 只取地址,不取内容 if resp.url and (re.search(FILE_EXT, resp.url, re.I) or "/bbcswebdav/" in resp.url): return resp.url return None

有几个参数值得说透。follow_until_file 里的 stream=True 很关键,它只发起请求读完响应头,拿到最终 URL 后立刻 close,中间跳转链路里夹着的几十 KB 中间页不会真的下载下来。smart_filename 函数负责把“预览”“点击查看”这类无意义的链接文本剔除,改用 URL 尾部的真实文件名,这个边界在后面重点展开。iframe 的 title 属性经常是空字符串,所以 smart_filename 里必须处理空值回退,不能直接拿 title 做文件名。

对于页面中挂载的模块标题链接,比如“第一章课件”这种文本,因为不含扩展名且不是跳转型关键词,会被自动忽略。这正是我们想要的——它只是内容区的导航标题,不是可下载文件。

3.3 smart_filename 与文件白名单:下载前的两道闸门

文件名不能无脑套链接文本。黑板里经常出现链接文本是“点击查看”,而真实文件名躺在 href 里;反过来也有文件名太长被界面截断的情况。smart_filename 的做法是:先看链接文本有没有扩展名,有就用链接文本;没有或文本为空,就用 URL 解码后的尾部:

def smart_filename(link_text: str, url: str) -> str: # 链接文本带扩展名时最可信 if link_text and re.search(FILE_EXT, link_text, re.I): return link_text # 回退到 URL 尾部,解码中文和空格 tail = url.split("?")[0].rstrip("/").split("/")[-1] tail = unquote(tail) if re.search(FILE_EXT, tail, re.I): return tail # 仍解析不出就退回时间戳命名,保证不丢数据 return f"unnamed_{int(time.time())}.bin"

这里特意保留最后一行兜底逻辑:宁可给一个无名后缀也要把文件落盘,因为下载到的数据比命名美观优先。实际使用中“点击查看”这类链接占的比例不小,不兜底会造成整个批次里零星丢文件,排查起来最费劲。

把解包和课程遍历组合起来,就得到完整的数据收集阶段:

def collect_all(session, courses): all_tasks = [] # [(课程名, 课程URL, 文件列表)] for name, url in courses: files = unwrap_links(session, url) if files: all_tasks.append((name, url, files)) return all_tasks

到这里,脚本已经拿到了完整的三元组信息:哪门课、有哪些文件、每个文件的真实地址。下一步就是下载落地和并发控制。

4. 并发下载落地:线程数、频率控制和失败日志三件事

4.1 为什么下载线程数不能盲目拉满

课程文件少则十几个,多则上百个,串行下载确实煎熬。用线程池并发能快几倍,但黑板的 Web 应用服务器和学校网络边界对单会话并发请求数量其实非常敏感,并发过高会出现整批 403,严重的还会把会话临时封禁。我一般把线程数控制在 2 到 6,同时每个请求之间留随机间隔。这个设置不是玄学,黑板服务端对单 IP 的请求速率有隐含限制,一旦触发,通常要等几分钟才能恢复。

4.2 可控并发下载的实现

import os import re import time import random import requests from concurrent.futures import ThreadPoolExecutor, as_completed def download_one(item, course_folder, session, timeout=30): """item: (文件名, 文件URL)""" filename, url = item # Windows/Linux/macOS 通用命名清洗 safe = re.sub(r'[\\/:*?"<>|\r\n]', "_", filename).strip() safe = safe.rstrip(". ") # 结尾的点和空格在 Windows 下非法 path = os.path.join(course_folder, safe) # 已存在且大小 > 0 视为已下载,支持重复运行 if os.path.exists(path) and os.path.getsize(path) > 0: return path, "exists" try: with session.get(url, stream=True, timeout=timeout) as r: r.raise_for_status() with open(path, "wb") as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk) return path, "ok" except Exception as exc: return path, f"failed: {exc}" def batch_download(session, tasks, root_dir="blackboard_files", max_workers=4, delay_range=(0.2, 0.8)): fail_log = [] total = 0 with ThreadPoolExecutor(max_workers=max_workers) as pool: futures = [] for course_name, course_url, files in tasks: folder = os.path.join(root_dir, clean_name(course_name)) os.makedirs(folder, exist_ok=True) for filename, file_url in files: futures.append( pool.submit(download_one, (filename, file_url), folder, session) ) for fut in as_completed(futures): path, status = fut.result() total += 1 if status not in ("ok", "exists"): fail_log.append((path, status)) # 随机延迟,降低触发频控的概率 time.sleep(random.uniform(*delay_range)) # 失败记录写盘,方便二次重跑 with open("download_fail.log", "w", encoding="utf-8") as f: for path, status in fail_log: f.write(f"{path}\t{status}\n") print(f"合计 {total} 个文件,失败 {len(fail_log)} 个")

这段代码里的参数值得逐个过一遍。max_workers 取 4 是大部分校内环境实测后折中的数值,2 太慢,6 以上开始出现零星 403。delay_range 控制每个任务结束后的随机间隔,0.2 到 0.8 秒的随机抖动比固定间隔更容易绕过速率限制。timeout 给 30 秒,考虑到大文件传输,比登录接口的 15 秒放宽了一倍。失败日志统一写到当前目录的 download_fail.log,重跑脚本时只补下载失败的那几个,非常高效。

4.3 课程目录命名:清洗、截断、避开保留名

文件下载后按课程归到独立文件夹,目录命名也需要清洗一次。Windows 下课程名不能含 / \ : * ? " < > | 这些字符,另外还有 CON、PRN、AUX 这类保留名,直接建目录会报错。目录名还要考虑路径长度,课程名超过 80 字符后,再加里面的长文件名,Windows 的 MAX_PATH 很容易爆掉。

def clean_name(name: str) -> str: name = re.sub(r'[\\/:*?"<>|\r\n]', "_", name).strip() if name.upper() in {"CON", "PRN", "AUX", "NUL", "COM1", "LPT1"}: name = f"_{name}" return name[:80]

目录名控制在 80 字符内是保险做法,过长的课程名在 Windows 下累计会超出路径限制。这个边界很容易踩,等它爆出来再改脚本,不如一开始就截断。macOS 和 Linux 对路径长度宽松一些,但保留名和非法字符的清洗逻辑不应该不同,统一做掉最省事。

5. 黑板下载避坑手册:登录失效、JS 渲染和频控 403 的应对

5.1 登录成功却继续被重定向到登录页

现象:脚本跑完登录,请求课程页时响应是 302,Location 指回登录页,页面 HTML 又是登录表单。

原因:最常见情况是课程已被归档,或者会话 cookie 在中间某个请求里意外失效,也可能是 Session 对象被无意间重建过。

解决:先用浏览器打开同一课程页确认是否归档;再在代码里加会话探测,请求门户页检查最终 URL 是否出现 login,出现就重新登录。上一章给出的 ensure_login 函数就是干这个的,建议在遍历每一门课之前调用一次,成本极低。

5.2 页面是 JS 渲染的,requests 拿不到附件链接

现象:用 requests 拿到内容页,HTML 里没有任何附件链接,但浏览器里能正常看到文档列表。

原因:学校升级到了新版本黑板,内容区由前端框架渲染,初始 HTML 只是一个空壳,BeautifulSoup 解不出任何东西。

解决:对这类页面退回浏览器渲染。用 Playwright 打开课程内容页,等内容容器出现再取完整 HTML,交给 BeautifulSoup 复用。

from playwright.sync_api import sync_playwright def fetch_rendered_html(url: str, container_sel: str = "#content_listContainer") -> str: with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto(url, timeout=30_000) page.wait_for_selector(container_sel, timeout=10_000) html = page.content() browser.close() return html

这个方案的代价是需要额外安装 Chromium 内核,跑批的机器上要有运行环境;不需要时尽量别开。判断标准很简单:先看 requests 响应文本里有没有 bbcswebdav 字样,有就用 requests,没有才考虑浏览器渲染。

5.3 文件名带非法字符,落盘直接报错

现象:Windows 下保存文件时报 OSError,错误信息指向文件名,文件包含 / 或 \ 或 ?。

原因:课程文件名本身包含操作系统非法字符,尤其常见于老师写“2023/2024学年课件”这种带斜杠的命名。

解决:下载前统一清洗。替换掉全套非法字符,删除结尾的点和空格,再用 unquote 处理 URL 编码的中文。

safe = re.sub(r'[\\/:*?"<>|\r\n]', "_", filename).strip() safe = safe.rstrip(". ")

清理动作一定要放在文件名拼接路径之前,否则目录创建和文件写入会各自报一遍错,日志里看着像两个问题,实际是一个根因。

5.4 同一文件在多个课程区域重复下载

现象:下载总量明显多于课程文件列表,同一份 PDF 出现两份副本。

原因:老师在内容区和课程文件存储区都挂载了同一文件,或者一个文件被复制到了多个内容模块,黑板对这些入口生成了不同 URL。

解决:按文件名加响应体积做去重。记录已下载文件的(文件名, 体积),后续遇到同名且体积相同的链接直接跳过。download_one 里已有的“文件存在且大小大于 0 就跳过”逻辑,配合外部维护一个体积映射表即可覆盖这个场景。

5.5 批量 403:下载五分钟后的访问被全量拦截

现象:下载进行到一半,所有线程的响应变成 403,日志里整批 failed: 403 Client Error。

原因:同一会话瞬时请求数过高,或者间隔太短,服务端触发了速率限制。

解决:立刻停止脚本,休息三五分钟再跑。把 max_workers 调低到 2 到 3,delay_range 拉长到 0.5 到 1.5 秒。已经下载的文件不会被重复下载,所以重跑的开销非常小,这也是为什么下载逻辑里要坚持先查重再下载。

这些排查顺序有讲究:先确认 URL 有效,再看页面是否渲染,再排查文件系统规则,最后才怀疑限流。实际遇到“下载到一半开始失败”的场景,八成是限流而不是代码 bug,别急着改解析逻辑。

6. 让它成为日常:增量同步与定时运行

把下载器变成一个日常工具,关键不是下载速度,而是重复运行的成本趋近于零。增量同步的做法是在每个课程目录下保留一份 manifest.json,记录文件体积和最后修改时间;再次运行时先比对,文件已存在且体积没变就直接跳过,只下载新增和有变化的文件。这样每周定时跑一次,十几秒就能完成,不会给校园网增加额外压力。

import json, os def load_manifest(folder): path = os.path.join(folder, ".manifest.json") if os.path.exists(path): with open(path, encoding="utf-8") as f: return json.load(f) return {} def save_manifest(folder, manifest): with open(os.path.join(folder, ".manifest.json"), "w", encoding="utf-8") as f: json.dump(manifest, f, ensure_ascii=False, indent=2) def update_after_download(path, manifest): stat = os.stat(path) manifest[os.path.basename(path)] = { "size": stat.st_size, "mtime": stat.st_mtime, }

定时运行用系统自带的调度就够了。Windows 上用任务计划程序,命令行可以直接注册一个每周日上午 9 点的任务:

schtasks /create /tn blackboard_sync /tr "python C:\\tools\\bdownloader\\run.py" /sc weekly /d SUN /st 09:00 /f

macOS 或 Linux 下用 cron,在 crontab 里加一行:

0 9 * * 0 cd /opt/blackboard_downloader && python run.py >> run.log 2>&1

定时任务有个细节很容易翻车:脚本要用绝对路径读 cookies.txt 和写日志,因为定时任务的工作目录不一定是脚本所在目录。我一般会在脚本开头用 os.chdir(os.path.dirname(file)) 固定工作目录,杜绝路径类问题的随机出现。

我自己用这套流程的习惯是 manifest 一直保留不清空,学期结束后手动删掉课程目录即可。增量同步真正解决了“下载器只服务一次”的问题,让脚本融进每周的课程资料归档流程。这些坑踩过一轮之后,现在每个新学期只需要刷新一次登录 cookie,整个学期都不用再碰命令行。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询