简介:Blackboard Downloader 是一款面向高校师生及教学管理人员的 Java 工具,用于批量抓取 Blackboard 平台上的全部课程文档,解决逐门课程手动下载繁琐、易遗漏的问题。程序运行后提示输入 Blackboard 用户名与密码,自动登录并遍历课程内容,按平台原有结构建立本地目录,教学大纲归入「教学大纲」文件夹、当年作业归入「作业」文件夹,课程内容等模块同样对应存放,且仅深入一层子文件夹,避免过度抓取。资源包内含 Downloader.java 源码、登录信息.txt、README 说明及可执行的黑板下载器.jar,压缩包约 51.56MB,以 Java 源码与可运行程序为主,便于直接使用或二次开发。登录信息读取后即删除文件内容,兼顾安全存储。目前已有 234 人学习下载,适合需要批量归档课程资料、减少重复操作的学生与教师参考使用。
1. 从手动一页页点,到一条命令拉完整个学期的课件
每学期开学第一周,我都要干一件特别枯燥的事:登录教学平台,进每一门课,把课件、讲义、作业说明一个个点下来。十几门课、每门课几十个文件,鼠标点到手酸,还容易漏。后来我干脆写了个小工具,用 Java 把这件事自动化了——这就是 BlackboardDownloader 想解决的问题:把「在黑板(Blackboard 教学平台)上下载所有课程的所有文档」变成一次运行、全部搞定。
它适合两类人:一类是每学期被课件淹没、想省事的学生;另一类是想拿它当 Java 网络编程练手、顺手改成自己学校平台的开发者。核心逻辑不复杂,但把登录、遍历课程、解析文件列表、批量下载这几步串起来,能踩的坑一个不少。下面我按「它怎么工作 → 怎么跑起来 → 怎么改」的顺序拆一遍。
2. 架构与目录:一个请求从登录到落盘的完整链路
2.1 整体分层:登录层、遍历层、下载层
这个项目的骨架是典型的三段式,我把它拆成三层来看会更清楚。
第一层是会话层,负责跟教学平台建立并维持一个已登录的 HTTP 会话。教学平台基本都靠 Cookie 维持登录态,所以这一层的核心产物就是一个带着有效 Cookie 的HttpClient或者HttpURLConnection会话对象。登录成功后拿到的 Cookie 会被后续所有请求复用,这是整个工具能跑通的前提。
第二层是遍历层,负责拿到「我有哪些课」以及「每门课有哪些文件」。通常先请求课程列表页,从返回的 HTML 里解析出每门课的 ID 或链接;再对每门课请求其资料/文档页面,解析出文件条目和对应的下载地址。
第三层是下载层,拿着遍历层给出的文件 URL 列表,逐个发起下载请求,把字节流写到本地磁盘,并按课程名建子目录归档。
三层之间靠数据传递解耦:会话层产出session,遍历层产出List<Course>和List<FileItem>,下载层消费FileItem。这样改起来很舒服——想换平台,主要动遍历层的解析规则;想换存储方式,只动下载层。
2.2 目录结构长什么样
一个能跑的 Java 项目,目录大致是这样组织的:
BlackboardDownloader/ ├── pom.xml # Maven 依赖与构建配置 ├── src/main/java/ │ └── com/example/bbd/ │ ├── Main.java # 入口,读配置、串流程 │ ├── SessionManager.java # 登录 + Cookie 维持 │ ├── CourseCrawler.java # 课程列表解析 │ ├── FileCrawler.java # 单门课文件列表解析 │ ├── Downloader.java # 文件下载与落盘 │ └── model/ │ ├── Course.java │ └── FileItem.java └── src/main/resources/ └── config.properties # 平台地址、账号、下载根目录model包里两个类就是纯数据载体。Course至少要有id、name、url三个字段;FileItem要有fileName、downloadUrl、courseName。别小看这两个类,遍历层和下载层之间的所有信息都靠它们传递,字段设计得清楚,后面调试省一半力气。
2.3 一次完整请求的时序
把链路串起来看是这样的:Main读配置 →SessionManager登录拿到 Cookie →CourseCrawler用 Cookie 请求课程页,解析出课程列表 → 对每门课调FileCrawler,解析出文件列表 → 把所有FileItem汇总 →Downloader逐个下载,按课程名建目录写入。
这里有个容易被忽略的点:遍历和下载最好分开两趟。先把所有文件清单抓全、打印出来确认,再统一下载。如果边遍历边下载,一旦中途某个课程解析失败,你很难判断到底是没爬到还是没下下来。我一般会加一个--dry-run开关,只打印清单不下载,确认无误再真下。
3. 核心模块拆解:登录、解析、下载三段代码
3.1 会话维持:Cookie 是命根子
教学平台的登录流程通常是:GET 登录页拿到一个隐藏的 token 或 session id,POST 账号密码,服务端 Set-Cookie,之后所有请求带上这个 Cookie。用 Java 原生HttpURLConnection手动管 Cookie 是最直观的做法:
// SessionManager.java 核心片段 public class SessionManager { private String cookie = ""; // 第一步:GET 登录页,抓取隐藏字段和初始 Cookie public void initSession(String loginPageUrl) throws IOException { HttpURLConnection conn = (HttpURLConnection) new URL(loginPageUrl).openConnection(); conn.setRequestMethod("GET"); // 服务端可能在这里下发 JSESSIONID,必须存下来 String setCookie = conn.getHeaderField("Set-Cookie"); if (setCookie != null) { cookie = setCookie.split(";")[0]; } conn.disconnect(); } // 第二步:POST 账号密码,带上上一步的 Cookie public boolean login(String loginUrl, String username, String password) throws IOException { HttpURLConnection conn = (HttpURLConnection) new URL(loginUrl).openConnection(); conn.setRequestMethod("POST"); conn.setDoOutput(true); conn.setRequestProperty("Cookie", cookie); conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); String body = "username=" + URLEncoder.encode(username, "UTF-8") + "&password=" + URLEncoder.encode(password, "UTF-8"); try (OutputStream os = conn.getOutputStream()) { os.write(body.getBytes("UTF-8")); } // 登录成功后服务端会更新 Cookie,这里要覆盖保存 String newCookie = conn.getHeaderField("Set-Cookie"); if (newCookie != null) { cookie = newCookie.split(";")[0]; } return conn.getResponseCode() == 200; } public String getCookie() { return cookie; } }逻辑说明:initSession先访问登录页,目的是拿到服务端下发的初始 Cookie(很多平台会在这里种一个 JSESSIONID),同时如果登录表单里有隐藏的 token 字段,也要在这一步解析出来。login把账号密码拼成表单体 POST 出去,关键动作是把返回的新 Cookie 覆盖保存——登录成功后服务端往往会换一个新的会话标识,如果还用旧的,后续请求会被当成未登录。
参数说明:loginPageUrl是登录页地址,loginUrl是表单提交地址,这两个经常不是同一个,配置里要分开写。username、password从config.properties读,别硬编码在代码里。
提示:如果登录后请求课程页返回的是登录页 HTML,八成是 Cookie 没带对或者没更新。先把每次请求的
Set-Cookie打印出来对比。
3.2 课程与文件解析:HTML 里捞数据
拿到会话后,遍历层要做的就是从 HTML 里把课程和文件条目抠出来。教学平台的页面结构因学校而异,但套路一致:课程列表通常在一个带特定 class 的ul或table里,每个条目有个指向课程页的链接。
// CourseCrawler.java 核心片段 public List<Course> parseCourses(String html, String baseUrl) { List<Course> courses = new ArrayList<>(); // 用正则粗匹配课程链接,实际项目建议换 Jsoup Pattern p = Pattern.compile("<a[^>]+href=\"([^\"]+)\"[^>]*>([^<]+)</a>"); Matcher m = p.matcher(html); while (m.find()) { String href = m.group(1); String name = m.group(2).trim(); // 只保留看起来像课程入口的链接,过滤导航栏 if (href.contains("/course/") && !name.isEmpty()) { Course c = new Course(); c.setUrl(baseUrl + href); c.setName(name); courses.add(c); } } return courses; }逻辑说明:这里用正则做演示,是为了让你看清「从 HTML 抽链接」这个动作的本质。真实项目里我强烈建议换成 Jsoup,用 CSS 选择器定位,比正则稳得多——页面稍微改个属性顺序,正则就崩了。
参数说明:html是课程页返回的完整 HTML 字符串,baseUrl是站点根地址,用来把相对链接拼成绝对链接。过滤条件href.contains("/course/")是经验值,不同平台路径不一样,改这里就能适配。
文件列表的解析同理,只是选择器换成文件区域的容器。解析出来的每个文件条目,要拿到它的fileName和downloadUrl,塞进FileItem。
3.3 下载落盘:按课程建目录,流式写入
下载层是最实在的一层,核心就是「发请求 → 读输入流 → 写文件」。要点有两个:一是按课程名建子目录,避免所有文件堆在一起;二是用缓冲区流式写,别把整个文件读进内存。
// Downloader.java 核心片段 public void download(FileItem item, String rootDir, String cookie) throws IOException { // 按课程名建目录,文件名里的非法字符要替换 Path dir = Paths.get(rootDir, sanitize(item.getCourseName())); Files.createDirectories(dir); Path target = dir.resolve(sanitize(item.getFileName())); HttpURLConnection conn = (HttpURLConnection) new URL(item.getDownloadUrl()).openConnection(); conn.setRequestProperty("Cookie", cookie); try (InputStream in = conn.getInputStream(); BufferedOutputStream out = new BufferedOutputStream(Files.newOutputStream(target))) { byte[] buf = new byte[8192]; int len; while ((len = in.read(buf)) != -1) { out.write(buf, 0, len); } } conn.disconnect(); } // 把 Windows/Linux 文件名里的非法字符统一替换掉 private String sanitize(String name) { return name.replaceAll("[\\\\/:*?\"<>|]", "_"); }逻辑说明:Files.createDirectories会自动创建多级目录,已存在也不报错,适合反复运行。下载用 8KB 缓冲区循环读写,内存占用恒定,下大文件也不会 OOM。sanitize处理文件名里的非法字符——课程名里带斜杠、冒号是常事,不处理直接建文件会抛异常。
参数说明:rootDir是下载根目录,从配置读;cookie是会话层传下来的登录态。buf大小 8192 是通用值,网络差可以调大,但一般没必要。
注意:下载前最好先判断目标文件是否已存在,存在就跳过。否则每次重跑都会把几百个文件重新下一遍,浪费时间也浪费平台带宽。
4. 跑起来:配置、编译、执行与常见报错
4.1 环境与依赖准备
这个项目是纯 Java,跑起来只需要 JDK 8 及以上。如果用了 Maven 管理依赖(比如引入 Jsoup 做 HTML 解析),那还需要 Maven。我一般会这样配pom.xml的关键部分:
<dependencies> <!-- HTML 解析,比正则稳得多 --> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency> </dependencies>如果你不想引第三方库,纯 JDK 也能跑,只是解析部分要用正则硬扛。新手建议先引 Jsoup,少踩解析的坑。
4.2 配置文件怎么写
config.properties是唯一需要你改的地方,把平台地址和账号填进去:
# 教学平台根地址 platform.baseUrl=https://your-platform.example.edu # 登录页与表单提交地址 platform.loginPage=/webapps/login platform.loginUrl=/webapps/login/verify # 账号密码 auth.username=your_id auth.password=your_password # 下载根目录 download.rootDir=./downloads参数说明:baseUrl后面拼loginPage和loginUrl组成完整地址。download.rootDir用相对路径时,是相对于你执行命令的目录,建议用绝对路径避免找不到文件。
4.3 编译与运行
用 Maven 的话,两条命令搞定:
# 编译打包 mvn clean package # 运行,指定配置文件路径 java -jar target/blackboard-downloader.jar --config ./config.properties如果没用 Maven,手动编译也行:
javac -encoding UTF-8 -d out src/main/java/com/example/bbd/*.java java -cp out com.example.bbd.Main运行后你会看到控制台先打印登录结果,然后逐门课列出文件清单,最后开始下载。建议第一次加--dry-run参数,只打印不下载,确认清单对了再真下。
4.4 常见报错对照
| 现象 | 原因 | 解决 |
|---|---|---|
| 登录后课程页返回登录页 | Cookie 没更新或没带上 | 打印每次Set-Cookie,确认覆盖保存 |
| 课程列表为空 | 解析选择器/正则不匹配 | 把返回 HTML 存下来,对着结构改规则 |
| 下载文件是 0 字节 | 下载 URL 需要额外鉴权头 | 检查请求是否带 Cookie 和 Referer |
| 文件名乱码 | 编码不一致 | 统一用 UTF-8 读写,响应头看 charset |
| 中文文件名建目录失败 | 含非法字符 | 走sanitize替换 |
5. 避坑与排查:那些让我重跑好几遍的坑
5.1 坑一:登录成功但后续请求全被踢回登录页
现象是登录接口返回 200,但紧接着请求课程页,拿回来的 HTML 是登录页。原因通常是登录成功后服务端换了新的会话 Cookie,而代码里还在用登录前那个。解决方法是每次请求后都检查Set-Cookie响应头,有新的就覆盖保存。我一开始图省事只在初始化时存一次,结果卡了整整一个下午。
5.2 坑二:正则解析遇到嵌套标签直接崩
用正则匹配<a href="...">时,如果页面里链接标签带换行或者嵌套了<span>,正则就匹配不到或者匹配错。现象是课程列表少了几门,或者文件名带了一堆 HTML 标签。解决办法是换 Jsoup,用doc.select("a.course-link")这种选择器,结构变了改选择器就行,不用跟正则较劲。
5.3 坑三:文件名里的斜杠导致建目录失败
课程名或文件名里带/、:、?这些字符时,Paths.get会把它当成路径分隔符,直接抛InvalidPathException。现象是下载到一半突然报错退出。解决办法就是前面sanitize那一步,把所有非法字符统一替换成下划线。这个坑不分平台,只要文件名来自网页就一定会遇到。
5.4 坑四:重复运行把文件下一遍又一遍
没有存在性判断时,每次运行都会把所有文件重新下载。现象是第二次运行还是慢得要命,磁盘里出现一堆重复文件。解决办法是在下载前用Files.exists(target)判断,存在就跳过并打印「已存在」。想更严谨可以比对文件大小或最后修改时间。
5.5 坑五:请求太密被平台限流
一口气发几百个下载请求,平台可能返回 429 或直接断开连接。现象是下载到几十个之后开始大量失败。解决办法是在每次下载之间加一个短暂休眠,比如Thread.sleep(200),把节奏放慢。别小看这 200 毫秒,它能让整个任务稳定跑完,比失败重试省心得多。
6. 二次开发:换平台、加并发、做增量
拿到这个项目后,最值钱的改造方向有三个,我按投入产出比排一下。
第一,适配你自己的教学平台。这是最常见的需求。改动集中在CourseCrawler和FileCrawler两个类的解析规则上。做法是先用浏览器打开你的平台,F12 看课程列表和文件列表的 DOM 结构,找到稳定的选择器(优先用 id 或语义化 class,别用会变的样式类),然后替换掉代码里的选择器。会话层一般不用动,因为登录流程大同小异。改完先跑--dry-run验证清单,对了再下载。
第二,加并发下载。单线程下载几百个文件确实慢。可以用ExecutorService开一个固定大小的线程池,比如 4 到 8 个线程,把FileItem列表提交进去并行下载。但要注意两点:一是并发数别开太大,否则触发限流;二是每个线程要用独立的HttpURLConnection,别共享连接对象。下面是个简单的改造骨架:
// 并发下载骨架 ExecutorService pool = Executors.newFixedThreadPool(4); for (FileItem item : items) { pool.submit(() -> { try { downloader.download(item, rootDir, cookie); System.out.println("完成: " + item.getFileName()); } catch (IOException e) { System.err.println("失败: " + item.getFileName() + " -> " + e.getMessage()); } }); } pool.shutdown(); pool.awaitTermination(1, TimeUnit.HOURS);参数说明:newFixedThreadPool(4)里的 4 是并发数,建议从 4 起步,稳定了再往上加。awaitTermination保证主线程等所有下载任务结束再退出,不然程序会提前关掉。
第三,做增量下载。结合前面「文件已存在就跳过」的判断,再进一步:把每次下载的文件名和大小记到一个本地清单文件里,下次运行先读清单,只下新增或变更的文件。这样每学期只需要第一次全量下,之后每次运行都是秒级完成。清单格式用简单的文件名|大小一行一条就行,不用上数据库。
验证改造是否成功,我的习惯是准备一个小号课程(文件少的),先在小号上跑通全流程,确认登录、解析、下载、跳过都正常,再切到真实账号全量跑。这样即使解析规则写错,也不会浪费大量时间。
从那以后我每次改解析规则,都强制先跑一遍--dry-run把清单打出来肉眼核对,确认无误才真下——这个习惯帮我省了无数次重下的时间。希望这个思路能帮到你,把每学期的课件下载从体力活变成一条命令的事。
本文还有配套的精品资源,点击获取