资讯动态

BlackboardDownloader教程:用Python自动化批量下载课程文档

发布时间:2026/10/14 20:24:48 来源:尧图企业网站定制
简介BlackboardDownloader是一款基于Java开发的自动化下载工具面向需要从在线教学平台批量获取课程资料的学生、教师及教务人员。它通过用户输入的账号密码登录系统自动抓取账号下所有课程的全部文档并按照平台原有的目录结构保存到本地例如教学大纲、作业、课程内容等且仅深入一级子文件夹避免下载无关的深层文件便于按课程模块进行离线整理。安全方面程序在读取登录信息后立即清除文件内容防止账号密码长期留存于磁盘降低泄露风险。代码由单一Java文件构成结构简洁附带可执行的程序包、登录信息模板和使用说明文档压缩包总体积约51.56MB解压后既可直接运行也可阅读源码学习文件遍历与页面解析等知识点。目前已有234人学习下载适合需要定期备份课程资料又能兼顾Java编程实践的用户。1. BlackboardDownloader把所有课程文档一次性搬回本地BlackboardDownloader 是一段很实用的 Python 脚本它会在你登录 Blackboard也就是我们常说的黑板系统后自动遍历所有课程并把里面的 PDF、PPT、Word、Excel 等文档批量下载到本地。每到期末我总是习惯把所有课程资料备份到移动硬盘手动点开一门课、展开文档列表、挨个下载几十门课下来一下午就没了。BlackboardDownloader 要做的就是把这种重复劳动变成一条命令。它能解决两类人的问题一类是快毕业、担心课程资料被学校归档下线的学生一类是每学期都要整理教学素材的助教和老师。不管 Blackboard 是什么版本核心逻辑都在一起模拟登录、抓课程列表、解析文档链接、并发下载。你可以从第 3 章的代码开始跑也可以只借用第 4 章的下载思路。不过有一点必须说清楚它只下载你账号权限内可见的课程内容不要拿去碰没有授权的资料。作为个人备份工具它很顺手跑完之后生成的目录结构和下载报告能让你在半年后依然一眼找到某一门课的讲义。2. 为什么需要 BlackboardDownloader三种实现路线和选型依据2.1 手动下载为什么让人崩溃Blackboard 课程里的文档并不是集中在一个入口。讲师可能把 PPT 放在“课程文档”模块把作业说明挂在“作业”区把参考资料贴在讨论区甚至有人直接把 PDF 链接塞进博客。每个链接从点击到保存至少要经历三个动作打开预览页、等待新窗口弹出、点下载按钮。文件一多手就会形成肌肉记忆但这种高强度重复恰恰最容易漏。我见过有同学临近毕业才发现某门课的课堂录像一直躺在“隐藏”板块里课程归档后想补都补不了。也有人下载下来的文件名是一串uuid_....pdf几个月后再打开浏览器下载目录完全分不清哪个是哪个。手动下载真正的问题不是慢而是没有规则漏了、覆盖了、下载错版本了都不会有提示。BlackboardDownloader 的价值就在这里它用一套可复现的规则把“找文件、点下载、命名归档”全部接管。规则明确就意味着结果可对比你可以随时拿着下载报告去课程页面对数量少了哪个立刻知道。2.2 三种实现路线官方 API、网页爬虫、浏览器自动化在动手写代码前先想清楚走哪条路线。我见过很多同学一上来就用 Selenium结果跑一个几千文件的课程下载要花好几个小时其实用轻量 requests 就能完成。下面这张表概括了三种路线实现路线优点缺点适用场景官方 API数据干净、接口规则变化少、带宽大学校不开放、需要管理员权限校内统一身份验证做得少数的实例requests HTML 解析轻量、速度快、代码直观依赖页面结构改版后要跟着改文档链接出现在静态 HTML 中Selenium / Playwright能处理 JS 动态渲染、能模拟点击慢、内存占用高、调试成本高文档链接依赖点击按钮或滚动加载官方 API 的适用面其实很窄。Blackboard 有开放的 REST API但很多学校实例出于安全考虑不会对外开放就算开放了权限模型也要求申请专用 application key这个过程不见得比写爬虫快。所以对大多数个人备份场景requests 和浏览器自动化是主要候选。requests 方案的核心理念是像浏览器一样发送 HTTP 请求但丢掉渲染过程。它速度快、内存占用低适合一万个文件的批量下载。代价是你必须能从 HTML 或内部 JSON 接口里手工抠出文档地址比较考验细心。Playwright 方案则是完整运行一个 Chromium 实例页面怎么渲染它就怎么拿链接理解成本低但每个页面都要载入资源、跑脚本速度可能比 requests 慢十倍以上。如果你只求快速跑通我建议先做一个试探性实验登录后随便打开一个课程详情页用查看源代码的方式看看页面里是否直接包含文件链接。如果直接包含requests 方案就能落地如果不包含再考虑 Playwright。这一步基本上决定了 80% 的架构走向。2.3 选型理由与适用边界我的选择顺序是先花十分钟用浏览器开发者工具抓包看看登录后有没有现成的 JSON 接口。很多 Blackboard 实例内部有listCourses或getCourseDocuments一类接口能直接返回课程数据和文件元信息。如果找到了requests 就是最优解如果页面的文档列表需要点击“展开更多”才加载那才轮到 Playwright。登录方式也是决定因素。有的学校用的是统一身份认证登录页不在 Blackboard 域名下requests 要跟着 302 跳转好几次还要手工复制 cookie这种情况用 Playwright 反而更省心因为它会自动处理跳转和 cookie。我不会在一开始就认定某种技术最好而是通过抓包结果做决定。边界上BlackboardDownloader 只负责“拿到课程列表和文档 URL”不负责解析那些需要额外插件才能打开的加密格式。课件里的视频附件如果走的是流媒体协议下载下来的可能是分片文件需要额外拼接设计阶段就应该把这些排除掉否则脚本会看起来很努力但产出的东西没法用。我一般会为这个脚本保留两个模块一个downloaders用 requests一个browser_automation用 Playwright。遇到静态页面就走第一个遇到动态模块就走第二个其余下载、去重、日志逻辑完全共用。这样的代码结构改起来也不伤筋动骨。3. 用 Python requests 跑通最小下载命令登录、课程列表、文档链接3.1 抓包确认登录接口与参数Blackboard 的登录页面通常是一个普通表单但不同学校实例会在前端注入隐藏字段来防止 CSRF。所以在写代码前必须先抓包。打开浏览器开发者工具切到 Network 面板勾选 Preserve log然后正常登录一遍。找到那个 POST 请求路径大多以/webapps/login/开头查看请求体里的字段名通常是username、password以及一个隐藏的csrf_token。用 requests 模拟登录时需要用同一个 Session 对象先 GET 一次登录页把 token 抠出来再 POST 登录数据。这样做是为了让浏览器访问和脚本访问保持在同一个会话上下文中服务器才能确认你是同一个人。下面是一段最小可用登录代码import requests from bs4 import BeautifulSoup session requests.Session() login_page_url https://blackboard.example.com/webapps/login/ resp session.get(login_page_url, timeout10) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) # 登录页里可能还有其它隐藏字段一起提取避免遗漏 form_data {} for inp in soup.find_all(input): name inp.get(name) if not name: continue value inp.get(value, ) form_data[name] value # 请求体里真正有用的名字以抓包为准不一定是这里用的两个 form_data[username] your_username form_data[password] your_password resp session.post(login_page_url, dataform_data, timeout10) print(登录状态码:, resp.status_code) print(登录后地址:, resp.url) # 登录失败时一般会停留在 login 页面打印一小段响应体辅助排查 if login in resp.url: print(resp.text[:500])这段代码的套路是先把登录页里所有input字段都提取出来填入用户名密码再提交表单。这样做能适应不同实例对额外字段的要求不会因为少传了某个隐藏字段而登录失败。注意有些学校启用了统一身份认证登录页会先跳转到另一个域名然后再带一个 ticket 调回来这种情况下上面的代码会失败你需要在 Network 面板里把整个跳转链都记录下来或者干脆跳到最后一章用 Playwright。3.2 获取课程列表的脚本骨架登录成功后下一步是拿到当前账号能看到的所有课程。我习惯先打开课程列表首页然后看 Network 面板里有没有一个单独的 JSON 请求。通常这个请求会返回course_id、course_name和一条进入课程的入口链接。找到后直接用 requests 请求并保存成本地文件方便后续断点续传。import json courses_api https://blackboard.example.com/webapps/bb-blackboard-execute/courseList params { cmd: getCourses, user_id: session.cookies.get(uid, ) } resp session.get(courses_api, paramsparams, timeout10) try: data resp.json() courses data.get(data, data.get(results, [])) for course in courses: print(course.get(course_id), course.get(name)) with open(courses.json, w, encodingutf-8) as fp: json.dump(courses, fp, ensure_asciiFalse, indent2) except Exception: # 回退到 HTML 解析很多 Blackboard 实例返回的是课程卡片页面 courses_page https://blackboard.example.com/webapps/portal/execute/tabs/tabAction resp session.get(courses_page, timeout10) soup BeautifulSoup(resp.text, html.parser) courses [] for a in soup.select(a[href*course_id]): course_name a.get_text(stripTrue) if course_name: courses.append({name: course_name, url: a[href]}) print(courses)这段代码给了两条路有 JSON 就解析 JSON没有就解析 HTML。很多实例的返回结构是{data: [...]}有的是{results: [...]}用get方法同时兼容两种情况可以少一个分支。这里的关键是把课程 ID 和课程名分开存为后面的目录命名做准备。还有一点课程列表接口的返回结构在不同实例里差别很大。我的做法是打印原始响应的前 500 个字符先看清楚再写解析代码不要盲目套模板。这个习惯能省掉大量的调 bug 时间。3.3 解析课程文档链接的两种方式拿到课程列表后进入单门课程的详情页用 BeautifulSoup 提取所有带文档扩展名的链接。这是最直接的方式实现如下from urllib.parse import urljoin from bs4 import BeautifulSoup def extract_doc_links(page_url, html_text): soup BeautifulSoup(html_text, html.parser) doc_exts {.pdf, .doc, .docx, .ppt, .pptx, .xlsx, .zip, .rar, .txt} links [] for a in soup.find_all(a, hrefTrue): href a[href].lower() if any(href.endswith(ext) for ext in doc_exts): abs_url urljoin(page_url, a[href]) links.append(abs_url) return links这个函数覆盖了绝大多数静态课程文档。但 Blackboard 还有另一种链接格式点击“下载”后请求的是/bbcswebdav/...或者/downloadDocument?documentIdxxx。这种 URL 不包含扩展名需要通过响应头里的Content-Disposition来推断文件名。所以我在抓取时会同时记录所有包含download、document、bbcswebdav等关键词的链接最后统一跟踪重定向。两种方式的取舍第一种代码简单但可能漏掉部分“接口下载”类型的文件第二种能覆盖接口型下载但在列表页无法预知文件名需要在下载时再处理。我一般先跑第一种用结果数量和课程页面对比如果少了再补第二种。这样不会一上来就面对一堆id参数。4. 把文档批量落地目录结构、并发下载、断点续传与校验4.1 目录结构设计按学期、课程、模块归档下载到本地后最重要的事情是能找回来。我采用备份根目录/学期/课程ID_课程名/模块名/文件名的结构。课程 ID 用系统原始 ID避免同名课程互相覆盖课程名要清理掉空格和特殊字符。import re from pathlib import Path def sanitize_name(name): # 去掉 Windows / macOS 文件名禁用的字符 name re.sub(r[\\/:*?|\r\n], _, name) name re.sub(r\s, , name).strip() return name[:80] # 过长截断防止超长路径 base_root Path(blackboard_backup) semester 2025-春季 course_id CS101 course_name sanitize_name(软件工程基础) target_dir base_root / semester / f{course_id}_{course_name} target_dir.mkdir(parentsTrue, exist_okTrue) print(保存到:, target_dir)这里有两层考虑一是course_id是唯一的带上前缀后哪怕课程名重复也不会合并到同一个文件夹二是路径总长度在 Windows 上最好控制在 200 个字符以内截断 80 个字符是最保守的做法。mkdir(parentsTrue, exist_okTrue)的用法要多说一句多线程下载同一目录时一个线程先建好了另一个线程再调用不会报错所以可以放心在每个下载任务里重复执行。4.2 并发下载与失败重试下载阶段不要一次开 100 个线程Blackboard 服务器对单个用户并发有限制。一般 4 到 8 个线程就到了收益边界。使用ThreadPoolExecutor时记得给每个线程传同一个 session因为 cookie 是线程安全的读取但不要同时往同一个请求会话里灌大量并发容易触发限流。from concurrent.futures import ThreadPoolExecutor, as_completed def download_file(download_url, dest_path, session, timeout(10, 60)): try: with session.get(download_url, streamTrue, timeouttimeout) as resp: if resp.status_code ! 200: return False, resp.status_code total_size int(resp.headers.get(Content-Length, 0)) written 0 with open(dest_path, wb) as fp: for chunk in resp.iter_content(chunk_size65536): if chunk: fp.write(chunk) written len(chunk) # 有 Content-Length 时校验大小没有就暂存 return written total_size if total_size else True, resp.status_code except Exception as exc: return False, str(exc) with ThreadPoolExecutor(max_workers4) as executor: future_map { executor.submit(download_file, url, dest, session): (url, dest) for url, dest in download_plan } for future in as_completed(future_map): url, dest future_map[future] ok, info future.result() if not ok: print(下载失败可重试:, url, info)streamTrue必须加否则大文件会被一次性读进内存课程里偶尔有几百 MB 的视频附件会直接吃光内存。timeout设置 10 秒连接、60 秒读取比较适合跨校园网下载。失败的文件不要直接扔掉写进retry.txt下次运行脚本时读入重试。常见失败是网络抖动重试两次基本能过。4.3 校验文件完整性与命名去重服务器返回的Content-Length可以帮我们确认文件没有断开。当返回头里没这个字段时就只能用本地文件大小和课程页面显示的文件大小做一次粗略比较。更可靠的做法是下载后计算哈希但完整哈希比较对超大文件挺耗时我一般在排查时才开。命名去重是容易翻车的点。不同目录下出现同名文件很正常只要放到不同子目录就不会冲突但同一目录下同名文件出现两次不处理就会互相覆盖。我采用“文件名_副本序号”的规则def unique_dest(dest_path): if not dest_path.exists(): return dest_path stem dest_path.stem suffix dest_path.suffix parent dest_path.parent index 1 while True: new_dest parent / f{stem}_{index}{suffix} if not new_dest.exists(): return new_dest index 1注意这个函数只解决“本地已有文件”的去重。如果一个文件已经在脚本前半段被排在任务里但下载还没完成另一个计划又指向了它仍可能冲突。所以我在生成下载计划时先用 URL 集合去重同一个 URL 只保留一个目标地址。URL 去重比文件名去重更可靠。4.4 断点续传与日志记录下载计划生成后先写一份plan.json包含每个任务的 URL 和目标路径。下载过程中用日志记录成功、失败、跳过。下一次运行读取plan.json如果目标文件已存在且大小不为 0就跳过如果之前失败则重试。这样可以避免每次都从头开始。import json from pathlib import Path plan_file Path(download_plan.json) if plan_file.exists(): download_plan json.loads(plan_file.read_text(encodingutf-8)) else: download_plan build_plan(courses) # 生成课程文档下载计划 plan_file.write_text(json.dumps(download_plan, ensure_asciiFalse, indent2), encodingutf-8) with open(download.log, a, encodingutf-8) as log_fp: for url, dest in download_plan: if Path(dest).exists() and Path(dest).stat().st_size 0: log_fp.write(fSKIP {dest}\n) continue log_fp.write(fTODO {url} - {dest}\n)plan.json相当于一个可复现的快照即使脚本中途被杀下次启动也能接着跑。日志文件保留 SKIP 和 TODO 两种状态方便你对比“哪些没下”。我最喜欢的一点是这个文件本身就是下载报告的一部分不用额外生成。5. BlackboardDownloader 避坑指南5 个常见问题与排查5.1 坑一登录态过期导致 401 循环现象脚本下载到一半突然连续弹出 401 Unauthorized并且之后所有请求都失败。原因Blackboard 的会话有效期通常在 30 到 60 分钟。爬虫脚本跑得慢或者课程文件特别多时第一个请求的会话已经过期后续一直沿用旧 session。解决在请求封装里加一个统一的状态码检查遇到 401 就自动重新登录然后把重试请求再发一次。重新登录时要复用同一个Session对象因为里面有些 cookie 可能和会话绑定。def safe_get(url, session, login_func, max_retry2): resp session.get(url, timeout10) if resp.status_code 401 and max_retry 0: print(401重新登录后重试) login_func(session) return safe_get(url, session, login_func, max_retry - 1) return resp5.2 坑二文件名非法字符与路径超长现象下载到第 80 个文件时抛出OSError: [Errno 22] Invalid argument脚本直接中断。原因Blackboard 允许文件名包含/、?等特殊字符Windows 文件系统不允许或者课程名过长导致完整路径超过 260 字符限制。解决把sanitize_name放在所有路径生成之前并且强制截断。路径层级不要搞成学期/学院/专业/课程/模块/文件这种五级结构改成学期/课程ID_课程名/文件把路径总长压缩到最短。还要留意.url文件里可能藏有超长链接下载时用本地短文件名链接写进内容即可。5.3 坑三分页接口只返回前 50 条现象下载完后统计发现文件数量比课程页面上显示的少了 30 个而且全是列表后半部分。原因Blackboard 的课程文档列表默认一次只加载 50 条需要点击“下一组”或滚动加载。requests 直接请求只拿到第一页。解决解析 JSON 响应中的分页字段。常见字段为totalRows、pageNumber、pageSize用pageNumber从 1 开始循环直到获取条数等于总数。如果页面是滚动加载则可以通过 Network 面板找到startOffset和maxRows参数像这样循环def fetch_all_docs(session, api_url, params, max_rows200): all_docs [] start 0 while True: params[startOffset] start params[maxRows] max_rows data session.get(api_url, paramsparams).json() docs data.get(results, []) all_docs.extend(docs) total data.get(totalResults, 0) start max_rows if not docs or start total: break return all_docs这里max_rows很多人喜欢填 1000但服务器可能会被压出超时我一般用 100 到 200 比较合适。5.4 坑四缺少 Referer 头导致 403 Forbidden现象requests 能正常登录但下载链接请求返回 403。原因Blackboard 对需要课件鉴权的文件做了 Referer 校验判断请求是否来自课程页面。解决从抓包里把下载请求的Referer和User-Agent原样复制到 headers。注意 Referer 要填下载按钮所在的那个课程页面 URL不是首页。headers { User-Agent: Mozilla/5.0 ..., Referer: https://blackboard.example.com/webapps/blackboard/execute/modulepage?course_idxxx } resp session.get(download_url, headersheaders, streamTrue)如果改了 Referer 还是 403检查是不是需要带X-Requested-With: XMLHttpRequest头某些内部接口用这个字段区分 AJAX 请求。5.5 坑五下载到一半磁盘空间不足现象下载视频或压缩包时报No space left on device脚本直接崩溃已经下载的文件也乱成一团。原因没有预估所有文件的总大小或者把临时文件写到了空间不足的系统盘。解决下载前把Content-Length汇总得到总大小再检查目标磁盘剩余空间预留至少 10% 的余量。如果空间不够就提示用户换目录。代码里可以在每次下载前检查一次import shutil total_need sum(size for _, _, size in download_plan) free_space shutil.disk_usage(target_dir).free if free_space total_need * 1.1: raise RuntimeError(磁盘空间不足先给备份腾地方)这个坑最常见的场景是下载列表里混入几个 GB 级视频光看文件名完全感觉不出来。检查磁盘空间不是可选项而是批量下载的必备步骤。5.6 避坑自检清单跑完一遍后我习惯对照这个清单检查courses.json中课程数量和页面显示是否一致每个课程的下载文件数是否大于等于课程页面的文件数日志中 401 和 403 的数量是否为 0磁盘剩余空间是否还在安全线以上。只要这几项都通过这批备份基本可以放心归档。6. 进阶用无头浏览器处理 JS 动态模块并核对下载结果6.1 什么时候必须上 Playwright当你发现文档列表在页面滚动到某个位置才加载或者点击“展开全部”才发出新接口时requests 策略就失效了。这种场景在 Blackboard 新版界面里不算少尤其“课程文件”模块用了大量前端渲染。这时用 Playwright 模拟真人点击是最省事的补全手段。6.2 最小 Playwright 下载方案from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://blackboard.example.com/webapps/login/) page.fill(input[nameusername], your_username) page.fill(input[namepassword], your_password) page.click(button[typesubmit]) page.wait_for_load_state(networkidle) # 获取课程页里所有文档链接 doc_urls page.eval_on_selector_all( a[href*download], els els.map(e e.href) ) print(doc_urls[:5]) browser.close()这段代码里headlessTrue表示无头模式调试时改成False能看到浏览器动作。wait_for_load_state(networkidle)等所有网络请求结束比固定 sleep 更可靠。拿到链接之后剩下的下载逻辑可以复用第 4 章的并发下载代码不冲突。6.3 验证下载结果的一个技巧跑完后我会生成一个download_report.txt包含每个文件的大小和目标路径。然后把它和课程页面里统计的文件数比一比。如果少了文件先查是不是分页问题再查是否是 403。这个习惯让我少踩很多次坑。最后想说的是BlackboardDownloader 这类工具不需要写得多炫能把重复下载自动化、故障可排查就已经很值了。我现在每次跑完都会先看日志里的 401 和重试次数再决定要不要手动补几个文件。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑