Python爬虫实战:BlackboardDownloader批量下载黑板课件与课程资料
简介BlackboardDownloader 是一款基于 Java 开发的自动化下载工具面向使用 Blackboard 在线教学平台的学生与教师帮助解决课程文档分散、手动逐个下载耗时的痛点。它通过用户输入账号密码自动抓取当前账号下所有课程的全部文档并按照平台原有结构生成“教学大纲”“作业”“课程内容”等文件夹下载时仅深入一个子文件夹便于资料整理与离线查阅。资源包整体约 51.56MB内含可运行的 JAR 程序、Java 源代码及登录信息示例等源码便于二次开发或学习 Java 网络编程与解析逻辑。该工具支持安全存储用户名和密码读取后即时删除内存中的敏感内容兼顾便捷与隐私。目前已有 234 人学习适合需要批量备份黑板书架资料、构建本地课程归档的学习者使用无论是日常备份还是学期末归档都能节省大量手动操作时间。1. 一门课点开四五个目录才凑齐材料BlackboardDownloader 帮你一次打包每学期最后一次课结束总有一波人开始疯狂单击浏览器里的下载按钮。课件一份、实验指导三份、参考论文两篇分散在黑板的不同模块里点完还要在下载栏里确认文件到底存到了哪个目录。BlackboardDownloader 就是为这种重复劳动准备的在黑板系统里登录一次脚本自动遍历你有权限访问的所有课程把内容区里的全部附件按课程归集下载到本地硬盘。它适合课程多到懒得手动整理的在校生也适合需要批量备份自己课程资料的助教。跟着后文的分层解析和代码骨架你能在一个晚上跑通整个流程。2. 摸清黑板结构并顺利登录四个层级、两种登录方式、一个 Session2.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, timeout15) 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, datapayload, timeout15) # 登录成功后一般会跳到首页或机构页而不是停留在 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_discardTrue, ignore_expiresTrue) for c in cj: session.cookies.set(c.name, c.value, domainc.domain, pathc.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, timeout15, allow_redirectsTrue, ) 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, timeout15) soup BeautifulSoup(resp.text, html.parser) course_links [] # 优先解析 courseMenu 区块这个区块里只有课程 menu soup.find(idcourseMenu) or soup.find(ul, class_nav) if menu: for a in menu.find_all(a, hrefTrue): course_links.append(a) else: # 退回全页面匹配 course_id 参数 for a in soup.find_all(a, hrefTrue): if course_id in a[href]: course_links.append(a) result [] seen set() for a in course_links: name a.get_text(stripTrue) 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 才是文档地址。统一的解包函数如下它把三种形态收敛成一组干净的文件名和 URLimport 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, timeout15) soup BeautifulSoup(resp.text, html.parser) items [] for a in soup.find_all(a, hrefTrue): href a[href] text a.get_text(stripTrue) # 直接型黑板的文件存储统一走 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, srcTrue): 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, streamTrue, allow_redirectsTrue, timeout15) 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 里的 streamTrue 很关键它只发起请求读完响应头拿到最终 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 funnamed_{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, timeout30): 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, streamTrue, timeouttimeout) as r: r.raise_for_status() with open(path, wb) as f: for chunk in r.iter_content(chunk_size8192): f.write(chunk) return path, ok except Exception as exc: return path, ffailed: {exc} def batch_download(session, tasks, root_dirblackboard_files, max_workers4, delay_range(0.2, 0.8)): fail_log [] total 0 with ThreadPoolExecutor(max_workersmax_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_okTrue) 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, encodingutf-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 登录成功却继续被重定向到登录页现象脚本跑完登录请求课程页时响应是 302Location 指回登录页页面 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(headlessTrue) page browser.new_page() page.goto(url, timeout30_000) page.wait_for_selector(container_sel, timeout10_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 到 3delay_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, encodingutf-8) as f: return json.load(f) return {} def save_manifest(folder, manifest): with open(os.path.join(folder, .manifest.json), w, encodingutf-8) as f: json.dump(manifest, f, ensure_asciiFalse, indent2) 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 /fmacOS 或 Linux 下用 cron在 crontab 里加一行0 9 * * 0 cd /opt/blackboard_downloader python run.py run.log 21定时任务有个细节很容易翻车脚本要用绝对路径读 cookies.txt 和写日志因为定时任务的工作目录不一定是脚本所在目录。我一般会在脚本开头用 os.chdir(os.path.dirname(file)) 固定工作目录杜绝路径类问题的随机出现。我自己用这套流程的习惯是 manifest 一直保留不清空学期结束后手动删掉课程目录即可。增量同步真正解决了“下载器只服务一次”的问题让脚本融进每周的课程资料归档流程。这些坑踩过一轮之后现在每个新学期只需要刷新一次登录 cookie整个学期都不用再碰命令行。希望帮到你。本文还有配套的精品资源点击获取