Browse Source

202506050218

main
wangminjun 1 month ago
parent
commit
b44ba6780c
  1. 4
      w11/AI协助记录.txt
  2. 4
      w11/java-cli/.gitignore
  3. 3
      w11/java-cli/.vscode/settings.json
  4. 17
      w11/java-cli/README.md
  5. 492
      w11/java-cli/W10 PPT.md
  6. 7
      w11/java-cli/data/古诗文数据.json
  7. 81
      w11/java-cli/data/古诗文数据.txt
  8. 7
      w11/java-cli/data/豆瓣电影评分.json
  9. 30
      w11/java-cli/data/豆瓣电影评分.txt
  10. 7
      w11/java-cli/data/长沙天气.json
  11. 28
      w11/java-cli/data/长沙天气.txt
  12. 4
      w11/java-cli/java-cli/.gitignore
  13. 78
      w11/java-cli/pom.xml
  14. 17
      w11/java-cli/src/main/java/com/example/datacollect/Main.java
  15. 90
      w11/java-cli/src/main/java/com/example/datacollect/TestCrawler.java
  16. 73
      w11/java-cli/src/main/java/com/example/datacollect/command/AnalyzeCommand.java
  17. 6
      w11/java-cli/src/main/java/com/example/datacollect/command/Command.java
  18. 58
      w11/java-cli/src/main/java/com/example/datacollect/command/CommandResult.java
  19. 72
      w11/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java
  20. 17
      w11/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java
  21. 38
      w11/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java
  22. 28
      w11/java-cli/src/main/java/com/example/datacollect/command/HistoryCommand.java
  23. 25
      w11/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java
  24. 73
      w11/java-cli/src/main/java/com/example/datacollect/command/LoadCommand.java
  25. 102
      w11/java-cli/src/main/java/com/example/datacollect/command/SaveCommand.java
  26. 99
      w11/java-cli/src/main/java/com/example/datacollect/controller/CrawlerController.java
  27. 12
      w11/java-cli/src/main/java/com/example/datacollect/exception/CrawlerException.java
  28. 20
      w11/java-cli/src/main/java/com/example/datacollect/exception/NetworkException.java
  29. 20
      w11/java-cli/src/main/java/com/example/datacollect/exception/ParseException.java
  30. 65
      w11/java-cli/src/main/java/com/example/datacollect/model/Article.java
  31. 121
      w11/java-cli/src/main/java/com/example/datacollect/repository/ArticleRepository.java
  32. 105
      w11/java-cli/src/main/java/com/example/datacollect/service/CrawlerService.java
  33. 164
      w11/java-cli/src/main/java/com/example/datacollect/service/DataAnalysisService.java
  34. 334
      w11/java-cli/src/main/java/com/example/datacollect/service/DataStorageService.java
  35. 35
      w11/java-cli/src/main/java/com/example/datacollect/strategy/BlogStrategy.java
  36. 69
      w11/java-cli/src/main/java/com/example/datacollect/strategy/CrawlStrategy.java
  37. 137
      w11/java-cli/src/main/java/com/example/datacollect/strategy/DoubanMovieStrategy.java
  38. 109
      w11/java-cli/src/main/java/com/example/datacollect/strategy/GushiwenStrategy.java
  39. 40
      w11/java-cli/src/main/java/com/example/datacollect/strategy/NewStrategy.java
  40. 8
      w11/java-cli/src/main/java/com/example/datacollect/strategy/Strategy.java
  41. 38
      w11/java-cli/src/main/java/com/example/datacollect/strategy/StrategyFactory.java
  42. 127
      w11/java-cli/src/main/java/com/example/datacollect/strategy/WeatherStrategy.java
  43. 42
      w11/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java
  44. 43
      w11/java-cli/src/main/resources/logback.xml
  45. 758
      w11/java-cli/target/W9工程架构 - 教案v3.md
  46. 43
      w11/java-cli/target/classes/logback.xml
  47. 5
      w11/java-cli/target/maven-archiver/pom.properties
  48. 0
      w11/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
  49. 30
      w11/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
  50. 530
      w11/java-cli/target/w9-ppt.md
  51. 705
      w11/java-cli/第10周——设计模式:灵活性与可扩展性.md
  52. 7
      w11/爬虫运行结果/data/古诗文数据.json
  53. 81
      w11/爬虫运行结果/data/古诗文数据.txt
  54. 7
      w11/爬虫运行结果/data/豆瓣电影评分.json
  55. 30
      w11/爬虫运行结果/data/豆瓣电影评分.txt
  56. 7
      w11/爬虫运行结果/data/长沙天气.json
  57. 28
      w11/爬虫运行结果/data/长沙天气.txt

4
w11/AI协助记录.txt

@ -0,0 +1,4 @@
1.让AI帮我添加Logger成员,并在execute方法开头添加logger.info
2.让AI帮我新建exception包,声明throws ParseException,添加重试逻辑,添加logback.xml配置,为Repository增加防御
3.让AI测试运行代码
4.让AI修改存储格式,改成中文形式

4
w11/java-cli/.gitignore

@ -0,0 +1,4 @@
*.jar
*.jar
*.class
*.log

3
w11/java-cli/.vscode/settings.json

@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}

17
w11/java-cli/README.md

@ -0,0 +1,17 @@
# DataCollect 教学项目 — 最小可运行版本
这是一个最小可用的 Java CLI 演示工程,目标:打印帮助信息以验证运行环境。
构建:
```bash
mvn -q package
```
运行(示例):
```bash
java -jar target/datacollect-cli-0.1.0-jar-with-dependencies.jar --help
```
项目结构(最小):
- `src/main/java/com/example/datacollect/Main.java` — CLI 入口,打印帮助
- `pom.xml` — Maven 构建配置,生成可执行 jar

492
w11/java-cli/W10 PPT.md

@ -0,0 +1,492 @@
---
id: "24"
title: w10-设计模式
slug: w10-design-patterns
status: draft
view_count: 0
created_at: 2026-05-07T12:00:00+08:00
updated_at: 2026-05-07T14:00:00.000000000+08:00
---
# 高级程序设计 · 第10周
### 设计模式:灵活性与可扩展性
### 策略模式 + 工厂 + Repository 实战
---
### 📌 本周导航
- W9回顾:骨架的成就与隐患
- 策略模式:解析器的“插头标准”
- 解析器工厂:自动匹配的魔法
- Repository:武装数据访问
- 整体架构串联:调用链全程
- 代码落地 + 实践任务
- 架构反思 + W11 预告
---
## 1️⃣ W9回顾:骨架的成就与隐患
### 我们建了一座漂亮的房子
- ✅ MVC 分层清晰
- ✅ Command 模式:**新增命令,Controller 零改动**
- ✅ 所有输出走 `ConsoleView`
- ✅ 工程包结构标准
---
### 但问题也随之而来
```java
// CrawlCommand 里解析逻辑怎么办?
if (url.contains("blog.example.com")) {
// 博客解析...
} else if (url.contains("news.example.com")) {
// 新闻解析...
} else {
view.printError("Unsupported website!");
}
```
> 😫 每支持一个新网站,就要加一个 `else if`
---
### 还有另一个“裸奔”的数据
```java
List<Article> articles = new ArrayList<>();
// 所有 Command 都可以:
articles.clear();
articles.add(null);
articles.remove(0);
```
> 🚨 数据没有任何保护,靠口头约定是靠不住的
---
### 本周任务
1. **解析逻辑可插拔** → 策略模式 + 工厂
2. **数据访问加守卫** → Repository 模式
> W9 搭骨架,W10 装盔甲
---
## 2️⃣ 策略模式:解析器的“插头标准”
### 墙上的插座,为什么什么电器都能插?
- **三孔插座** 是标准接口
- 电视、电脑、手机充电器都实现这个接口
- 插座不关心你是什么电器
---
### 爬虫的世界也一样
- `CrawlStrategy` = 插座接口
- `BlogStrategy`、`NewsStrategy` = 具体电器
- `CrawlCommand` = 使用电器的人
- `StrategyFactory` = 插座面板
---
### 接口即合同
```java
public interface CrawlStrategy {
List<Article> parse(String url, Document doc);
boolean supports(String url);
}
```
- `supports()`:我能不能处理这个 URL?
- `parse()`:怎么解析?
- **任何网站想被爬,签这份合同!**
---
### 策略 vs 硬编码
| 维度 | if-else 屎山 | 策略模式 |
|------|-------------|----------|
| 新增网站 | 改 Command | 新建策略类 |
| 修改解析 | 翻找 else if | 只改对应类 |
| 测试 | 启动整个爬虫 | 单独测策略 |
| 开闭原则 | ❌ 修改开放 | ✅ 扩展开放,修改关闭 |
---
### 具体策略示例
```java
public class BlogStrategy implements CrawlStrategy {
public boolean supports(String url) {
return url.contains("blog.example.com");
}
public List<Article> parse(String url, Document doc) {
List<Article> articles = new ArrayList<>();
for (Element e : doc.select(".post-title")) {
articles.add(new Article(e.text(), url, ""));
}
return articles;
}
}
```
> ✨ 一个新网站,一个独立类,各扫门前雪
---
## 3️⃣ 解析器工厂:自动匹配的魔法
### 谁来选择策略?
- 如果 `CrawlCommand` 遍历所有策略 → 策略模式白用了
- 我们需要一个黑盒子:**丢入 URL,返回合适的解析器**
---
### 工厂登场
```java
public class StrategyFactory {
private final List<CrawlStrategy> strategies = new ArrayList<>();
public StrategyFactory() {
strategies.add(new BlogStrategy());
strategies.add(new NewsStrategy());
}
public CrawlStrategy getStrategy(String url) {
for (CrawlStrategy s : strategies) {
if (s.supports(url)) return s;
}
return null;
}
}
```
> 🔧 新增网站只需:新建策略类 + 工厂里注册一行
---
### 开闭原则的胜利
- ✅ `CrawlCommand` 完全不改
- ✅ 新增 `XxxStrategy` 和一行注册
- ✅ 所有策略的调用方式完全一致
> 这就是 **“对扩展开放,对修改关闭”**
---
### 重构后的 CrawlCommand
```java
public void execute(String[] args, ArticleRepository repository) {
String url = args[1];
CrawlStrategy strategy = strategyFactory.getStrategy(url);
if (strategy == null) {
view.printError("No strategy for: " + url);
return;
}
Document doc = Jsoup.connect(url).get();
List<Article> parsed = strategy.parse(url, doc);
for (Article a : parsed) {
repository.add(a);
}
view.printSuccess("Crawled " + parsed.size() + " articles.");
}
```
> 🧠 CrawlCommand 现在只做 **“调度”**,不做解析
---
## 4️⃣ Repository:武装数据访问
### 共享 List 的问题
```java
articles.clear(); // 清空
articles.add(null); // 塞 null
articles.remove(0); // 随意删除
```
> 靠约定维护的秩序,终将被打破
---
### 给数据装上防盗门
```java
public class ArticleRepository {
private final List<Article> articles = new ArrayList<>();
public void add(Article article) {
if (article == null) throw new IllegalArgumentException(...);
articles.add(article);
}
public List<Article> getAll() {
return Collections.unmodifiableList(articles);
}
public int size() { return articles.size(); }
public void clear() { articles.clear(); }
}
```
---
### 三道防线
| 机制 | 作用 |
|------|------|
| **add 拒绝 null** | 规则写在代码里,不靠口头约定 |
| **getAll 返回不可变视图** | 任何修改立即抛异常 |
| **必须通过 repository 访问** | 封装内部结构,只暴露安全方法 |
---
### 所有 Command 签名改变
```java
// W9
public void execute(String[] args, List<Article> articles);
// W10
public void execute(String[] args, ArticleRepository repository);
```
> 语义变化:从“给你数据随便玩” → “给你安全的存取通道”
---
## 5️⃣ 整体架构串联
### 一个 `crawl` 命令的完整旅程
```
用户输入 "crawl https://blog.example.com"
ConsoleView 解析
Controller 路由 → CrawlCommand
StrategyFactory.getStrategy(url) → BlogStrategy
Jsoup 抓取 → Document
BlogStrategy.parse(url, doc) → List<Article>
Repository.add() 存储
ConsoleView 输出成功信息
```
---
### 架构全景图
![mvc-strategy-repo](/api/v1/attachments/8 "width=70% center")
```mermaid
flowchart TD
User(["👤 用户输入<br/>crawl https://blog.example.com"]) --> View
subgraph View["🎨 View 层 (ConsoleView)"]
ReadLine["readLine()"]
Display["display() / printSuccess()"]
end
ReadLine --> Controller
subgraph Controller["🧭 Controller 层"]
Router["CrawlerController<br/>Map 路由"]
end
Router --> Command
subgraph Command["⚡ Command 层"]
CrawlCmd["CrawlCommand<br/>(调度者)"]
end
CrawlCmd --> Factory
subgraph Strategy["🧩 Strategy 层"]
Factory["StrategyFactory<br/>(自动匹配)"]
StrategyI["<<interface>> CrawlStrategy"]
BlogS["BlogStrategy"]
NewsS["NewsStrategy"]
Factory --> StrategyI --> BlogS
StrategyI --> NewsS
end
BlogS --> Repository
subgraph Repository["🔐 Repository 层"]
Repo["ArticleRepository<br/>(add / getAll)"]
RepoList["List<Article> (私有)"]
Repo --> RepoList
end
RepoList --> Model
subgraph Model["📦 Model 层"]
Article["Article"]
end
CrawlCmd --> Display
Repository --> Display
```
> 🗺️ 每一层都有清晰的职责,每一处扩展都只需要新增而不是修改
---
## 6️⃣ 代码落地(分步升级)
### 从 W9 升级到 W10 的改动清单
1. 新建 `strategy/` 包 → `CrawlStrategy` 接口
2. 实现 `BlogStrategy`、`NewsStrategy`
3. 实现 `StrategyFactory`
4. 新建 `repository/` 包 → `ArticleRepository`
5. 修改 `Command` 接口签名
6. 重写 `CrawlCommand`
7. 调整其他所有 `Command`
8. 调整 `Controller``App.java`
---
### 关键代码演示
- `Collections.unmodifiableList()` 的用法
- `StrategyFactory.getStrategy()` 的遍历逻辑
- `CrawlCommand` 从“写死解析”到“调度组装”
```java
// 一个改动示例
for (Article a : parsed) {
repository.add(a); // 旧: articles.add(a);
}
```
---
### 找茬点
- `StrategyFactory` 没匹配到策略时返回 `null`
- `CrawlCommand` 检查 `null` 并报错
- 有没有更优雅的方式避免 `null` 判断?
> 🔍 课后用 AI 探索 “空对象模式” 的前奏
---
## 7️⃣ 架构反思 + 下周预告
### 当前架构的脆弱点
- ❌ 异常处理单一笼统
- ❌ 没有重试机制
- ❌ 网络超时无控制
- ❌ 日志仅输出到终端
---
### W11 目标:健壮性工程
- ✅ **自定义异常体系**:把“出错了”变成具体的业务异常
- ✅ **工程化日志**:记录谁、什么时间、做了什么
- ✅ **防御式编程 + 重试机制**:网络抖动不再致命
> W9 搭骨架 → W10 装盔甲 → W11 让它经得起毒打
---
## 8️⃣ 实践任务(现场)
### 必做
1. 基于 W9 项目升级到 W10
2. 至少实现 2 个 CrawlStrategy(可模拟)
3. 实现 `StrategyFactory``ArticleRepository`
4. 测试完整 `crawl``list` 流程
### 验收标准
- [ ] 新增策略只加类+注册,零改动旧代码
- [ ] `getAll()` 返回不可修改视图
- [ ] `CrawlCommand` 不含网站特定解析
- [ ] 所有 Command 用 Repository
- [ ] 无地方直接操作 `List<Article>`
---
## 9️⃣ 课后作业
### 必做
1. 完善 `ArticleRepository`:增加 `addAll`,防御 null
2. **★ AnalyzeCommand**:复用策略解析但不存储,输出统计信息
3. **AI 架构审计**:发送类签名给 AI,检查策略解耦与封装
### 选做
- 正则策略匹配、默认策略、策略优先级
- 思考题:两个策略都 `supports` 同一 URL 时怎么办?
---
## 🤖 AI 协同升级
### 架构审计师(必做)
- 画出类依赖图
- 发给 AI:“检查开闭原则达成度,Repository 封装完备性,是否存在循环依赖”
### 进阶探究
- 不用工厂,直接用 `Map<String, CrawlStrategy>` 存起来 vs `StrategyFactory` 的区别?
---
## 📚 总结
- ✅ 策略模式:算法可插拔,新增网站零痛苦
- ✅ 工厂:自动匹配,URL → 策略的魔法
- ✅ Repository:数据守卫,规则从口头约定变成代码强制
- ✅ 架构:从“分开”到“优雅合上”,对扩展开放,对修改关闭
### W11 预告
自定义异常体系 + 日志 + 重试机制
> 🚀 让我们造的爬虫,经得住现实的考验
---
## 谢谢!
**保持工程洁癖,下周见!**
---
# 居中标题
## 居中副标题
### 居中内容
---

7
w11/java-cli/data/古诗文数据.json

File diff suppressed because one or more lines are too long

81
w11/java-cli/data/古诗文数据.txt

@ -0,0 +1,81 @@
========================================
爬虫数据采集结果
========================================
生成时间: 2026年05月19日 10:44:25
========================================
【标题】
Gushiwen Collection
【作者】
Gushiwen
【发布日期】
2026-05-19
【来源链接】
https://www.gushiwen.cn/
【内容】
────────────────
No poems found. Trying alternative selector...
薄宦频移疾,当年久索居。哀同庾开府,瘦极沈尚书。城绿新阴远,江清返照虚。所思惟翰墨,从古待双鱼。——唐代·李商隐《有怀在蒙飞卿》https://www.guwendao.net/shiwenv_29f8b0666c1e.aspx
有怀在蒙飞卿 李商隐〔唐代〕 薄宦频移疾,当年久索居。 哀同庾开府,瘦极沈尚书。 城绿新阴远,江清返照虚。 所思惟翰墨,从古待双鱼。 完善 感怀 抒怀
凉风吹夜雨,萧瑟动寒林。正有高堂宴,能忘迟暮心。军中宜剑舞,塞上重笳音。不作边城将,谁知恩遇深!——唐代·张说《幽州夜饮》https://www.guwendao.net/shiwenv_072b1a3b49d9.aspx
幽州夜饮 张说〔唐代〕 凉风吹夜雨,萧瑟动寒林。 正有高堂宴,能忘迟暮心。 军中宜剑舞,塞上重笳音。 不作边城将,谁知恩遇深! 完善 边塞 写景 幽州
常识 xī西 bīn宾 1.旧时因宾位设于西侧,故称西宾,常作为对家塾教师或幕友的尊称。
不以智累心,不以私累己;寄治乱于法术,托是非于赏罚https://www.guwendao.net/mingju/juv_8abd61ea655f.aspx
唐寅 款鹤图局部 不以智累心,不以私累己;寄治乱于法术,托是非于赏罚 《韩非子·大体》 完善
伛偻溪头白发翁,暮年心事一枝筇。山衔落日青横野,鸦起平沙黑蔽空。天下可忧非一事,书生无地效孤忠。东山七月犹关念,未忍沉浮酒醆中。——宋代·陆游《溪上作二首·其二》https://www.guwendao.net/shiwenv_e05c7ec018c1.aspx
溪上作二首·其二 陆游〔宋代〕 伛偻溪头白发翁,暮年心事一枝筇。 山衔落日青横野,鸦起平沙黑蔽空。 天下可忧非一事,书生无地效孤忠。 东山七月犹关念,未忍沉浮酒醆中。 完善 抒情 忧国忧民 暮年 年老
新裂齐纨素,皎洁如霜雪。(皎洁 一作:鲜洁)裁作合欢扇,团团似明月。出入君怀袖,动摇微风发。常恐秋节至,凉飙夺炎热。(凉飙 一作:凉风)弃捐箧笥中,恩情中道绝。——两汉·班婕妤《怨歌行》https://www.guwendao.net/shiwenv_31c5fb3823cc.aspx
怨歌行 班婕妤〔两汉〕 新裂齐纨素,皎洁如霜雪。(皎洁 一作:鲜洁) 裁作合欢扇,团团似明月。 出入君怀袖,动摇微风发。 常恐秋节至,凉飙夺炎热。(凉飙 一作:凉风) 弃捐箧笥中,恩情中道绝。 完善 咏物 写人 宫怨 怨情 宫人 怨妇 怨恨 幽怨 懊悔 隐喻 寓事 宫中
曲水流觞,赏心乐事良辰。兰蕙光风,转头天气还新。明眸皓齿,看江头、有女如云。折花归去,绮罗陌上芳尘。能几多春。试听啼鸟殷勤。览物兴怀,向来哀乐纷纷。且题醉墨,似兰亭、列序时人。后之览者,又将有感斯文。——宋代·辛弃疾《新荷叶·上巳日吴子似谓古今无此词索赋》https://www.guwendao.net/shiwenv_6cdd4462db1a.aspx
新荷叶·上巳日吴子似谓古今无此词索赋 辛弃疾〔宋代〕 曲水流觞,赏心乐事良辰。兰蕙光风,转头天气还新。明眸皓齿,看江头、有女如云。折花归去,绮罗陌上芳尘。 能几多春。试听啼鸟殷勤。览物兴怀,向来哀乐纷纷。且题醉墨,似兰亭、列序时人。后之览者,又将有感斯文。 完善 宴席 宴会
名高前后事,回首一伤神。 —— 杜甫《发潭州》 重重似画,曲曲如屏。 —— 苏轼《行香子·过七里濑》 能变人间世,翛然是玉京。 —— 刘禹锡《八月十五日夜玩月》 新啼痕压旧啼痕,断肠人忆断肠人。 —— 王实甫《十二月过尧民歌·别情》 兵无常势,水无常形,能因敌变化而取胜者,谓之神。 —— 《孙子兵法·虚实篇》
人未己知,不可急求其知;人未己合,不可急与之合。https://www.guwendao.net/mingju/juv_31a713018e6d.aspx
曹兴 山水图局部 人未己知,不可急求其知;人未己合,不可急与之合。 《格言联璧·接物类》 完善
长忆西湖。尽日凭阑楼上望:三三两两钓鱼舟,岛屿正清秋。笛声依约芦花里,白鸟成行忽惊起。别来闲整钓鱼竿,思入水云寒。——宋代·潘阆《忆馀杭·长忆西湖》https://www.guwendao.net/shiwenv_0be6fa5203c6.aspx
忆馀杭·长忆西湖 潘阆〔宋代〕 长忆西湖。尽日凭阑楼上望:三三两两钓鱼舟,岛屿正清秋。 笛声依约芦花里,白鸟成行忽惊起。别来闲整钓鱼竿,思入水云寒。 完善 回忆 西湖 美景 忆昔 往事 水乡
常识 yōu耰 chú锄 1.犹锄耰。参见“耡耰 ”。
南登碣石馆,遥望黄金台。丘陵尽乔木,昭王安在哉?霸图今已矣,驱马复归来。——唐代·陈子昂《燕昭王》https://www.guwendao.net/shiwenv_82a2d6725e98.aspx
燕昭王 陈子昂〔唐代〕 南登碣石馆,遥望黄金台。 丘陵尽乔木,昭王安在哉? 霸图今已矣,驱马复归来。 完善 怀古
湖上雨晴时,秋水半篙初没。朱槛俯窥寒鉴,照衰颜华发。醉中吹坠白纶巾,溪风漾流月。独棹小舟归去,任烟波飘兀。——宋代·苏轼《好事近·湖上》https://www.guwendao.net/shiwenv_419ef8d31195.aspx
好事近·湖上 苏轼〔宋代〕 湖上雨晴时,秋水半篙初没。朱槛俯窥寒鉴,照衰颜华发。 醉中吹坠白纶巾,溪风漾流月。独棹小舟归去,任烟波飘兀。 完善 夜晚 写景 抒情 愁闷 湖水 夜景 湖泊 湖光
缀文者情动而辞发,观文者披文以入情https://www.guwendao.net/mingju/juv_2e8f13f64677.aspx
颜辉(传) 松荫论道图局部 缀文者情动而辞发,观文者披文以入情 《文心雕龙·知音》 完善
玉鉴尘生,凤奁香殄。懒蝉鬓之巧梳,闲缕衣之轻缘。苦寂寞于蕙宫,但凝思乎兰殿。信摽落之梅花,隔长门而不见。况乃花心飏恨,柳眼弄愁。暖风习习,春鸟啾啾。楼上黄昏兮,听风吹而回首;碧云日暮兮,对素月而凝眸。温泉不到,忆拾翠之旧游;长门深闭,嗟青鸾之信修。  忆昔太液清波,水光荡浮,笙歌赏宴,陪从宸旒。奏舞鸾之妙曲,乘画鷁之仙舟。君情缱绻,深叙绸缪。誓山海而常在,似日月而无休。  奈何嫉色庸庸,妒气冲冲。夺我之爱幸,斥我乎幽宫。思旧欢之莫得,梦相著乎朦胧。度花朝与月夕,羞懒对乎春风。欲相如之奏赋,奈世才之不工。属愁吟之未尽,已响动乎疏钟。空长叹而掩袂,踌躇步于楼东。(梦相 一作:想梦)——唐代·江采萍《楼东赋》https://www.guwendao.net/shiwenv_6edbcc1a7ec2.aspx
楼东赋 江采萍〔唐代〕   玉鉴尘生,凤奁香殄。懒蝉鬓之巧梳,闲缕衣之轻缘。苦寂寞于蕙宫,但凝思乎兰殿。信摽落之梅花,隔长门而不见。况乃花心飏恨,柳眼弄愁。暖风习习,春鸟啾啾。楼上黄昏兮,听风吹而回首;碧云日暮兮,对素月而凝眸。温泉不到,忆拾翠之旧游;长门深闭,嗟青鸾之信修。   忆昔太液清波,水光荡浮,笙歌赏宴,陪从宸旒。奏舞鸾之妙曲,乘画鷁之仙舟。君情缱绻,深叙绸缪。誓山海而常在,似日月而无休。   奈何嫉色庸庸,妒气冲冲。夺我之爱幸,斥我乎幽宫。思旧欢之莫得,梦相著乎朦胧。度花朝与月夕,羞懒对乎春风。欲相如之奏赋,奈世才之不工。属愁吟之未尽,已响动乎疏钟。空长叹而掩袂,踌躇步于楼东。(梦相 一作:想梦) 完善 女子 闺情
楚山碧岩岩,汉水碧汤汤。秀气结成象,孟氏之文章。今我讽遗文,思人至其乡。清风无人继,日暮空襄阳。南望鹿门山,蔼若有余芳。旧隐不知处,云深树苍苍。——唐代·白居易《游襄阳怀孟浩然》https://www.guwendao.net/shiwenv_9efa3d1d8d2a.aspx
游襄阳怀孟浩然 白居易〔唐代〕 楚山碧岩岩,汉水碧汤汤。 秀气结成象,孟氏之文章。 今我讽遗文,思人至其乡。 清风无人继,日暮空襄阳。 南望鹿门山,蔼若有余芳。 旧隐不知处,云深树苍苍。 完善 山水 怀人
滴不尽相思血泪抛红豆,开不完春柳春花满画楼,睡不稳纱窗风雨黄昏后,忘不了新愁与旧愁,咽不下玉粒金莼噎满喉,照不见菱花镜里形容瘦。 —— 《红楼梦·第二十八回》 何当一夕金风起,为我扫除天下热。 —— 《水浒传·第十六回》 枫香晚花静,锦水南山影。 —— 李贺《蜀国弦》 有则改之,无则加勉 —— 《传习录·卷下·右南大吉录》 韩子曰:“儒以文乱法,而侠以武犯禁。” —— 司马迁《游侠列传序》

7
w11/java-cli/data/豆瓣电影评分.json

@ -0,0 +1,7 @@
{
"title" : "Douban Movies",
"url" : "https://movie.douban.com/chart",
"content" : "1. 世界的主人 / 若问世界谁无伤(港) / 世界之主 ((102341人评价)) - Rating: 9.1\n2. 爱情抓马 / 抓马恋人(台) / 戏剧性婚礼(港) ((27347人评价)) - Rating: 7.0\n3. 杀的就是你 / 杀死你(港) / 他们要杀你(台) ((18784人评价)) - Rating: 6.9\n4. 蜂蜜的针 / 没有别的爱 / No Other Love ((43311人评价)) - Rating: 6.9\n5. 蒙特利尔,我的美人 / 蒙特利尔,我的爱人 / Montreal, My Beautiful ((12170人评价)) - Rating: 7.6\n6. 与王生活的男人 / 王命之徒(台) / 和王一起生活的男人 ((7834人评价)) - Rating: 7.4\n7. 巅峰猎杀 / 巅峰 ((13676人评价)) - Rating: 6.3\n8. 准备好了没2:我来了 / 爆血新婚夜2:豪门游戏(港) / 弑婚游戏:2度开局(台) ((13991人评价)) - Rating: 6.0\n9. 翠湖 / As The Water Flows ((17859人评价)) - Rating: 7.8\n10. 像我这样的爱情 / Someone Like Me ((5564人评价)) - Rating: 7.1\n",
"author" : "Douban",
"publishDate" : "2026-05-19"
}

30
w11/java-cli/data/豆瓣电影评分.txt

@ -0,0 +1,30 @@
========================================
爬虫数据采集结果
========================================
生成时间: 2026年05月19日 10:44:25
========================================
【标题】
Douban Movies
【作者】
Douban
【发布日期】
2026-05-19
【来源链接】
https://movie.douban.com/chart
【内容】
────────────────
1. 世界的主人 / 若问世界谁无伤(港) / 世界之主 ((102341人评价)) - Rating: 9.1
2. 爱情抓马 / 抓马恋人(台) / 戏剧性婚礼(港) ((27347人评价)) - Rating: 7.0
3. 杀的就是你 / 杀死你(港) / 他们要杀你(台) ((18784人评价)) - Rating: 6.9
4. 蜂蜜的针 / 没有别的爱 / No Other Love ((43311人评价)) - Rating: 6.9
5. 蒙特利尔,我的美人 / 蒙特利尔,我的爱人 / Montreal, My Beautiful ((12170人评价)) - Rating: 7.6
6. 与王生活的男人 / 王命之徒(台) / 和王一起生活的男人 ((7834人评价)) - Rating: 7.4
7. 巅峰猎杀 / 巅峰 ((13676人评价)) - Rating: 6.3
8. 准备好了没2:我来了 / 爆血新婚夜2:豪门游戏(港) / 弑婚游戏:2度开局(台) ((13991人评价)) - Rating: 6.0
9. 翠湖 / As The Water Flows ((17859人评价)) - Rating: 7.8
10. 像我这样的爱情 / Someone Like Me ((5564人评价)) - Rating: 7.1

7
w11/java-cli/data/长沙天气.json

@ -0,0 +1,7 @@
{
"title" : "Changsha Weather",
"url" : "https://www.tianqi.com/changsha/",
"content" : "City: Changsha\nTemperature: N/A\nWeather: N/A\nHumidity: N/A\nWind: N/A\nUpdate Time: \n\nForecast:\n",
"author" : "Weather API",
"publishDate" : "2026-05-19T10:44:24.997101700"
}

28
w11/java-cli/data/长沙天气.txt

@ -0,0 +1,28 @@
========================================
爬虫数据采集结果
========================================
生成时间: 2026年05月19日 10:44:25
========================================
【标题】
Changsha Weather
【作者】
Weather API
【发布日期】
2026-05-19T10:44:24.997101700
【来源链接】
https://www.tianqi.com/changsha/
【内容】
────────────────
City: Changsha
Temperature: N/A
Weather: N/A
Humidity: N/A
Wind: N/A
Update Time:
Forecast:

4
w11/java-cli/java-cli/.gitignore

@ -0,0 +1,4 @@
*.jar
*.jar
*.class
*.log

78
w11/java-cli/pom.xml

@ -0,0 +1,78 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>datacollect-cli</artifactId>
<version>0.1.0</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.16.1</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.9</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.4.11</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.example.datacollect.Main</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<mainClass>com.example.datacollect.TestCrawler</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>

17
w11/java-cli/src/main/java/com/example/datacollect/Main.java

@ -0,0 +1,17 @@
package com.example.datacollect;
import com.example.datacollect.controller.CrawlerController;
import com.example.datacollect.view.ConsoleView;
public class Main {
public static void main(String[] args) {
ConsoleView view = new ConsoleView();
CrawlerController controller = new CrawlerController(view);
view.printSuccess("Welcome to CLI Crawler (w9_1)! Type help for commands.");
while (true) {
controller.handle(view.readLine());
}
}
}

90
w11/java-cli/src/main/java/com/example/datacollect/TestCrawler.java

@ -0,0 +1,90 @@
package com.example.datacollect;
import com.example.datacollect.exception.CrawlerException;
import com.example.datacollect.model.Article;
import com.example.datacollect.repository.ArticleRepository;
import com.example.datacollect.service.CrawlerService;
import com.example.datacollect.service.DataStorageService;
import java.io.IOException;
import java.util.List;
public class TestCrawler {
public static void main(String[] args) {
System.out.println("=== 开始完整爬虫流程 ===\n");
// Initialize components
ArticleRepository repository = new ArticleRepository();
CrawlerService crawlerService = new CrawlerService(repository);
DataStorageService storageService = new DataStorageService();
// Step 1: Crawl all sources
System.out.println("步骤1: 爬取数据来源...\n");
// Crawl Gushiwen
try {
System.out.println("爬取 古诗文网...");
Article gushiwen = crawlerService.crawlWithStrategy("https://www.gushiwen.cn/", "gushiwen");
System.out.println("✓ 成功! " + gushiwen.getTitle() + "\n");
} catch (CrawlerException e) {
System.out.println("✗ 失败: " + e.getMessage() + "\n");
}
// Crawl Douban Movie
try {
System.out.println("爬取 豆瓣电影...");
Article douban = crawlerService.crawlWithStrategy("https://movie.douban.com/chart", "douban");
System.out.println("✓ 成功! " + douban.getTitle() + "\n");
} catch (CrawlerException e) {
System.out.println("✗ 失败: " + e.getMessage() + "\n");
}
// Crawl Weather
try {
System.out.println("爬取 长沙天气...");
Article weather = crawlerService.crawlWithStrategy("https://www.tianqi.com/changsha/", "weather");
System.out.println("✓ 成功! " + weather.getTitle() + "\n");
} catch (CrawlerException e) {
System.out.println("✗ 失败: " + e.getMessage() + "\n");
}
// Step 2: Display crawled data
System.out.println("步骤2: 显示爬取的文章...\n");
List<Article> articles = crawlerService.getAllArticles();
for (int i = 0; i < articles.size(); i++) {
Article article = articles.get(i);
System.out.println("【文章 " + (i + 1) + "】 " + article.getTitle());
System.out.println(" 来源: " + article.getUrl());
System.out.println(" 内容预览: " + article.getContent().substring(0, Math.min(60, article.getContent().length())) + "...\n");
}
// Step 3: Save data by type
System.out.println("步骤3: 按类型保存数据...\n");
try {
// Clear old files first
storageService.clearOldFiles();
// Save each article to separate file by type
for (Article article : articles) {
storageService.saveArticleByType(article);
}
System.out.println("✓ 已按类型保存数据\n");
// Show files in data folder
System.out.println("📁 data 文件夹内容:");
for (String file : storageService.listFiles()) {
System.out.println(" - " + file);
}
} catch (IOException e) {
System.out.println("✗ 保存数据失败: " + e.getMessage());
}
System.out.println("\n=== 所有任务完成! ===");
System.out.println("\n📁 结果文件位置: d:\\java-cli\\data\\");
System.out.println(" - 古诗文数据.json / 古诗文数据.txt");
System.out.println(" - 豆瓣电影评分.json / 豆瓣电影评分.txt");
System.out.println(" - 长沙天气.json / 长沙天气.txt");
}
}

73
w11/java-cli/src/main/java/com/example/datacollect/command/AnalyzeCommand.java

@ -0,0 +1,73 @@
package com.example.datacollect.command;
import com.example.datacollect.service.CrawlerService;
import com.example.datacollect.service.DataAnalysisService;
import java.util.Map;
public class AnalyzeCommand implements Command {
private final CrawlerService crawlerService;
private final DataAnalysisService analysisService;
public AnalyzeCommand(CrawlerService crawlerService) {
this.crawlerService = crawlerService;
this.analysisService = new DataAnalysisService();
}
@Override
public String getName() {
return "analyze";
}
@Override
public CommandResult execute(String[] args) {
var articles = crawlerService.getAllArticles();
if (articles.isEmpty()) {
return CommandResult.builder()
.message("No data to analyze. Please crawl articles first.")
.success(false)
.build();
}
Map<String, Object> analysis = analysisService.analyzeArticles(articles);
StringBuilder sb = new StringBuilder();
sb.append("========== Data Analysis Report ==========\n");
sb.append("Total articles: ").append(analysis.get("totalArticles")).append("\n");
sb.append("Average content length: ").append(String.format("%.1f", analysis.get("avgContentLength"))).append(" chars\n");
sb.append("Most frequent author: ").append(analysis.get("mostFrequentAuthor")).append("\n");
@SuppressWarnings("unchecked")
Map<String, Object> stats = (Map<String, Object>) analysis.get("articleStats");
sb.append("\nContent Statistics:\n");
sb.append(" - Min: ").append(stats.get("minLength")).append(" chars\n");
sb.append(" - Max: ").append(stats.get("maxLength")).append(" chars\n");
sb.append(" - Total: ").append(stats.get("totalLength")).append(" chars\n");
@SuppressWarnings("unchecked")
Map<String, Integer> wordFreq = (Map<String, Integer>) analysis.get("wordFrequency");
sb.append("\nTop 10 Frequent Words:\n");
wordFreq.forEach((word, count) -> sb.append(" - ").append(word).append(": ").append(count).append(" times\n"));
@SuppressWarnings("unchecked")
Map<String, Object> ratingAnalysis = (Map<String, Object>) analysis.get("ratingAnalysis");
if ((Boolean) ratingAnalysis.get("hasRatings")) {
sb.append("\nRating Analysis:\n");
sb.append(" - Count: ").append(ratingAnalysis.get("count")).append("\n");
sb.append(" - Average: ").append(ratingAnalysis.get("avgRating")).append("\n");
sb.append(" - Max: ").append(ratingAnalysis.get("maxRating")).append("\n");
sb.append(" - Min: ").append(ratingAnalysis.get("minRating")).append("\n");
@SuppressWarnings("unchecked")
Map<String, Integer> distribution = (Map<String, Integer>) ratingAnalysis.get("distribution");
sb.append(" - Distribution:\n");
distribution.forEach((range, count) -> sb.append(" * ").append(range).append(": ").append(count).append(" items\n"));
}
return CommandResult.builder()
.message(sb.toString())
.success(true)
.build();
}
}

6
w11/java-cli/src/main/java/com/example/datacollect/command/Command.java

@ -0,0 +1,6 @@
package com.example.datacollect.command;
public interface Command {
String getName();
CommandResult execute(String[] args);
}

58
w11/java-cli/src/main/java/com/example/datacollect/command/CommandResult.java

@ -0,0 +1,58 @@
package com.example.datacollect.command;
import com.example.datacollect.model.Article;
import java.util.ArrayList;
import java.util.List;
public class CommandResult {
private final String message;
private final List<Article> articles;
private final boolean success;
private CommandResult(Builder builder) {
this.message = builder.message;
this.articles = builder.articles;
this.success = builder.success;
}
public String getMessage() {
return message;
}
public List<Article> getArticles() {
return articles;
}
public boolean isSuccess() {
return success;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String message;
private List<Article> articles = new ArrayList<>();
private boolean success = true;
public Builder message(String message) {
this.message = message;
return this;
}
public Builder articles(List<Article> articles) {
this.articles = articles;
return this;
}
public Builder success(boolean success) {
this.success = success;
return this;
}
public CommandResult build() {
return new CommandResult(this);
}
}
}

72
w11/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java

@ -0,0 +1,72 @@
package com.example.datacollect.command;
import com.example.datacollect.exception.CrawlerException;
import com.example.datacollect.service.CrawlerService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CrawlCommand implements Command {
private final CrawlerService service;
private static final Logger logger = LoggerFactory.getLogger(CrawlCommand.class);
public CrawlCommand(CrawlerService service) {
this.service = service;
logger.info("CrawlCommand initialized");
}
@Override
public String getName() {
return "crawl";
}
@Override
public CommandResult execute(String[] args) {
if (args.length < 2) {
return CommandResult.builder()
.message("Usage: crawl <url> [strategy]")
.success(false)
.build();
}
String target = args[1].toLowerCase();
String url;
String strategyType;
switch (target) {
case "gushiwen":
url = "https://www.gushiwen.cn/";
strategyType = "gushiwen";
break;
case "douban":
url = "https://movie.douban.com/chart";
strategyType = "douban";
break;
case "weather":
url = "https://www.tianqi.com/changsha/";
strategyType = "weather";
break;
default:
url = args[1];
strategyType = args.length > 2 ? args[2] : "default";
break;
}
logger.info("Crawl started for: {} with strategy: {}", url, strategyType);
try {
service.crawlWithStrategy(url, strategyType);
logger.info("Crawl completed successfully for: {}", url);
return CommandResult.builder()
.message("Crawled successfully using [" + strategyType + "] strategy\nSource: " + url)
.success(true)
.build();
} catch (CrawlerException e) {
logger.error("Crawl failed for: {} - {}", url, e.getMessage(), e);
return CommandResult.builder()
.message("Failed to crawl: " + e.getMessage())
.success(false)
.build();
}
}
}

17
w11/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java

@ -0,0 +1,17 @@
package com.example.datacollect.command;
public class ExitCommand implements Command {
@Override
public String getName() {
return "exit";
}
@Override
public CommandResult execute(String[] args) {
return CommandResult.builder()
.message("Bye!")
.success(true)
.build();
}
}

38
w11/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java

@ -0,0 +1,38 @@
package com.example.datacollect.command;
public class HelpCommand implements Command {
@Override
public String getName() {
return "help";
}
@Override
public CommandResult execute(String[] args) {
StringBuilder sb = new StringBuilder();
sb.append("========== CLI Crawler Help ==========\n");
sb.append("\n爬虫命令:\n");
sb.append(" crawl <url> [strategy] - 爬取网页\n");
sb.append(" 支持的策略: default, blog, new, gushiwen, douban, weather\n");
sb.append(" 示例: crawl https://www.gushiwen.cn gushiwen\n");
sb.append("\n数据管理:\n");
sb.append(" list - 列出已爬取的文章\n");
sb.append(" analyze - 数据分析报告\n");
sb.append(" save <format> [filename] - 保存数据\n");
sb.append(" 格式: json, txt, all\n");
sb.append(" 示例: save json articles\n");
sb.append(" load <filename> - 加载保存的数据\n");
sb.append("\n系统命令:\n");
sb.append(" history - 查看命令历史\n");
sb.append(" help - 显示此帮助信息\n");
sb.append(" exit - 退出程序\n");
sb.append("\n快捷爬取命令:\n");
sb.append(" crawl gushiwen - 爬取古诗文网\n");
sb.append(" crawl douban - 爬取豆瓣电影\n");
sb.append(" crawl weather - 爬取长沙天气\n");
return CommandResult.builder()
.message(sb.toString())
.success(true)
.build();
}
}

28
w11/java-cli/src/main/java/com/example/datacollect/command/HistoryCommand.java

@ -0,0 +1,28 @@
package com.example.datacollect.command;
import com.example.datacollect.service.CrawlerService;
public class HistoryCommand implements Command {
private final CrawlerService service;
public HistoryCommand(CrawlerService service) {
this.service = service;
}
@Override
public String getName() {
return "history";
}
@Override
public CommandResult execute(String[] args) {
return CommandResult.builder()
.message("Command history")
.success(true)
.build();
}
public java.util.List<String> getHistory() {
return service.getHistory();
}
}

25
w11/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java

@ -0,0 +1,25 @@
package com.example.datacollect.command;
import com.example.datacollect.service.CrawlerService;
public class ListCommand implements Command {
private final CrawlerService service;
public ListCommand(CrawlerService service) {
this.service = service;
}
@Override
public String getName() {
return "list";
}
@Override
public CommandResult execute(String[] args) {
return CommandResult.builder()
.articles(service.getAllArticles())
.message("Total: " + service.getArticleCount() + " articles")
.success(true)
.build();
}
}

73
w11/java-cli/src/main/java/com/example/datacollect/command/LoadCommand.java

@ -0,0 +1,73 @@
package com.example.datacollect.command;
import com.example.datacollect.model.Article;
import com.example.datacollect.repository.ArticleRepository;
import com.example.datacollect.service.CrawlerService;
import com.example.datacollect.service.DataStorageService;
import java.io.IOException;
import java.util.List;
public class LoadCommand implements Command {
private final CrawlerService crawlerService;
private final DataStorageService storageService;
public LoadCommand(CrawlerService crawlerService) {
this.crawlerService = crawlerService;
this.storageService = new DataStorageService();
}
@Override
public String getName() {
return "load";
}
@Override
public CommandResult execute(String[] args) {
if (args.length < 2) {
List<String> files = storageService.listFiles();
if (files.isEmpty()) {
return CommandResult.builder()
.message("data/ 目录中没有文件")
.success(false)
.build();
}
StringBuilder sb = new StringBuilder();
sb.append("可用文件:\n");
files.forEach(file -> sb.append(" - ").append(file).append("\n"));
sb.append("\n使用: load <filename>");
return CommandResult.builder()
.message(sb.toString())
.success(false)
.build();
}
String filename = args[1];
try {
List<Article> articles = storageService.loadFromJson(filename);
if (articles.isEmpty()) {
return CommandResult.builder()
.message("文件为空或不存在: " + filename)
.success(false)
.build();
}
ArticleRepository repo = new ArticleRepository();
articles.forEach(repo::add);
crawlerService.clearHistory();
return CommandResult.builder()
.message("成功加载 " + articles.size() + " 篇文章")
.articles(articles)
.success(true)
.build();
} catch (IOException e) {
return CommandResult.builder()
.message("加载失败: " + e.getMessage())
.success(false)
.build();
}
}
}

102
w11/java-cli/src/main/java/com/example/datacollect/command/SaveCommand.java

@ -0,0 +1,102 @@
package com.example.datacollect.command;
import com.example.datacollect.service.CrawlerService;
import com.example.datacollect.service.DataAnalysisService;
import com.example.datacollect.service.DataStorageService;
import java.io.IOException;
import java.util.Map;
public class SaveCommand implements Command {
private final CrawlerService crawlerService;
private final DataStorageService storageService;
private final DataAnalysisService analysisService;
public SaveCommand(CrawlerService crawlerService) {
this.crawlerService = crawlerService;
this.storageService = new DataStorageService();
this.analysisService = new DataAnalysisService();
}
@Override
public String getName() {
return "save";
}
@Override
public CommandResult execute(String[] args) {
var articles = crawlerService.getAllArticles();
if (articles.isEmpty()) {
return CommandResult.builder()
.message("没有数据可保存,请先爬取文章。")
.success(false)
.build();
}
if (args.length < 2) {
return CommandResult.builder()
.message("Usage: save <format> [filename]\nFormats: json, txt, all")
.success(false)
.build();
}
String format = args[1].toLowerCase();
String filename = args.length > 2 ? args[2] : null;
try {
switch (format) {
case "json":
if (filename != null) {
storageService.saveToJson(articles, filename + ".json");
return CommandResult.builder()
.message("数据已保存到 data/" + filename + ".json")
.success(true)
.build();
} else {
storageService.saveToJsonWithTimestamp(articles);
return CommandResult.builder()
.message("数据已保存到 data/articles_<timestamp>.json")
.success(true)
.build();
}
case "txt":
if (filename != null) {
storageService.saveToTxt(articles, filename + ".txt");
return CommandResult.builder()
.message("数据已保存到 data/" + filename + ".txt")
.success(true)
.build();
} else {
storageService.saveToTxt(articles, "articles_" +
java.time.LocalDateTime.now().format(
java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + ".txt");
return CommandResult.builder()
.message("数据已保存到 data/articles_<timestamp>.txt")
.success(true)
.build();
}
case "all":
storageService.saveToJsonWithTimestamp(articles);
Map<String, Object> analysis = analysisService.analyzeArticles(articles);
storageService.saveAnalysisResult(analysis, "analysis_" +
java.time.LocalDateTime.now().format(
java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + ".json");
return CommandResult.builder()
.message("数据和分析结果已保存到 data/ 目录")
.success(true)
.build();
default:
return CommandResult.builder()
.message("未知格式: " + format + "\n支持的格式: json, txt, all")
.success(false)
.build();
}
} catch (IOException e) {
return CommandResult.builder()
.message("保存失败: " + e.getMessage())
.success(false)
.build();
}
}
}

99
w11/java-cli/src/main/java/com/example/datacollect/controller/CrawlerController.java

@ -0,0 +1,99 @@
package com.example.datacollect.controller;
import com.example.datacollect.command.*;
import com.example.datacollect.repository.ArticleRepository;
import com.example.datacollect.service.CrawlerService;
import com.example.datacollect.view.ConsoleView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class CrawlerController {
private static final Logger logger = LoggerFactory.getLogger(CrawlerController.class);
private final Map<String, Command> commands = new HashMap<>();
private final ConsoleView view;
private final CrawlerService service;
private HistoryCommand historyCommand;
public CrawlerController(ConsoleView view) {
this.view = view;
this.service = new CrawlerService(new ArticleRepository());
logger.info("CrawlerController initialized");
initializeCommands();
}
private void initializeCommands() {
historyCommand = new HistoryCommand(service);
commands.put("help", new HelpCommand());
commands.put("list", new ListCommand(service));
commands.put("crawl", new CrawlCommand(service));
commands.put("exit", new ExitCommand());
commands.put("history", historyCommand);
commands.put("analyze", new AnalyzeCommand(service));
commands.put("save", new SaveCommand(service));
commands.put("load", new LoadCommand(service));
logger.debug("Registered {} commands", commands.size());
}
public void handle(String input) {
String text = input == null ? "" : input.trim();
if (text.isEmpty()) {
return;
}
service.addToHistory(text);
logger.debug("Received command: {}", text);
String[] args = text.split("\\s+");
String cmdName = args[0].toLowerCase();
Command command = commands.get(cmdName);
if (command == null) {
logger.warn("Unknown command received: {}", cmdName);
view.printError("Unknown command: " + cmdName);
return;
}
try {
CommandResult result = command.execute(args);
if (cmdName.equals("exit")) {
logger.info("Exit command received, shutting down");
view.printSuccess(result.getMessage());
System.exit(0);
return;
}
if (cmdName.equals("list")) {
view.display(result.getArticles());
return;
}
if (cmdName.equals("history")) {
List<String> history = historyCommand.getHistory();
if (history.isEmpty()) {
view.printInfo("No command history.");
} else {
view.printInfo("Command history:");
for (int i = 0; i < history.size(); i++) {
view.printInfo((i + 1) + ". " + history.get(i));
}
}
return;
}
if (result.isSuccess()) {
view.printSuccess(result.getMessage());
} else {
view.printError(result.getMessage());
}
} catch (Exception e) {
logger.error("Command execution failed: {}", e.getMessage(), e);
view.printError("Command execution failed: " + e.getMessage());
}
}
}

12
w11/java-cli/src/main/java/com/example/datacollect/exception/CrawlerException.java

@ -0,0 +1,12 @@
package com.example.datacollect.exception;
public class CrawlerException extends Exception {
public CrawlerException(String message) {
super(message);
}
public CrawlerException(String message, Throwable cause) {
super(message, cause);
}
}

20
w11/java-cli/src/main/java/com/example/datacollect/exception/NetworkException.java

@ -0,0 +1,20 @@
package com.example.datacollect.exception;
public class NetworkException extends CrawlerException {
private final String url;
public NetworkException(String message, String url) {
super(message);
this.url = url;
}
public NetworkException(String message, String url, Throwable cause) {
super(message, cause);
this.url = url;
}
public String getUrl() {
return url;
}
}

20
w11/java-cli/src/main/java/com/example/datacollect/exception/ParseException.java

@ -0,0 +1,20 @@
package com.example.datacollect.exception;
public class ParseException extends CrawlerException {
private final String url;
public ParseException(String message, String url) {
super(message);
this.url = url;
}
public ParseException(String message, String url, Throwable cause) {
super(message, cause);
this.url = url;
}
public String getUrl() {
return url;
}
}

65
w11/java-cli/src/main/java/com/example/datacollect/model/Article.java

@ -0,0 +1,65 @@
package com.example.datacollect.model;
public class Article {
private String title;
private String url;
private String content;
private String author;
private String publishDate;
public Article(String title, String url, String content) {
this.title = title;
this.url = url;
this.content = content;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getPublishDate() {
return publishDate;
}
public void setPublishDate(String publishDate) {
this.publishDate = publishDate;
}
@Override
public String toString() {
return "Article{"
+ "title='" + title + '\''
+ ", url='" + url + '\''
+ ", author='" + author + '\''
+ ", publishDate='" + publishDate + '\''
+ '}';
}
}

121
w11/java-cli/src/main/java/com/example/datacollect/repository/ArticleRepository.java

@ -0,0 +1,121 @@
package com.example.datacollect.repository;
import com.example.datacollect.model.Article;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
public class ArticleRepository {
private static final Logger logger = LoggerFactory.getLogger(ArticleRepository.class);
private final List<Article> articles = new ArrayList<>();
public void add(Article article) {
validateArticle(article);
boolean exists = articles.stream()
.anyMatch(a -> a.getUrl() != null && a.getUrl().equals(article.getUrl()));
if (exists) {
logger.warn("Article with URL '{}' already exists, skipping", article.getUrl());
throw new IllegalArgumentException("Article with URL '" + article.getUrl() + "' already exists");
}
articles.add(article);
logger.debug("Added article: {}", article.getTitle());
}
public void addAll(List<Article> articleList) {
if (articleList == null) {
logger.warn("Attempted to add null article list");
throw new IllegalArgumentException("Article list cannot be null");
}
if (articleList.isEmpty()) {
logger.debug("Empty article list provided, nothing to add");
return;
}
for (Article article : articleList) {
try {
add(article);
} catch (IllegalArgumentException e) {
logger.warn("Skipping article due to validation error: {}", e.getMessage());
}
}
}
public List<Article> getAll() {
return Collections.unmodifiableList(articles);
}
public int size() {
return articles.size();
}
public void clear() {
int count = articles.size();
articles.clear();
logger.info("Cleared {} articles from repository", count);
}
public Optional<Article> findByUrl(String url) {
if (url == null || url.trim().isEmpty()) {
logger.warn("Invalid URL provided for findByUrl");
return Optional.empty();
}
return articles.stream()
.filter(a -> url.equals(a.getUrl()))
.findFirst();
}
public boolean removeByUrl(String url) {
if (url == null || url.trim().isEmpty()) {
logger.warn("Invalid URL provided for removeByUrl");
return false;
}
boolean removed = articles.removeIf(a -> url.equals(a.getUrl()));
if (removed) {
logger.info("Removed article with URL: {}", url);
} else {
logger.warn("No article found with URL: {}", url);
}
return removed;
}
public boolean containsUrl(String url) {
if (url == null) {
return false;
}
return articles.stream().anyMatch(a -> url.equals(a.getUrl()));
}
private void validateArticle(Article article) {
if (article == null) {
logger.error("Attempted to add null article");
throw new IllegalArgumentException("Article cannot be null");
}
if (article.getTitle() == null || article.getTitle().trim().isEmpty()) {
logger.warn("Article has empty title");
throw new IllegalArgumentException("Article title cannot be null or empty");
}
if (article.getContent() == null || article.getContent().trim().isEmpty()) {
logger.warn("Article has empty content: {}", article.getTitle());
throw new IllegalArgumentException("Article content cannot be null or empty");
}
if (article.getUrl() == null || article.getUrl().trim().isEmpty()) {
logger.warn("Article has empty URL: {}", article.getTitle());
throw new IllegalArgumentException("Article URL cannot be null or empty");
}
logger.debug("Article validated successfully: {}", article.getTitle());
}
}

105
w11/java-cli/src/main/java/com/example/datacollect/service/CrawlerService.java

@ -0,0 +1,105 @@
package com.example.datacollect.service;
import com.example.datacollect.exception.CrawlerException;
import com.example.datacollect.exception.NetworkException;
import com.example.datacollect.model.Article;
import com.example.datacollect.repository.ArticleRepository;
import com.example.datacollect.strategy.Strategy;
import com.example.datacollect.strategy.StrategyFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class CrawlerService {
private static final Logger logger = LoggerFactory.getLogger(CrawlerService.class);
private static final int MAX_RETRY_ATTEMPTS = 3;
private static final long RETRY_DELAY_MS = 2000;
private final ArticleRepository articleRepository;
private final List<String> commandHistory = new ArrayList<>();
public CrawlerService(ArticleRepository articleRepository) {
this.articleRepository = articleRepository;
logger.info("CrawlerService initialized with retry attempts: {}", MAX_RETRY_ATTEMPTS);
}
public Article crawl(String url) throws CrawlerException {
return crawlWithRetry(url, "default");
}
public Article crawlWithStrategy(String url, String strategyType) throws CrawlerException {
return crawlWithRetry(url, strategyType);
}
private Article crawlWithRetry(String url, String strategyType) throws CrawlerException {
int attempts = 0;
Exception lastException = null;
while (attempts < MAX_RETRY_ATTEMPTS) {
attempts++;
logger.debug("Attempt {} of {} to crawl {} with strategy {}",
attempts, MAX_RETRY_ATTEMPTS, url, strategyType);
try {
Strategy strategy = StrategyFactory.getStrategy(strategyType);
Article article = strategy.execute(url);
if (article != null) {
articleRepository.add(article);
logger.info("Successfully crawled {} (attempt {})", url, attempts);
return article;
} else {
logger.warn("Strategy returned null for {}", url);
}
} catch (NetworkException e) {
lastException = e;
logger.warn("Network error on attempt {} for {}: {}", attempts, url, e.getMessage());
} catch (CrawlerException e) {
lastException = e;
logger.warn("Crawler error on attempt {} for {}: {}", attempts, url, e.getMessage());
}
if (attempts < MAX_RETRY_ATTEMPTS) {
try {
logger.info("Waiting {}ms before retry...", RETRY_DELAY_MS);
Thread.sleep(RETRY_DELAY_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new CrawlerException("Crawl interrupted", ie);
}
}
}
logger.error("Failed to crawl {} after {} attempts", url, MAX_RETRY_ATTEMPTS);
throw new CrawlerException("Failed to crawl " + url + " after " + MAX_RETRY_ATTEMPTS + " attempts", lastException);
}
public List<Article> getAllArticles() {
return articleRepository.getAll();
}
public int getArticleCount() {
return articleRepository.size();
}
public void addToHistory(String command) {
commandHistory.add(command);
}
public List<String> getHistory() {
return Collections.unmodifiableList(commandHistory);
}
public void clearHistory() {
commandHistory.clear();
logger.info("Command history cleared");
}
public void clearArticles() {
articleRepository.clear();
logger.info("Articles cleared from repository");
}
}

164
w11/java-cli/src/main/java/com/example/datacollect/service/DataAnalysisService.java

@ -0,0 +1,164 @@
package com.example.datacollect.service;
import com.example.datacollect.model.Article;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class DataAnalysisService {
private static final Logger logger = LoggerFactory.getLogger(DataAnalysisService.class);
public Map<String, Object> analyzeArticles(List<Article> articles) {
logger.info("Starting analysis for {} articles", articles.size());
Map<String, Object> result = new HashMap<>();
result.put("totalArticles", articles.size());
result.put("avgContentLength", calculateAvgContentLength(articles));
result.put("mostFrequentAuthor", findMostFrequentAuthor(articles));
result.put("articleStats", getArticleStats(articles));
result.put("wordFrequency", calculateWordFrequency(articles, 10));
result.put("ratingAnalysis", analyzeRatings(articles));
logger.debug("Analysis completed successfully");
return result;
}
private double calculateAvgContentLength(List<Article> articles) {
if (articles.isEmpty()) {
logger.debug("No articles for average length calculation");
return 0;
}
int totalLength = articles.stream()
.mapToInt(a -> a.getContent().length())
.sum();
return (double) totalLength / articles.size();
}
private String findMostFrequentAuthor(List<Article> articles) {
Map<String, Integer> authorCount = new HashMap<>();
for (Article article : articles) {
String author = article.getAuthor() != null ? article.getAuthor() : "Unknown";
authorCount.put(author, authorCount.getOrDefault(author, 0) + 1);
}
return authorCount.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("Unknown");
}
private Map<String, Object> getArticleStats(List<Article> articles) {
Map<String, Object> stats = new HashMap<>();
if (articles.isEmpty()) {
stats.put("minLength", 0);
stats.put("maxLength", 0);
stats.put("totalLength", 0);
return stats;
}
int minLength = articles.stream()
.mapToInt(a -> a.getContent().length())
.min().orElse(0);
int maxLength = articles.stream()
.mapToInt(a -> a.getContent().length())
.max().orElse(0);
int totalLength = articles.stream()
.mapToInt(a -> a.getContent().length())
.sum();
stats.put("minLength", minLength);
stats.put("maxLength", maxLength);
stats.put("totalLength", totalLength);
return stats;
}
private Map<String, Integer> calculateWordFrequency(List<Article> articles, int limit) {
Map<String, Integer> wordCount = new HashMap<>();
Pattern pattern = Pattern.compile("[\\u4e00-\\u9fa5]+");
for (Article article : articles) {
Matcher matcher = pattern.matcher(article.getContent());
while (matcher.find()) {
String word = matcher.group();
if (word.length() >= 2) {
wordCount.put(word, wordCount.getOrDefault(word, 0) + 1);
}
}
}
return wordCount.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.limit(limit)
.collect(LinkedHashMap::new,
(map, entry) -> map.put(entry.getKey(), entry.getValue()),
LinkedHashMap::putAll);
}
private Map<String, Object> analyzeRatings(List<Article> articles) {
Map<String, Object> result = new HashMap<>();
List<Double> ratings = new ArrayList<>();
Pattern ratingPattern = Pattern.compile("\\d+\\.\\d+");
for (Article article : articles) {
Matcher matcher = ratingPattern.matcher(article.getContent());
if (matcher.find()) {
try {
ratings.add(Double.parseDouble(matcher.group()));
} catch (NumberFormatException e) {
logger.warn("Failed to parse rating from article: {}", article.getTitle());
}
}
}
if (ratings.isEmpty()) {
result.put("hasRatings", false);
return result;
}
double avgRating = ratings.stream().mapToDouble(Double::doubleValue).average().orElse(0);
double maxRating = ratings.stream().mapToDouble(Double::doubleValue).max().orElse(0);
double minRating = ratings.stream().mapToDouble(Double::doubleValue).min().orElse(0);
result.put("hasRatings", true);
result.put("count", ratings.size());
result.put("avgRating", String.format("%.1f", avgRating));
result.put("maxRating", maxRating);
result.put("minRating", minRating);
Map<String, Integer> ratingDistribution = new HashMap<>();
for (Double rating : ratings) {
String range = String.format("%.0f-%.0f", Math.floor(rating), Math.floor(rating) + 0.9);
ratingDistribution.put(range, ratingDistribution.getOrDefault(range, 0) + 1);
}
result.put("distribution", ratingDistribution);
return result;
}
public Map<String, List<Article>> categorizeBySource(List<Article> articles) {
Map<String, List<Article>> categories = new HashMap<>();
for (Article article : articles) {
String url = article.getUrl();
String category;
if (url.contains("gushiwen")) {
category = "Gushiwen";
} else if (url.contains("douban")) {
category = "Douban";
} else if (url.contains("tianqi") || url.contains("weather") || article.getTitle().contains("Weather")) {
category = "Weather";
} else {
category = "Other";
}
categories.computeIfAbsent(category, k -> new ArrayList<>()).add(article);
}
return categories;
}
}

334
w11/java-cli/src/main/java/com/example/datacollect/service/DataStorageService.java

@ -0,0 +1,334 @@
package com.example.datacollect.service;
import com.example.datacollect.model.Article;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.core.JsonGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
public class DataStorageService {
private static final Logger logger = LoggerFactory.getLogger(DataStorageService.class);
private static final String BASE_DIR = "data";
private static final ObjectMapper mapper = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT)
.configure(JsonGenerator.Feature.ESCAPE_NON_ASCII, false);
public void saveArticleByType(Article article) throws IOException {
logger.info("Saving article by type: {}", article.getTitle());
File dir = new File(BASE_DIR);
if (!dir.exists()) {
dir.mkdirs();
}
String filename = getFilenameByType(article.getUrl());
String jsonFilename = filename + ".json";
String txtFilename = filename + ".txt";
saveSingleArticleToJson(article, jsonFilename);
saveSingleArticleToTxt(article, txtFilename);
logger.debug("Saved article to: {}", filename);
}
private String getFilenameByType(String url) {
if (url.contains("gushiwen") || url.contains("gushiwen.cn")) {
return "古诗文数据";
} else if (url.contains("douban") || url.contains("movie.douban")) {
return "豆瓣电影评分";
} else if (url.contains("tianqi") || url.contains("weather")) {
return "长沙天气";
}
return "其他数据";
}
private void saveSingleArticleToJson(Article article, String filename) throws IOException {
String fullPath = BASE_DIR + File.separator + filename;
mapper.writeValue(new File(fullPath), article);
logger.debug("Saved JSON: {}", fullPath);
}
private void saveSingleArticleToTxt(Article article, String filename) throws IOException {
String fullPath = BASE_DIR + File.separator + filename;
StringBuilder sb = new StringBuilder();
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss"));
sb.append("========================================\n");
sb.append(" 爬虫数据采集结果\n");
sb.append("========================================\n");
sb.append("生成时间: ").append(timestamp).append("\n");
sb.append("========================================\n\n");
sb.append("【标题】\n");
sb.append(article.getTitle()).append("\n\n");
sb.append("【作者】\n");
sb.append(article.getAuthor() != null ? article.getAuthor() : "未知").append("\n\n");
sb.append("【发布日期】\n");
sb.append(article.getPublishDate() != null ? article.getPublishDate() : "未知").append("\n\n");
sb.append("【来源链接】\n");
sb.append(article.getUrl()).append("\n\n");
sb.append("【内容】\n");
sb.append("────────────────\n");
sb.append(article.getContent());
java.nio.file.Files.writeString(
new File(fullPath).toPath(),
sb.toString(),
java.nio.charset.StandardCharsets.UTF_8
);
logger.debug("Saved TXT: {}", fullPath);
}
public void saveAllArticlesByType(List<Article> articles) throws IOException {
logger.info("Saving {} articles by type", articles.size());
List<Article> gushiwenArticles = new ArrayList<>();
List<Article> doubanArticles = new ArrayList<>();
List<Article> weatherArticles = new ArrayList<>();
for (Article article : articles) {
if (article.getUrl().contains("gushiwen")) {
gushiwenArticles.add(article);
} else if (article.getUrl().contains("douban")) {
doubanArticles.add(article);
} else if (article.getUrl().contains("tianqi") || article.getUrl().contains("weather")) {
weatherArticles.add(article);
}
}
if (!gushiwenArticles.isEmpty()) {
saveArticlesToJson(gushiwenArticles, "古诗文数据.json");
saveArticlesToTxt(gushiwenArticles, "古诗文数据.txt");
logger.info("Saved {} gushiwen articles", gushiwenArticles.size());
}
if (!doubanArticles.isEmpty()) {
saveArticlesToJson(doubanArticles, "豆瓣电影评分.json");
saveArticlesToTxt(doubanArticles, "豆瓣电影评分.txt");
logger.info("Saved {} douban articles", doubanArticles.size());
}
if (!weatherArticles.isEmpty()) {
saveArticlesToJson(weatherArticles, "长沙天气.json");
saveArticlesToTxt(weatherArticles, "长沙天气.txt");
logger.info("Saved {} weather articles", weatherArticles.size());
}
}
public void saveToJsonWithTimestamp(List<Article> articles) throws IOException {
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
saveToJson(articles, "articles_" + timestamp + ".json");
}
private void saveArticlesToJson(List<Article> articles, String filename) throws IOException {
File dir = new File(BASE_DIR);
if (!dir.exists()) {
dir.mkdirs();
}
String fullPath = BASE_DIR + File.separator + filename;
mapper.writeValue(new File(fullPath), articles);
}
private void saveArticlesToTxt(List<Article> articles, String filename) throws IOException {
File dir = new File(BASE_DIR);
if (!dir.exists()) {
dir.mkdirs();
}
StringBuilder sb = new StringBuilder();
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss"));
sb.append("========================================\n");
sb.append(" 爬虫数据采集结果\n");
sb.append("========================================\n");
sb.append("生成时间: ").append(timestamp).append("\n");
sb.append("文章总数: ").append(articles.size()).append("\n");
sb.append("========================================\n\n");
for (int i = 0; i < articles.size(); i++) {
Article article = articles.get(i);
sb.append("【文章 ").append(i + 1).append("】\n");
sb.append("标题: ").append(article.getTitle()).append("\n");
sb.append("作者: ").append(article.getAuthor() != null ? article.getAuthor() : "未知").append("\n");
sb.append("发布日期: ").append(article.getPublishDate() != null ? article.getPublishDate() : "未知").append("\n");
sb.append("来源链接: ").append(article.getUrl()).append("\n");
sb.append("────────────────\n");
sb.append("内容:\n").append(article.getContent()).append("\n\n");
}
String fullPath = BASE_DIR + File.separator + filename;
java.nio.file.Files.writeString(
new File(fullPath).toPath(),
sb.toString(),
java.nio.charset.StandardCharsets.UTF_8
);
}
public void saveToJson(List<Article> articles, String filename) throws IOException {
logger.info("Saving {} articles to JSON: {}", articles.size(), filename);
File dir = new File(BASE_DIR);
if (!dir.exists()) {
dir.mkdirs();
}
String fullPath = BASE_DIR + File.separator + filename;
mapper.writeValue(new File(fullPath), articles);
}
public void saveToTxt(List<Article> articles, String filename) throws IOException {
logger.info("Saving {} articles to TXT: {}", articles.size(), filename);
File dir = new File(BASE_DIR);
if (!dir.exists()) {
dir.mkdirs();
}
StringBuilder sb = new StringBuilder();
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss"));
sb.append("========================================\n");
sb.append(" 爬虫数据采集结果\n");
sb.append("========================================\n");
sb.append("生成时间: ").append(timestamp).append("\n");
sb.append("文章总数: ").append(articles.size()).append("\n");
sb.append("========================================\n\n");
for (int i = 0; i < articles.size(); i++) {
Article article = articles.get(i);
sb.append("【文章 ").append(i + 1).append("】\n");
sb.append("标题: ").append(article.getTitle()).append("\n");
sb.append("作者: ").append(article.getAuthor() != null ? article.getAuthor() : "未知").append("\n");
sb.append("发布日期: ").append(article.getPublishDate() != null ? article.getPublishDate() : "未知").append("\n");
sb.append("来源链接: ").append(article.getUrl()).append("\n");
sb.append("────────────────\n");
sb.append("内容:\n").append(article.getContent()).append("\n\n");
}
java.nio.file.Files.writeString(
new File(BASE_DIR + File.separator + filename).toPath(),
sb.toString(),
java.nio.charset.StandardCharsets.UTF_8
);
}
public List<Article> loadFromJson(String filename) throws IOException {
logger.info("Loading articles from JSON: {}", filename);
File file = new File(BASE_DIR + File.separator + filename);
if (!file.exists()) {
logger.warn("File not found: {}", filename);
return List.of();
}
List<Article> articles = mapper.readValue(file,
mapper.getTypeFactory().constructCollectionType(List.class, Article.class));
return articles;
}
public void saveAnalysisResult(java.util.Map<String, Object> analysis, String filename) throws IOException {
logger.info("Saving analysis result to: {}", filename);
File dir = new File(BASE_DIR);
if (!dir.exists()) {
dir.mkdirs();
}
String fullPath = BASE_DIR + File.separator + filename;
mapper.writeValue(new File(fullPath), analysis);
}
public void saveAnalysisToTxt(java.util.Map<String, Object> analysis, String filename) throws IOException {
logger.info("Saving analysis to TXT: {}", filename);
File dir = new File(BASE_DIR);
if (!dir.exists()) {
dir.mkdirs();
}
StringBuilder sb = new StringBuilder();
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss"));
sb.append("========================================\n");
sb.append(" 数据分析报告\n");
sb.append("========================================\n");
sb.append("生成时间: ").append(timestamp).append("\n");
sb.append("========================================\n\n");
sb.append("【基本统计】\n");
sb.append("────────────────\n");
sb.append("文章总数: ").append(analysis.get("totalArticles")).append("\n");
sb.append("平均内容长度: ").append(analysis.get("avgContentLength")).append(" 字符\n");
sb.append("最常出现作者: ").append(analysis.get("mostFrequentAuthor")).append("\n\n");
@SuppressWarnings("unchecked")
java.util.Map<String, Object> stats = (java.util.Map<String, Object>) analysis.get("articleStats");
if (stats != null) {
sb.append("【内容统计详情】\n");
sb.append("────────────────\n");
sb.append("最短内容: ").append(stats.get("minLength")).append(" 字符\n");
sb.append("最长内容: ").append(stats.get("maxLength")).append(" 字符\n");
sb.append("总内容长度: ").append(stats.get("totalLength")).append(" 字符\n\n");
}
@SuppressWarnings("unchecked")
java.util.Map<String, Object> ratingAnalysis = (java.util.Map<String, Object>) analysis.get("ratingAnalysis");
if (ratingAnalysis != null && (Boolean) ratingAnalysis.get("hasRatings")) {
sb.append("【评分分析】\n");
sb.append("────────────────\n");
sb.append("评分数量: ").append(ratingAnalysis.get("count")).append("\n");
sb.append("平均评分: ").append(ratingAnalysis.get("avgRating")).append("\n");
sb.append("最高评分: ").append(ratingAnalysis.get("maxRating")).append("\n");
sb.append("最低评分: ").append(ratingAnalysis.get("minRating")).append("\n\n");
}
@SuppressWarnings("unchecked")
java.util.Map<String, Integer> wordFreq = (java.util.Map<String, Integer>) analysis.get("wordFrequency");
if (wordFreq != null && !wordFreq.isEmpty()) {
sb.append("【词汇频率 Top 10】\n");
sb.append("────────────────\n");
int rank = 1;
for (java.util.Map.Entry<String, Integer> entry : wordFreq.entrySet()) {
sb.append(String.format("%2d. %-8s 出现 %d 次\n", rank++, entry.getKey(), entry.getValue()));
}
sb.append("\n");
}
java.nio.file.Files.writeString(
new File(BASE_DIR + File.separator + filename).toPath(),
sb.toString(),
java.nio.charset.StandardCharsets.UTF_8
);
}
public void clearOldFiles() {
File dir = new File(BASE_DIR);
if (!dir.exists()) return;
File[] files = dir.listFiles();
if (files == null) return;
for (File file : files) {
String name = file.getName();
if (!name.equals("古诗文数据.json") && !name.equals("古诗文数据.txt") &&
!name.equals("豆瓣电影评分.json") && !name.equals("豆瓣电影评分.txt") &&
!name.equals("长沙天气.json") && !name.equals("长沙天气.txt")) {
boolean deleted = file.delete();
if (deleted) {
logger.info("Deleted old file: {}", name);
}
}
}
}
public java.util.List<String> listFiles() {
File dir = new File(BASE_DIR);
if (!dir.exists()) {
return List.of();
}
File[] files = dir.listFiles();
if (files == null) {
return List.of();
}
return java.util.Arrays.stream(files)
.map(File::getName)
.sorted()
.collect(java.util.stream.Collectors.toList());
}
}

35
w11/java-cli/src/main/java/com/example/datacollect/strategy/BlogStrategy.java

@ -0,0 +1,35 @@
package com.example.datacollect.strategy;
import com.example.datacollect.model.Article;
public class BlogStrategy implements Strategy {
@Override
public Article execute(String url) {
String title = extractTitleFromBlog(url);
String content = extractContentFromBlog(url);
String author = extractAuthorFromBlog(url);
String publishDate = extractPublishDateFromBlog(url);
Article article = new Article(title, url, content);
article.setAuthor(author);
article.setPublishDate(publishDate);
return article;
}
private String extractTitleFromBlog(String url) {
return "Blog Post: " + url.substring(url.lastIndexOf('/') + 1);
}
private String extractContentFromBlog(String url) {
return "Blog content crawled from: " + url + "\n\nThis is a blog-specific content extraction.";
}
private String extractAuthorFromBlog(String url) {
return "Blog Author";
}
private String extractPublishDateFromBlog(String url) {
return java.time.LocalDate.now().toString();
}
}

69
w11/java-cli/src/main/java/com/example/datacollect/strategy/CrawlStrategy.java

@ -0,0 +1,69 @@
package com.example.datacollect.strategy;
import com.example.datacollect.exception.CrawlerException;
import com.example.datacollect.exception.NetworkException;
import com.example.datacollect.exception.ParseException;
import com.example.datacollect.model.Article;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CrawlStrategy implements Strategy {
protected static final Logger logger = LoggerFactory.getLogger(CrawlStrategy.class);
@Override
public Article execute(String url) throws CrawlerException {
logger.info("Starting crawl for URL: {}", url);
if (url == null || url.trim().isEmpty()) {
logger.error("Invalid URL provided");
throw new IllegalArgumentException("URL cannot be null or empty");
}
try {
logger.debug("Connecting to: {}", url);
Document doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.referrer("https://www.google.com")
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
.timeout(10000)
.get();
logger.debug("Successfully fetched document from: {}", url);
return parse(doc, url);
} catch (java.io.IOException e) {
logger.error("Network error while fetching {}: {}", url, e.getMessage());
throw new NetworkException("Failed to fetch URL: " + url, url, e);
}
}
protected Article parse(Document doc, String url) throws ParseException {
logger.debug("Parsing document for URL: {}", url);
if (doc == null) {
logger.error("Document is null for URL: {}", url);
throw new ParseException("Document is null", url);
}
String title = doc.title();
if (title == null || title.isEmpty()) {
logger.warn("Title is empty for URL: {}, using 'Untitled'", url);
title = "Untitled";
}
String content = doc.text();
if (content == null || content.isEmpty()) {
logger.error("Failed to extract content from document: {}", url);
throw new ParseException("Failed to extract content from document", url);
}
Article article = new Article(title, url, content);
article.setAuthor("Unknown");
article.setPublishDate(java.time.LocalDate.now().toString());
logger.debug("Successfully parsed article: {}", title);
return article;
}
}

137
w11/java-cli/src/main/java/com/example/datacollect/strategy/DoubanMovieStrategy.java

@ -0,0 +1,137 @@
package com.example.datacollect.strategy;
import com.example.datacollect.exception.CrawlerException;
import com.example.datacollect.exception.NetworkException;
import com.example.datacollect.exception.ParseException;
import com.example.datacollect.model.Article;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class DoubanMovieStrategy implements Strategy {
private static final Logger logger = LoggerFactory.getLogger(DoubanMovieStrategy.class);
@Override
public Article execute(String url) throws CrawlerException {
logger.info("Starting crawl for Douban Movie: {}", url);
try {
Document doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.referrer("https://www.douban.com")
.timeout(10000)
.get();
return parse(doc, url);
} catch (java.io.IOException e) {
logger.error("Network error while crawling Douban: {}", url, e);
throw new NetworkException("Failed to connect to Douban: " + e.getMessage(), url, e);
}
}
private Article parse(Document doc, String url) throws ParseException {
if (doc == null) {
throw new ParseException("Document is null", url);
}
StringBuilder content = new StringBuilder();
if (url.contains("/subject/")) {
Element titleElement = doc.selectFirst("h1 span");
Element ratingElement = doc.selectFirst("strong.rating_num");
Element yearElement = doc.selectFirst("span.year");
Element directorElement = doc.selectFirst("div#info span:nth-child(1) a");
Element summaryElement = doc.selectFirst("span.all.hidden");
if (summaryElement == null) {
summaryElement = doc.selectFirst("div.indent span");
}
String title = titleElement != null ? titleElement.text() : "Unknown";
String rating = ratingElement != null ? ratingElement.text() : "N/A";
String year = yearElement != null ? yearElement.text().replace("(", "").replace(")", "") : "N/A";
String director = directorElement != null ? directorElement.text() : "Unknown";
String summary = summaryElement != null ? summaryElement.text() : "";
content.append("Movie: ").append(title).append("\n");
content.append("Year: ").append(year).append("\n");
content.append("Director: ").append(director).append("\n");
content.append("Rating: ").append(rating).append("/10\n\n");
content.append("Summary: ").append(summary);
Article article = new Article(title, url, content.toString());
article.setAuthor(director);
article.setPublishDate(year);
return article;
} else {
Elements movies = doc.select("div.item");
if (movies.isEmpty()) {
logger.warn("Primary selector 'div.item' failed, trying alternative selectors");
movies = doc.select("div.pl2");
}
if (movies.isEmpty()) {
movies = doc.select("tr.item td");
}
if (movies.isEmpty()) {
logger.warn("All selectors failed, extracting from document text");
String text = doc.text();
if (text.length() > 0) {
content.append("Douban Movie Chart\n");
content.append(String.format("%40s", "").replace(" ", "=")).append("\n");
content.append(text.substring(0, Math.min(2000, text.length())));
Article article = new Article("Douban Movies", url, content.toString());
article.setAuthor("Douban");
article.setPublishDate(java.time.LocalDate.now().toString());
return article;
}
throw new ParseException("No movies found in document", url);
}
int count = 0;
for (Element movie : movies) {
if (count >= 10) break;
Element title = movie.selectFirst("a");
Element rating = movie.selectFirst("span.rating_nums, span.rating_num");
Element year = movie.selectFirst("span.year, span.pl");
if (title != null) {
String titleText = title.text().trim();
if (!titleText.isEmpty()) {
content.append(count + 1).append(". ").append(titleText);
if (year != null) {
String yearText = year.text().trim();
if (!yearText.isEmpty() && yearText.length() < 50) {
content.append(" (").append(yearText).append(")");
}
}
if (rating != null) {
content.append(" - Rating: ").append(rating.text());
}
content.append("\n");
count++;
}
}
}
if (count == 0) {
logger.warn("No valid movie entries found, extracting page title");
content.append("Douban Movie Chart\n");
content.append("Page Title: ").append(doc.title()).append("\n");
content.append("URL: ").append(url);
}
Article article = new Article("Douban Movies", url, content.toString());
article.setAuthor("Douban");
article.setPublishDate(java.time.LocalDate.now().toString());
return article;
}
}
}

109
w11/java-cli/src/main/java/com/example/datacollect/strategy/GushiwenStrategy.java

@ -0,0 +1,109 @@
package com.example.datacollect.strategy;
import com.example.datacollect.exception.CrawlerException;
import com.example.datacollect.exception.NetworkException;
import com.example.datacollect.exception.ParseException;
import com.example.datacollect.model.Article;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class GushiwenStrategy implements Strategy {
private static final Logger logger = LoggerFactory.getLogger(GushiwenStrategy.class);
@Override
public Article execute(String url) throws CrawlerException {
logger.info("Starting crawl for Gushiwen: {}", url);
try {
Document doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.referrer("https://www.gushiwen.cn/")
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8")
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
.header("Accept-Encoding", "gzip, deflate")
.header("Connection", "keep-alive")
.timeout(15000)
.get();
return parse(doc, url);
} catch (java.io.IOException e) {
logger.error("Network error while crawling Gushiwen: {}", url, e);
throw new NetworkException("Failed to connect to Gushiwen: " + e.getMessage(), url, e);
}
}
private Article parse(Document doc, String url) throws ParseException {
if (doc == null) {
throw new ParseException("Document is null", url);
}
StringBuilder content = new StringBuilder();
if (url.contains("/shiwenv_")) {
Element titleElement = doc.selectFirst("h1.title");
Element authorElement = doc.selectFirst("p.source a");
Element contentElement = doc.selectFirst("div.contson");
if (titleElement == null && authorElement == null && contentElement == null) {
throw new ParseException("Failed to find any elements in document", url);
}
String title = titleElement != null ? titleElement.text() : "Unknown";
String author = authorElement != null ? authorElement.text() : "Unknown";
String poemContent = contentElement != null ? contentElement.text() : "";
content.append("Title: ").append(title).append("\n");
content.append("Author: ").append(author).append("\n\n");
content.append("Content: ").append(poemContent);
Article article = new Article(title, url, content.toString());
article.setAuthor(author);
article.setPublishDate(java.time.LocalDate.now().toString());
return article;
} else {
Elements poems = doc.select("div.sons");
int count = 0;
for (Element poem : poems) {
if (count >= 5) break;
Element title = poem.selectFirst("b a");
Element author = poem.selectFirst("p.source");
Element preview = poem.selectFirst("p.cont");
if (title != null) {
content.append(count + 1).append(". ");
content.append(title.text());
if (author != null) {
content.append(" - ").append(author.text());
}
content.append("\n");
if (preview != null) {
content.append(preview.text()).append("\n\n");
}
count++;
}
}
if (content.length() == 0) {
logger.warn("No poems found with primary selector, trying alternative");
content.append("No poems found. Trying alternative selector...\n");
Elements items = doc.select("div.left > div");
for (Element item : items) {
String text = item.text();
if (text.length() > 10) {
content.append(text).append("\n\n");
}
}
}
Article article = new Article("Gushiwen Collection", url, content.toString());
article.setAuthor("Gushiwen");
article.setPublishDate(java.time.LocalDate.now().toString());
return article;
}
}
}

40
w11/java-cli/src/main/java/com/example/datacollect/strategy/NewStrategy.java

@ -0,0 +1,40 @@
package com.example.datacollect.strategy;
import com.example.datacollect.model.Article;
public class NewStrategy implements Strategy {
@Override
public Article execute(String url) {
String title = generateTitle(url);
String content = fetchContent(url);
String author = getAuthorInfo(url);
String publishDate = getCurrentDate();
Article article = new Article(title, url, content);
article.setAuthor(author);
article.setPublishDate(publishDate);
return article;
}
private String generateTitle(String url) {
return "[NEW] " + url;
}
private String fetchContent(String url) {
StringBuilder content = new StringBuilder();
content.append("=== New Strategy Content ===\n");
content.append("Source: ").append(url).append("\n");
content.append("Extracted: ").append(java.time.LocalDateTime.now()).append("\n");
content.append("Status: Successfully crawled using NewStrategy\n");
return content.toString();
}
private String getAuthorInfo(String url) {
return "New Strategy Bot";
}
private String getCurrentDate() {
return java.time.LocalDateTime.now().toString();
}
}

8
w11/java-cli/src/main/java/com/example/datacollect/strategy/Strategy.java

@ -0,0 +1,8 @@
package com.example.datacollect.strategy;
import com.example.datacollect.exception.CrawlerException;
import com.example.datacollect.model.Article;
public interface Strategy {
Article execute(String url) throws CrawlerException;
}

38
w11/java-cli/src/main/java/com/example/datacollect/strategy/StrategyFactory.java

@ -0,0 +1,38 @@
package com.example.datacollect.strategy;
import java.util.HashMap;
import java.util.Map;
public class StrategyFactory {
private static final Map<String, Strategy> strategyMap = new HashMap<>();
static {
strategyMap.put("default", new CrawlStrategy());
strategyMap.put("blog", new BlogStrategy());
strategyMap.put("new", new NewStrategy());
strategyMap.put("gushiwen", new GushiwenStrategy());
strategyMap.put("douban", new DoubanMovieStrategy());
strategyMap.put("weather", new WeatherStrategy());
}
public static Strategy getStrategy(String type) {
if (type == null || type.isEmpty()) {
return strategyMap.get("default");
}
return strategyMap.getOrDefault(type.toLowerCase(), strategyMap.get("default"));
}
public static void registerStrategy(String type, Strategy strategy) {
if (type != null && strategy != null) {
strategyMap.put(type.toLowerCase(), strategy);
}
}
public static boolean hasStrategy(String type) {
return type != null && strategyMap.containsKey(type.toLowerCase());
}
public static Map<String, Strategy> getAllStrategies() {
return new HashMap<>(strategyMap);
}
}

127
w11/java-cli/src/main/java/com/example/datacollect/strategy/WeatherStrategy.java

@ -0,0 +1,127 @@
package com.example.datacollect.strategy;
import com.example.datacollect.exception.CrawlerException;
import com.example.datacollect.exception.NetworkException;
import com.example.datacollect.exception.ParseException;
import com.example.datacollect.model.Article;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class WeatherStrategy implements Strategy {
private static final Logger logger = LoggerFactory.getLogger(WeatherStrategy.class);
@Override
public Article execute(String url) throws CrawlerException {
logger.info("Starting crawl for Weather: {}", url);
try {
Document doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.timeout(10000)
.get();
return parse(doc, url);
} catch (java.io.IOException e) {
logger.warn("Primary weather URL failed, trying fallback: {}", url);
return tryFallback(url);
}
}
private Article tryFallback(String originalUrl) throws CrawlerException {
String weatherUrl = "https://www.tianqi.com/changsha/";
try {
Document doc = Jsoup.connect(weatherUrl)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.timeout(10000)
.get();
return parseFallback(doc, weatherUrl);
} catch (java.io.IOException e) {
logger.error("Fallback weather URL also failed: {}", weatherUrl, e);
throw new NetworkException("Failed to connect to weather service", weatherUrl, e);
}
}
private Article parse(Document doc, String url) throws ParseException {
if (doc == null) {
throw new ParseException("Document is null", url);
}
StringBuilder content = new StringBuilder();
Element cityElement = doc.selectFirst("h1.city-name");
Element tempElement = doc.selectFirst("div.temperature span.value");
Element weatherElement = doc.selectFirst("div.weather-status span");
Element humidityElement = doc.selectFirst("div.humidity span.value");
Element windElement = doc.selectFirst("div.wind span.value");
Element updateTimeElement = doc.selectFirst("div.update-time");
String city = cityElement != null ? cityElement.text() : "Changsha";
String temp = tempElement != null ? tempElement.text() + "°C" : "N/A";
String weather = weatherElement != null ? weatherElement.text() : "N/A";
String humidity = humidityElement != null ? humidityElement.text() + "%" : "N/A";
String wind = windElement != null ? windElement.text() : "N/A";
String updateTime = updateTimeElement != null ? updateTimeElement.text() : "";
content.append("City: ").append(city).append("\n");
content.append("Temperature: ").append(temp).append("\n");
content.append("Weather: ").append(weather).append("\n");
content.append("Humidity: ").append(humidity).append("\n");
content.append("Wind: ").append(wind).append("\n");
content.append("Update Time: ").append(updateTime);
content.append("\n\nForecast:\n");
Elements forecast = doc.select("div.forecast-item");
for (Element day : forecast) {
Element date = day.selectFirst("div.date");
Element dayWeather = day.selectFirst("div.weather");
Element dayTemp = day.selectFirst("div.temp-range");
if (date != null) {
content.append(date.text());
if (dayWeather != null) {
content.append(" ").append(dayWeather.text());
}
if (dayTemp != null) {
content.append(" ").append(dayTemp.text());
}
content.append("\n");
}
}
Article article = new Article(city + " Weather", url, content.toString());
article.setAuthor("Weather API");
article.setPublishDate(java.time.LocalDateTime.now().toString());
return article;
}
private Article parseFallback(Document doc, String url) throws ParseException {
if (doc == null) {
throw new ParseException("Fallback document is null", url);
}
StringBuilder content = new StringBuilder();
Element tempElement = doc.selectFirst("div.now span.temp");
Element weatherElement = doc.selectFirst("div.now span.wea");
Element humidityElement = doc.selectFirst("div.now span.humidity");
String temp = tempElement != null ? tempElement.text() : "N/A";
String weather = weatherElement != null ? weatherElement.text() : "N/A";
String humidity = humidityElement != null ? humidityElement.text() : "N/A";
content.append("City: Changsha\n");
content.append("Temperature: ").append(temp).append("\n");
content.append("Weather: ").append(weather).append("\n");
content.append("Humidity: ").append(humidity).append("\n");
Article article = new Article("Changsha Weather", url, content.toString());
article.setAuthor("Weather API");
article.setPublishDate(java.time.LocalDateTime.now().toString());
return article;
}
}

42
w11/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java

@ -0,0 +1,42 @@
package com.example.datacollect.view;
import com.example.datacollect.model.Article;
import java.util.List;
import java.util.Scanner;
public class ConsoleView {
private static final String ANSI_RESET = "\u001B[0m";
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_BLUE = "\u001B[34m";
private final Scanner scanner = new Scanner(System.in);
public String readLine() {
System.out.print("> ");
return scanner.nextLine();
}
public void printSuccess(String msg) {
System.out.println(ANSI_GREEN + msg + ANSI_RESET);
}
public void printError(String msg) {
System.out.println(ANSI_RED + msg + ANSI_RESET);
}
public void printInfo(String msg) {
System.out.println(ANSI_BLUE + msg + ANSI_RESET);
}
public void display(List<Article> articles) {
if (articles.isEmpty()) {
printInfo("暂无文章,请先执行 crawl。");
return;
}
for (int i = 0; i < articles.size(); i++) {
Article a = articles.get(i);
System.out.println((i + 1) + ". " + a.getTitle() + " | " + a.getUrl());
}
}
}

43
w11/java-cli/src/main/resources/logback.xml

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_HOME" value="./logs"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>${LOG_HOME}/crawler.log</file>
<append>true</append>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<appender name="ROLLING_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/crawler.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/crawler.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<logger name="com.example.datacollect" level="INFO"/>
<logger name="com.example.datacollect.strategy" level="DEBUG"/>
<logger name="com.example.datacollect.service" level="DEBUG"/>
<logger name="com.example.datacollect.controller" level="INFO"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ROLLING_FILE"/>
</root>
</configuration>

758
w11/java-cli/target/W9工程架构 - 教案v3.md

@ -0,0 +1,758 @@
---
# 教案:《高级程序设计》第9周——工程架构:从"写代码"到"造系统"
| 项目 | 内容 |
|------|------|
| **课程名称** | 高级程序设计 |
| **周次** | 第9周 |
| **主题** | 工程架构——从"写代码"到"造系统" |
| **学时** | 2学时(90分钟) |
| **授课对象** | 具备Python基础、已完成Java面向对象特性学习的学生 |
| **教学环境** | JDK 17+、IntelliJ IDEA、Maven(模板) |
| **前情提要** | 本课程原计划使用JavaFX GUI,后根据教学反馈转向CLI + MVC + 爬虫工程化 |
---
## 教学调整说明:为什么选择CLI而不是GUI?
> **原计划**:JavaFX桌面应用 → **新计划**:CLI命令行应用
| 维度 | GUI (JavaFX) | CLI (命令行) |
|------|--------------|-------------|
| **学习重心** | 布局、控件、事件监听 | 架构、分层、命令路由 |
| **学生痛点** | "窗口点击"与后端能力无关 | 真正锻炼工程思维 |
| **AI辅助** | AI生成FXML,学生看不懂 | AI辅助重构架构 |
| **工程化** | 脱离真实后端开发场景 | 模拟真实服务器/大数据开发 |
| **核心转型** | "视觉装饰"优先 | "逻辑架构"优先 |
**决策理由**:
1. **985学生需要的是工程思维**,不是拖控件
2. **接口抽象**是弱项,CLI + MVC更能暴露这个问题
3. **彩色终端**足够酷炫,且代码量可控
**更深层的教育价值**:
> 在GUI框架中,架构已被框架强制划定,学生只是"遵守规矩";而CLI世界里没有任何框架告诉你模型在哪、视图在哪——**当外部约束消失,内部的工程纪律才真正建立**。这正是本节课要传递的核心精神。
---
## 一、教学目标
| 目标维度 | 具体描述 |
|----------|----------|
| **知识掌握** | 理解MVC架构的职责划分及其演化脉络;掌握Maven项目结构与pom.xml基础;理解Command模式的路由原理。 |
| **工程实践** | 能搭建规范的Maven项目包结构;能实现基于Scanner的控制台交互;能用Command接口实现可扩展的命令路由;能识别架构中的"越权行为"。 |
| **思维转型** | 从"一个类写全部"转向"分层解耦";从"修改现有代码"转向"新增类实现功能";从"满足功能"转向"代码的工程洁癖"。 |
| **工具应用** | 利用AI辅助审查MVC职责越权;让AI扮演"架构审计师"检查分层是否清晰;理解AI生成代码中的架构缺陷。 |
---
## 二、教学重点与难点
| 项目 | 内容 | 突破方法 |
|------|------|----------|
| **重点** | MVC三层职责划分、CLI交互实现、Command接口解耦、代码中的工程细节(常量、输出归属) | 以"新增命令需要改什么"为切入点,展示Command模式的优势;通过现场"代码找茬"强化细节意识 |
| **难点** | Controller不写业务逻辑、Command接口的多态实现、共享数据模型的设计缺陷识别 | 现场演示:增加一个命令只需新建类,无需修改Controller;暴露`List<Article>`共享引用的问题并预告解决方案 |
---
## 三、教学过程设计(90分钟)
| 环节 | 时间 | 教学内容 | 师生活动 | AI协同点 |
|------|------|----------|----------|----------|
| **1. 痛点引入:从脚本到工程的鸿沟** | 10' | 展示"意大利面"式爬虫代码,演示改一处需要动全身 | **教师演示**:现场展示一段混乱代码,让学生找问题 | 用AI分析代码耦合度 |
| **2. CLI vs GUI:架构选择的思考** | 10' | 对比两种方案的优缺点,解释为什么CLI更适合培养工程思维 | **教师讲解**:用对比表格说明选择CLI的理由 | — |
| **3. MVC分层设计** | 20' | 讲解Model/View/Controller三层职责,用"餐厅类比"强化理解,随后批判类比局限性 | **教师讲解**:配合架构图讲解三层交互,引导学生寻找类比破绽 | 用AI生成MVC职责对照表 |
| **4. Command模式:可扩展的命令路由** | 15' | 引入Command接口,解释"一个命令就是一个类" | **类比**:Command像酒店的服务部门,Controller是前台 | 让AI解释Command模式的多态原理 |
| **5. Maven模板与环境** | 5' | 直接使用提供的Maven模板,讲解目录结构 | **教师演示**:解压模板 → IDEA打开 → 运行 | — |
| **6. 三层代码落地** | 20' | **Model**:Article实体<br>**View**:ConsoleView(ANSI常量)<br>**Command接口**+实现<br>**Controller**:Map路由 | **教师演示**:分步写出代码,刻意埋入1~2个"越权细节"让学生找茬 | 学生用AI做"架构审计" |
| **7. 架构反思与展望** | 5' | 指出当前`List<Article>`共享引用的问题,预告W10策略模式与仓库层 | **师生互动**:你发现这个设计有什么风险? | 让AI分析共享可变状态的危害 |
| **8. 实践任务:空壳程序** | 5' | 搭建完整包结构,实现CLI循环 | 学生现场编码,教师巡视 | 完成后用AI检查包结构 |
| **9. 总结与过渡** | 5' | 本周实现了"骨架+命令可扩展",下周填入"灵魂"——解析器,并解决数据安全问题 | 总结Command模式优势,预告策略模式 | — |
---
## 四、核心教学内容脚本
### 4.1 痛点引入:从脚本到工程的鸿沟(10分钟)
**教师口播**:
> "同学们,前8周我们学的是Java语法,从变量到类,从继承到接口。但有一个问题:代码写完之后,怎么组织?"
>
> "来看这段代码——这是某个同学写的'爬虫',他一个人完成了一个'完整'的项目。"
**展示"脚本式"代码**:
```java
public class Crawler {
public static void main(String[] args) {
System.out.print("请输入URL: ");
Scanner scanner = new Scanner(System.in);
String url = scanner.nextLine();
List titles = new ArrayList();
try {
Document doc = Jsoup.connect(url).get();
Elements elements = doc.select(".post-title");
for (Element e : elements) {
String title = e.text();
System.out.println("标题: " + title);
titles.add(title);
}
} catch (Exception ex) {
System.out.println("出错啦: " + ex.getMessage());
}
}
}
```
**提问引导**:
1. "如果我想把标题保存到文件,要改哪里?"
2. "如果我想支持另一个网站,它的HTML结构不一样,要怎么办?"
3. "如果我想让输出变成彩色,要改哪里?"
**痛点提炼**:
> "看到了吗?才60行代码,已经'牵一发而动全身'了。这就是一个'脚本'的宿命——功能全混在一起,改一个小需求,整个文件都要翻。"
>
> "这周我们要解决:**怎么让代码'改起来不疼'?**"
---
### 4.2 CLI vs GUI:架构选择的思考(10分钟)
**教师口播**:
> "既然要写一个'完整'的爬虫应用,我们有两个选择:图形界面(GUI)或命令行界面(CLI)。为什么我推荐CLI而不是GUI?"
**对比表格**
| 维度 | GUI (JavaFX) | CLI (命令行) |
|------|--------------|-------------|
| **代码量** | FXML + Controller + CSS,大量模板代码 | 纯Java,代码量可控 |
| **学习重心** | 布局、控件、事件监听 | 架构、分层、命令路由 |
| **后端能力** | 几乎无关 | 模拟真实服务器开发 |
| **可测试性** | 难(需要UI测试框架) | 易(直接测试Command类) |
| **工程思维** | 弱(关注视觉) | 强(关注逻辑) |
**核心观点**:
> **CLI更需要MVC!** GUI有现成的事件系统(点击按钮→触发事件),而CLI只有字符流。**没有架构,分分钟写成脚本**。MVC在CLI里是"刚需",不是"装饰"。
>
> **更深一层**:在GUI里,框架已经硬塞给你一套架构,你只是在填空;但在CLI里,所有结构都必须由你亲手搭建。**当外部约束消失,内部的工程纪律才真正开始建立**——这才是本节课的真正目的。
**CLI也能很酷**:
- ANSI彩色输出(红/绿/黄/蓝)
- 表格展示数据
- 进度条动画
- 模拟真实大数据开发场景
---
### 4.3 MVC分层设计(20分钟)
#### 4.3.1 MVC的起源与演进
**教师口播**:
> "MVC不是新东西,它是1970年代为桌面应用设计的架构思想。但它的核心——'职责分离'——在任何软件里都适用。"
| 年代 | 场景 | MVC的角色 |
|------|------|----------|
| 1970s | Smalltalk-72 GUI | 最早的用户界面架构 |
| 1990s | Web开发 (Struts) | 后端模板引擎 |
| 2000s | ASP.NET MVC | 现代Web框架 |
| 2020s | CLI + API | 解耦业务逻辑与表现层 |
#### 4.3.2 从GUI到CLI的映射
| GUI组件 | CLI对应 | 说明 |
|--------|--------|------|
| 窗口/按钮 | 命令行输入 | **View = 用户交互** |
| 数据模型 | Article实体类 | **Model = 数据结构** |
| 事件监听 | Command路由 | **Controller = 调度** |
#### 4.3.3 MVC三层职责
**架构图示**:
```
┌─────────────────────────────────────────┐
│ 入口 │
│ (main方法) │
└─────────────────┬───────────────────────┘
┌─────────────────────────────────────────┐
│ Controller │
│ - 接收命令(crawl, help, exit) │
│ - 分发给对应的Command │
│ 【口诀】:Controller不管"怎么做", │
│ 只管"派给谁" │
└─────────┬───────────────┬───────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Model │ │ View │
│ - 数据实体 │ │ - 输入解析 │
│ - 业务逻辑 │ │ - 输出格式化 │
│ 【口诀】: │ │ 【口诀】: │
│ Model管"数据" │ │ View管"呈现" │
└─────────────────┘ └─────────────────┘
```
**三层职责详解**
| 层级 | 职责 | 典型代码 | 禁止做什么 |
|------|------|----------|------------|
| **Model** | 数据结构 + 业务逻辑 | `class Article { String title; String content; }` | 不能有`System.out.println`,不能有`Scanner` |
| **View** | 接收用户输入 + 格式化输出 | `class ConsoleView { String readInput(); void print(String); }` | 不能写爬虫逻辑,只做"传声筒" |
| **Controller** | 协调调度 | `class CrawlerController { void handle(String cmd) { ... } }` | 不能直接写业务细节,委托给Command |
#### 4.3.4 类比强化:"餐厅类比"
> "把MVC想象成一家餐厅:
> - **Model是后厨**:只管做菜(数据加工),不管谁来吃、怎么端
> - **View是服务员**:只管端菜和收钱(输入输出),不管菜怎么做
> - **Controller是前台**:只管把顾客的点单传给后厨,把做好的菜端给顾客
>
> 如果后厨开始管'谁来吃饭',这餐厅就乱了。"
#### 4.3.5 对"餐厅类比"的批判性思考(关键!)
**教师导引**:
> "刚才的类比好理解吗?很好。但任何一个类比都有它的边界,如果把它当成真理,就会出问题。现在我们来给这个类比'找茬'。"
**提问学生**:
1. "后厨真的完全不知道客人是谁吗?如果客人有忌口(比如不吃香菜),这个信息需不需要传到后厨?"
2. "服务员只是端菜吗?在真实餐厅里,服务员经常向后厨反馈'客人觉得今天的菜咸了',这属于View→Model的反向影响吗?"
3. "在这个类比里,我们把前台(Controller)和后厨(Model)的关系说成单向的。但实际上,后厨做完了菜,需要通知前台'菜好了',这不就是**观察者模式**吗?"
**点明本质**:
> "实际MVC的数据流向常常是**双向**的:Controller调用Model的方法改变数据,Model变化后又通知View更新显示。只不过在本次CLI项目中,我们暂时使用'请求-响应'的单向简化模型——用户输入命令,系统处理,然后立即输出结果。这个简化版够用,但你要知道完整的MVC是更动态的。随着系统复杂,Model层需要一个专门的'仓库类'来管理数据,并通知视图刷新——这正是W10我们将要深入的内容。"
#### 4.3.6 MVC的数据流向(本课程简化版)
```
CLI用户输入
View(解析命令字符串)
Controller(找到对应Command)
Command.execute()(执行业务逻辑)
Model(Article数据,目前暂存于List)
View(display()展示数据)
CLI终端显示
```
---
### 4.4 Command模式:可扩展的命令路由(15分钟)
**教师口播**:
> "现在引入一个设计模式——Command(命令)模式。它的核心思想是:**一个命令就是一个类**。"
#### 4.4.1 为什么需要Command模式?
**演示:增加一个命令的代价(switch-case版)**
```java
// 现状代码
switch (cmd) {
case "crawl": handleCrawl(); break;
case "help": showHelp(); break;
// 如果要增加 list 命令?
// 1. 加 case "list"
// 2. 加 handleList() 方法
// 3. 可能还要改其他地方...
}
```
**提问**:
- "如果我想增加10个命令,这个类要改多少次?"
- "如果我不小心删了一个case,整个程序还能跑吗?"
**痛点提炼**:
> "每加一个功能,就要在这个类里戳一个洞。**这就是'肥控制器'陷阱**——所有的逻辑都堆在Controller里,它变成了新的'意大利面'。"
#### 4.4.2 Command模式的四个要素
| 要素 | 角色 | 示例 |
|------|------|------|
| **Command接口** | 抽象的"订单" | `Command` 接口 |
| **ConcreteCommand** | 具体的订单 | `HelpCommand`、`CrawlCommand` |
| **Invoker** | 接单的前台 | `CrawlerController` |
| **Receiver** | 执行者 | `ConsoleView`、`ArticleRepository` |
#### 4.4.3 Command接口定义
```java
// src/main/java/com/crawler/command/Command.java
package com.crawler.command;
import com.crawler.model.Article;
import java.util.List;
public interface Command {
String getName(); // 命令名,如 "crawl"
void execute(String[] args, List<Article> articles); // 执行逻辑
}
```
#### 4.4.4 Controller的变革(从switch到Map)
```java
// 修改后的Controller
public class CrawlerController {
private Map<String, Command> commands; // 用Map存命令
private ConsoleView view; // 持有View以输出错误
public CrawlerController(ConsoleView view, List<Article> articles) {
this.view = view;
this.commands = new HashMap<>();
// 增加命令无需改Controller代码,只需在这里注册
commands.put("crawl", new CrawlCommand(view));
commands.put("help", new HelpCommand(view));
commands.put("list", new ListCommand(view));
commands.put("exit", new ExitCommand(view));
}
public void handle(String input) {
if (input.isEmpty()) return;
String[] parts = input.split("\\s+");
String cmd = parts[0].toLowerCase();
Command command = commands.get(cmd);
if (command == null) {
view.printError("Unknown command: " + cmd); // 通过View输出,而非直接System.out
return;
}
// 执行命令,传入参数和文章列表
command.execute(parts, articles);
}
}
```
**对比表格**
| 维度 | switch-case | Command模式 |
|------|-------------|-------------|
| 增加命令 | 要改Controller | 新建一个类 |
| 多态体验 | 无 | execute()的多态调用 |
| 可测试性 | 难 | 每个Command可单独测试 |
| 代码量 | 少 | 多,但更清晰 |
**类比强化**:
> "Command模式就像**酒店的客房服务**:每个服务(清理、送餐、按摩)都是一个独立的部门。前台(Controller)只负责接电话,然后把请求'派发'给对应的部门。部门自己知道怎么干活,不需要前台教。"
>
> "如果想新增一个服务,前台只需要'登记'一下,不需要把现有部门重新装修。"
---
### 4.5 Maven模板与环境(5分钟)
**教师口播**:
> "这周我们不发愁pom.xml配置。我已经把 Maven 模板准备好了,你们只需要解压、打开、运行。"
**模板使用流程**:
```
1. 解压 [my-crawler-template.zip]
2. 用 IDEA 打开文件夹
3. 右键 pom.xml → Maven → Reload Project
4. 运行 App.java
```
**标准目录结构**:
```
src/main/java/com/crawler/
├── model/
│ └── Article.java
├── view/
│ └── ConsoleView.java
├── command/
│ ├── Command.java (接口)
│ ├── CrawlCommand.java
│ ├── HelpCommand.java
│ ├── ListCommand.java
│ └── ExitCommand.java
└── controller/
└── CrawlerController.java
```
---
### 4.6 代码落地(20分钟)
#### 4.6.1 Model层:Article实体
```java
// src/main/java/com/crawler/model/Article.java
package com.crawler.model;
public class Article {
private String title;
private String url;
private String content;
public Article(String title, String url, String content) {
this.title = title;
this.url = url;
this.content = content;
}
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
@Override
public String toString() {
return "Article{title='" + title + "', url='" + url + "'}";
}
}
```
#### 4.6.2 View层:ANSI常量集中管理(工程细节!)
```java
// src/main/java/com/crawler/view/ConsoleView.java
package com.crawler.view;
import com.crawler.model.Article;
import java.util.List;
import java.util.Scanner;
public class ConsoleView {
// ANSI颜色常量——集中管理,避免散落各处
private static final String ANSI_GREEN = "\033[32m";
private static final String ANSI_RED = "\033[31m";
private static final String ANSI_CYAN = "\033[36m";
private static final String ANSI_RESET = "\033[0m";
private Scanner scanner = new Scanner(System.in);
public String readLine() {
System.out.print("crawler> ");
return scanner.nextLine().trim();
}
public void print(String msg) {
System.out.println(msg);
}
public void printSuccess(String msg) {
print(ANSI_GREEN + msg + ANSI_RESET);
}
public void printError(String msg) {
print(ANSI_RED + msg + ANSI_RESET);
}
public void printInfo(String msg) {
print(ANSI_CYAN + msg + ANSI_RESET);
}
// 展示文章列表
public void display(List<Article> articles) {
if (articles.isEmpty()) {
printInfo("No articles yet. Use 'crawl <url>' first.");
return;
}
print("+----------+--------------------------------+");
print("| Title | URL |");
print("+----------+--------------------------------+");
for (Article a : articles) {
String title = a.getTitle();
if (title.length() > 10) title = title.substring(0, 10) + "..";
String url = a.getUrl();
if (url.length() > 30) url = url.substring(0, 27) + "...";
print("| " + String.format("%-10s", title) + " | " + url + " |");
}
print("+----------+--------------------------------+");
printInfo("Total: " + articles.size() + " articles");
}
}
```
**教师提示**:
> "注意:所有ANSI转义码都被定义为`private static final`常量。如果把`\033[32m`散落在项目各处,一旦想调整颜色,就得满世界去改——这正是我们之前痛批的'意大利面'。**这就是工程细节**。"
#### 4.6.3 Command接口与四个实现(全部通过View输出)
```java
// Command.java
public interface Command {
String getName();
void execute(String[] args, List<Article> articles);
}
// HelpCommand.java
public class HelpCommand implements Command {
private ConsoleView view;
public HelpCommand(ConsoleView v) { this.view = v; }
public String getName() { return "help"; }
public void execute(String[] args, List<Article> articles) {
view.printInfo("Commands: crawl <url>, list, help, exit");
}
}
// ListCommand.java
public class ListCommand implements Command {
private ConsoleView view;
public ListCommand(ConsoleView v) { this.view = v; }
public String getName() { return "list"; }
public void execute(String[] args, List<Article> articles) {
view.display(articles);
}
}
// CrawlCommand.java (存根)
public class CrawlCommand implements Command {
private ConsoleView view;
public CrawlCommand(ConsoleView v) { this.view = v; }
public String getName() { return "crawl"; }
public void execute(String[] args, List<Article> articles) {
if (args.length < 2) {
view.printError("Usage: crawl <url>");
return;
}
view.printInfo("Stub: Would crawl " + args[1]);
}
}
// ExitCommand.java
public class ExitCommand implements Command {
private ConsoleView view;
public ExitCommand(ConsoleView v) { this.view = v; }
public String getName() { return "exit"; }
public void execute(String[] args, List<Article> articles) {
view.printSuccess("Bye!"); // 全部输出都通过View,绝不让System.out直接出现在这里
System.exit(0);
}
}
```
**故意埋设的"找茬点"**:
> "我在刚才的代码里有没有隐藏违反MVC原则的地方?`CrawlCommand`的存根里,`view.printInfo("Stub: Would crawl " + args[1]);` —— 这个字符串拼接算是"业务逻辑"吗?留给大家用AI架构审计时讨论。
#### 4.6.4 Controller:Map路由(全部通过View输出)
```java
// src/main/java/com/crawler/controller/CrawlerController.java
package com.crawler.controller;
import com.crawler.command.*;
import com.crawler.model.Article;
import com.crawler.view.ConsoleView;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class CrawlerController {
private Map<String, Command> commands = new HashMap<>();
private ConsoleView view; // 持有View
private List<Article> articles;
public CrawlerController(ConsoleView view, List<Article> articles) {
this.view = view;
this.articles = articles;
commands.put("help", new HelpCommand(view));
commands.put("list", new ListCommand(view));
commands.put("crawl", new CrawlCommand(view));
commands.put("exit", new ExitCommand(view));
}
public void handle(String input) {
if (input.isEmpty()) return;
String[] parts = input.split("\\s+");
String cmdName = parts[0].toLowerCase();
Command cmd = commands.get(cmdName);
if (cmd == null) {
view.printError("Unknown command: " + cmdName); // 错误信息也走View!
return;
}
cmd.execute(parts, articles);
}
}
```
#### 4.6.5 main方法:组装
```java
// src/main/java/com/crawler/App.java
package com.crawler;
import com.crawler.controller.CrawlerController;
import com.crawler.model.Article;
import com.crawler.view.ConsoleView;
import java.util.ArrayList;
import java.util.List;
public class App {
public static void main(String[] args) {
ConsoleView view = new ConsoleView();
List<Article> articles = new ArrayList<>();
CrawlerController controller = new CrawlerController(view, articles);
view.printSuccess("Welcome to CLI Crawler!");
view.printInfo("Type 'help' for commands.");
while (true) {
controller.handle(view.readLine());
}
}
}
```
#### 4.6.6 架构反思与展望:共享List<Article>的隐患(关键!)
**教师口播**:
> "现在这个架构已经可用了。但请大家审视一下:我们所有的Command都直接拿到了`List<Article>`的引用。换句话说,任何一个命令都可以随意增、删、改这个列表。"
>
> "这就好像一家酒店,所有服务员、厨师、清洁工都能随意进出保险箱——**数据结构完全裸奔了**。"
**提问**:
- "如果CrawlCommand不小心写错了代码,把一个null塞进articles,HelpCommand会不会受影响?"
- "如果未来我们要在添加文章时也写入日志文件,现在的设计能优雅实现吗?还是得在所有Command里分别加日志代码?"
**预告解决方案**:
> "下周,我们将引入**策略模式**和一个真正的**Model仓库层(ArticleRepository)**。这个仓库会把`List`封装起来,对外只提供`add()`、`getAll()`等安全接口。任何命令想修改数据,都必须通过仓库。这就是从'数据结构'到'模型层'的进化——我们W9先搭骨架,W10给它装上盔甲。"
---
### 4.7 实践任务(5分钟)
**任务要求**:
1. 使用Maven模板创建项目
2. 实现完整包结构(model/view/command/controller)
3. 实现4个Command:help/list/crawl/exit
4. `list`命令能展示已抓取的文章
5. 运行并测试循环
6. **代码找茬(额外加分)**:找出你自己代码中是否存在`System.out`直接调用、硬编码ANSI字符串等"越权行为"
**验收标准**:
- [x] Maven编译通过
- [x] Command接口和4个实现分离在不同文件
- [x] Controller里没有switch-case
- [x] 新增命令只需新建类,不改Controller
- [x] list命令能正确显示空列表
- [x] 所有输出均通过ConsoleView完成,无直接System.out.println(main除外)
- [x] ANSI颜色码集中定义为View常量
---
## 五、课后作业
### 5.1 必做任务
1. **完善Article**:增加`author`、`publishDate`字段
2. **★ HistoryCommand(强制作业)**:
- 实现`history`命令,记录用户输入过的所有命令
- 使用`List<String>`存储历史(复习W8集合)
- 示例输出:
```
crawler> history
1. help
2. list
3. crawl https://example.com
```
3. **AI架构审计**:将类名和方法名发给AI,指令:
> "作为Java架构审计师,请检查我的MVC三层划分是否存在越权行为?Model层是否包含输入输出代码?View层是否越权写了业务逻辑?有没有地方直接使用了System.out或硬编码ANSI码?"
### 5.2 选做任务
1. **命令别名**:给`crawl`增加别名`c`,`help`增加别名`h`
2. **URL验证**:检查URL格式是否以http://或https://开头
3. **暗色主题**:实现不同的配色方案(利用View中的ANSI常量,只需修改一处即可)
4. **思考并回答**:分析`List<Article>`共享引用的潜在风险,写一段200字的小结
### 5.3 思考题
1. **Command vs switch-case**:增加10个命令,哪种方式代码改动量更小?
2. **如果不用Command接口,直接用Map存命令类行不行?** 接口的意义是什么?
3. **Controller里的`commands.put()`能否减少?** 提示:思考"注册机制"
4. **为什么ExitCommand里的`view.printSuccess("Bye!")`比直接`System.out.println`更"MVC"?** 提示:回忆View的职责
---
## 六、AI协同升级
### 架构审计师任务(必做)
**学生执行步骤**:
1. 列出项目中所有类名(不含方法实现)
2. 将类名列表发给AI
3. 输入指令:
> "作为Java架构审计师,请检查我的MVC三层划分是否清晰。Model层是否包含了不应该有的代码(Scanner/System.out)?View层是否越权写了业务逻辑?请指出任何一处直接使用System.out.println的地方,并建议如何改正。"
**预期AI输出**:
- 指出哪一层有越权行为
- 建议如何整改
- 评价整体架构健康度
### 进阶AI探究(选做)
> "假设我的Command接口中execute方法接收了一个`List<Article>`参数,请分析这种设计在工程上有什么隐患,并给出重构建议。"
---
## 七、教学反思与调整记录
| 日期 | 事项 | 调整内容 |
|------|------|----------|
| 2026-04-28 | 首次编写 | 基于CLI+MVC重构 |
| 2026-04-30 | 教授反馈 | 引入Command模式、提供Maven模板、升级AI协同比 |
| 2026-04-30 | 逻辑重排 | 按"问题→选择→架构→模式"顺序重写 |
| 2026-05-01 | v2 vs V3合并 | 融合深度改进:增加教育哲学、批判性思考、ANSI常量、共享List隐患、故意埋坑 |
---
## 附录1:Maven模板说明
> 老师提供`my-crawler-template.zip`压缩包,包含:
> - pom.xml(含Jsoup依赖)
> - 空的src/main/java结构
> - .gitignore
## 附录2:常见问题速查
| 问题 | 解答 |
|------|------|
| IDEA不识别pom.xml | 右键 pom.xml → Maven → Reload Project |
| 中文乱码 | Settings → Editor → File Encodings → UTF-8 |
| 包名大小写 | 包名全小写,类名首字母大写 |
| Command找不到 | 检查是否 implements Command,是否 @Override getName() |
| 命令不生效 | 检查 commands.put() 是否注册了该命令 |
| 输出颜色乱码 | IDEA控制台需支持ANSI,Windows下建议使用Windows Terminal或调整设置 |
| 我的System.out为什么被老师说越权 | View层才是与用户交互的唯一出口,所有输出都应通过View,这样将来改成GUI或日志时只需改View |
## 附录3:教学逻辑说明
| 顺序 | 内容 | 设计理由 |
|------|------|----------|
| 1 | 痛点引入 | 从问题出发,让学生感受"为什么需要架构" |
| 2 | CLI vs GUI | 解释技术选型,建立"工程思维 > 视觉装饰"的认知 |
| 3 | MVC分层 | 核心架构概念,理解职责分离,通过类比及批判加深理解 |
| 4 | Command模式 | 具体实现方式,解决"肥控制器"问题 |
| 5 | Maven | 工具链支持 |
| 6 | 代码落地 | 实践验证,刻意植入细节规范,训练工程洁癖 |
| 7 | 架构反思 | 暴露共享可变状态隐患,为W10策略模式+仓库层做铺垫 |
| 8 | 实践任务 | 现场编码验证 |
| 9 | 总结 | 强化认知,预告下周 |
---
## 版本说明
- **v1**:首次编写,CLI+MVC基础框架
- **v2**:按"问题→选择→架构→模式"逻辑重排
- **v3 (本版)**:融合v2结构 + V3深度改进,包含:
- 更深的CLI教育哲学
- 餐厅类比批判性思考
- ANSI常量集中管理工程细节
- 全部输出走View
- 共享List架构隐患反思
- 故意埋坑让学生找茬
- W10铺垫(策略模式+仓库层)

43
w11/java-cli/target/classes/logback.xml

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_HOME" value="./logs"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>${LOG_HOME}/crawler.log</file>
<append>true</append>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<appender name="ROLLING_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/crawler.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/crawler.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<logger name="com.example.datacollect" level="INFO"/>
<logger name="com.example.datacollect.strategy" level="DEBUG"/>
<logger name="com.example.datacollect.service" level="DEBUG"/>
<logger name="com.example.datacollect.controller" level="INFO"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ROLLING_FILE"/>
</root>
</configuration>

5
w11/java-cli/target/maven-archiver/pom.properties

@ -0,0 +1,5 @@
#Generated by Maven
#Thu Apr 30 11:50:54 CST 2026
artifactId=datacollect-cli
groupId=com.example
version=0.1.0

0
w11/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst

30
w11/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst

@ -0,0 +1,30 @@
D:\java-cli\src\main\java\com\example\datacollect\strategy\NewStrategy.java
D:\java-cli\src\main\java\com\example\datacollect\command\AnalyzeCommand.java
D:\java-cli\src\main\java\com\example\datacollect\command\ListCommand.java
D:\java-cli\src\main\java\com\example\datacollect\service\CrawlerService.java
D:\java-cli\src\main\java\com\example\datacollect\service\DataAnalysisService.java
D:\java-cli\src\main\java\com\example\datacollect\command\ExitCommand.java
D:\java-cli\src\main\java\com\example\datacollect\strategy\BlogStrategy.java
D:\java-cli\src\main\java\com\example\datacollect\view\ConsoleView.java
D:\java-cli\src\main\java\com\example\datacollect\strategy\StrategyFactory.java
D:\java-cli\src\main\java\com\example\datacollect\command\HelpCommand.java
D:\java-cli\src\main\java\com\example\datacollect\controller\CrawlerController.java
D:\java-cli\src\main\java\com\example\datacollect\command\Command.java
D:\java-cli\src\main\java\com\example\datacollect\exception\ParseException.java
D:\java-cli\src\main\java\com\example\datacollect\TestCrawler.java
D:\java-cli\src\main\java\com\example\datacollect\strategy\Strategy.java
D:\java-cli\src\main\java\com\example\datacollect\repository\ArticleRepository.java
D:\java-cli\src\main\java\com\example\datacollect\service\DataStorageService.java
D:\java-cli\src\main\java\com\example\datacollect\command\HistoryCommand.java
D:\java-cli\src\main\java\com\example\datacollect\command\CommandResult.java
D:\java-cli\src\main\java\com\example\datacollect\exception\NetworkException.java
D:\java-cli\src\main\java\com\example\datacollect\Main.java
D:\java-cli\src\main\java\com\example\datacollect\strategy\WeatherStrategy.java
D:\java-cli\src\main\java\com\example\datacollect\strategy\CrawlStrategy.java
D:\java-cli\src\main\java\com\example\datacollect\exception\CrawlerException.java
D:\java-cli\src\main\java\com\example\datacollect\strategy\GushiwenStrategy.java
D:\java-cli\src\main\java\com\example\datacollect\command\CrawlCommand.java
D:\java-cli\src\main\java\com\example\datacollect\command\SaveCommand.java
D:\java-cli\src\main\java\com\example\datacollect\strategy\DoubanMovieStrategy.java
D:\java-cli\src\main\java\com\example\datacollect\model\Article.java
D:\java-cli\src\main\java\com\example\datacollect\command\LoadCommand.java

530
w11/java-cli/target/w9-ppt.md

@ -0,0 +1,530 @@
## 高级程序设计 · 第9周
#### 工程架构:从"写代码"到"造系统"
##### CLI + MVC + Command模式实战
---
### 📌 本周导航
- 痛点引入:脚本的宿命
- CLI vs GUI:为什么选命令行?
- MVC分层:职责分离的艺术
- Command模式:可扩展的路由
- Maven模板:工程化第一步
- 代码落地:从接口到实现
- 架构反思:共享数据的隐患
- 实践任务 + 课后作业
---
### 1️⃣ 痛点引入:从脚本到工程的鸿沟
#### 这是一段“意大利面”爬虫
```java
public class Crawler {
public static void main(String[] args) {
System.out.print("请输入URL: ");
Scanner scanner = new Scanner(System.in);
String url = scanner.nextLine();
List titles = new ArrayList();
try {
Document doc = Jsoup.connect(url).get();
Elements elements = doc.select(".post-title");
for (Element e : elements) {
String title = e.text();
System.out.println("标题: " + title);
titles.add(title);
}
} catch (Exception ex) {
System.out.println("出错啦: " + ex.getMessage());
}
}
}
```
---
### 脚本的三大痛点
| 需求 | 需要改哪里? |
|------|--------------|
| 保存标题到文件 | 改 main 内部逻辑 |
| 支持不同网站结构 | 全部重写解析代码 |
| 彩色输出 | 一个一个改 print |
> 😫 **牵一发而动全身 → 改起来疼**
### 本周目标:**让代码“改起来不疼”**
---
## 2️⃣ CLI vs GUI:架构选择的思考
### 图形界面 vs 命令行
| 维度 | GUI (JavaFX) | CLI (命令行) |
|------|--------------|-------------|
| 学习重心 | 布局、控件、事件 | **架构、分层、路由** |
| 后端能力 | 弱 | 模拟真实服务器 |
| 工程思维 | 弱(关注视觉) | **强(关注逻辑)** |
| 可测试性 | 难 | 易 |
---
## 核心观点
> **CLI 更需要 MVC!**
- GUI 有现成事件系统,框架强塞给你一套架构
- CLI 只有字符流 → **没有架构,分分钟写成脚本**
> 🎯 **当外部约束消失,内部的工程纪律才真正开始建立**
### CLI 也能很酷
- ANSI 彩色输出
- 表格展示数据
- 模拟大数据/后端开发
---
## 3️⃣ MVC 分层设计
### MVC 的起源与演进
| 年代 | 场景 | MVC的角色 |
|------|------|----------|
| 1970s | Smalltalk-72 GUI | 最早的用户界面架构 |
| 1990s | Web开发 (Struts) | 后端模板引擎 |
| 2000s | ASP.NET MVC | 现代Web框架 |
| 2020s | CLI + API | 解耦业务逻辑与表现层 |
**核心不变:职责分离**
---
## MVC 三层职责
![[mvc.png]]
```
┌─────────────────────────────────────────┐
│ 入口 │
│ (main方法) │
└─────────────────┬───────────────────────┘
┌─────────────────────────────────────────┐
│ Controller │
│ 只管"派给谁",不管"怎么做" │
└─────────┬───────────────┬───────────────┘
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Model │ │ View │
│ 管"数据" │ │ 管"呈现" │
│ + 业务逻辑 │ │ + 输入输出 │
└─────────────────┘ └─────────────────┘
```
---
## 三层“禁止做什么”
| 层级 | 禁止行为 |
| -------------- | -------------------------------------- |
| **Model** | 不能有 `System.out.println`,不能有 `Scanner` |
| **View** | 不能写爬虫逻辑,只做“传声筒” |
| **Controller** | 不能直接写业务细节,委托给 Command |
> 🔴 **越权就是架构腐败的开始**
---
## 🍽️ 餐厅类比(帮助理解)
- **Model = 后厨**:只管做菜,不管谁来吃、怎么端
- **View = 服务员**:只管端菜和收钱,不管菜怎么做
- **Controller = 前台**:接单 → 派给后厨 → 叫服务员上菜
---
## 🤔 对类比的批判性思考(关键!)
> 任何类比都有边界,不要当成真理
| 场景 | 暴露的问题 |
|------|------------|
| 客人有忌口(不吃香菜) | 信息需要传到后厨 → Model 可能需要知道 meta 信息 |
| 服务员反馈“今天的菜咸了” | View → Model 反向影响 |
| 后厨做完菜通知前台 | **观察者模式**,数据流可能是双向的 |
**本课程简化模型**:请求-响应,单向流
---
## MVC 数据流向(本课程简化版)
```
CLI用户输入
View(解析命令字符串)
Controller(找到对应Command)
Command.execute()(执行业务逻辑)
Model(Article数据,暂存于List)
View(display()展示数据)
CLI终端显示
```
---
## 4️⃣ Command 模式:可扩展的命令路由
### 为什么需要 Command 模式?
```java
switch (cmd) {
case "crawl": handleCrawl(); break;
case "help": showHelp(); break;
// 如果要增加 list 命令?
// 1. 加 case "list"
// 2. 加 handleList() 方法
// 3. 可能还要改其他地方...
}
```
> 每加一个功能,就要在这个类里戳一个洞 → **肥控制器陷阱**
---
## Command 模式的四个要素
| 要素 | 角色 | 示例 |
|------|------|------|
| Command接口 | 抽象的“订单” | `Command` |
| ConcreteCommand | 具体的订单 | `HelpCommand` |
| Invoker | 接单的前台 | `CrawlerController` |
| Receiver | 执行者 | `ConsoleView`、`ArticleRepository` |
---
## Command 接口定义
```java
package com.crawler.command;
import com.crawler.model.Article;
import java.util.List;
public interface Command {
String getName();
void execute(String[] args, List<Article> articles);
}
```
---
## Controller 的变革:从 switch 到 Map
```java
public class CrawlerController {
private Map<String, Command> commands = new HashMap<>();
public CrawlerController(ConsoleView view, List<Article> articles) {
commands.put("help", new HelpCommand(view));
commands.put("list", new ListCommand(view));
commands.put("crawl", new CrawlCommand(view));
commands.put("exit", new ExitCommand(view));
}
public void handle(String input) {
// 解析命令 → 从 Map 取 Command → 调用 execute
}
}
```
> **增加新命令:只需新建类,Controller 零改动!**
---
## 对比:switch-case vs Command
| 维度 | switch-case | Command模式 |
|------|-------------|-------------|
| 增加命令 | 要改 Controller | 新建一个类 |
| 多态体验 | 无 | `execute()` 多态 |
| 可测试性 | 难 | 每个 Command 单独测试 |
| 代码量 | 少 | 多,但更清晰 |
> 🏨 **类比:酒店客房服务,前台只负责派单**
---
## 5️⃣ Maven 模板与环境(5分钟)
### 直接使用模板,不折腾配置
```
my-crawler-template.zip
↓ 解压 + IDEA打开
↓ 右键 pom.xml → Maven → Reload Project
↓ 运行 App.java
```
### 标准目录结构
```
src/main/java/com/crawler/
├── model/Article.java
├── view/ConsoleView.java
├── command/
│ ├── Command.java
│ ├── CrawlCommand.java
│ ├── HelpCommand.java
│ ├── ListCommand.java
│ └── ExitCommand.java
└── controller/CrawlerController.java
```
---
## 6️⃣ 代码落地(分步实现)
### Model:Article 实体
```java
public class Article {
private String title;
private String url;
private String content;
// 构造器、getter/setter、toString
}
```
> 📦 只存放数据,没有任何输入输出代码
---
## View:ConsoleView(ANSI常量集中管理)
```java
public class ConsoleView {
private static final String ANSI_GREEN = "\033[32m";
private static final String ANSI_RED = "\033[31m";
// ... 其他常量
public void printSuccess(String msg) {
System.out.println(ANSI_GREEN + msg + ANSI_RESET);
}
public void printError(String msg) { ... }
public void display(List<Article> articles) { ... }
}
```
> ✨ **所有颜色码集中定义 → 改主题只需改一处**
---
## Command 实现示例(HelpCommand)
```java
public class HelpCommand implements Command {
private ConsoleView view;
public HelpCommand(ConsoleView v) { this.view = v; }
public String getName() { return "help"; }
public void execute(String[] args, List<Article> articles) {
view.printInfo("Commands: crawl <url>, list, help, exit");
}
}
```
> ⚠️ 全部输出通过 `view`,绝不让 `System.out` 直接出现在这里
---
## CrawlCommand(存根,下周填坑)
```java
public class CrawlCommand implements Command {
private ConsoleView view;
public CrawlCommand(ConsoleView v) { this.view = v; }
public String getName() { return "crawl"; }
public void execute(String[] args, List<Article> articles) {
if (args.length < 2) {
view.printError("Usage: crawl <url>");
return;
}
view.printInfo("Stub: Would crawl " + args[1]);
}
}
```
> 🔍 **找茬点**:这里拼接字符串算是“业务逻辑”吗?留给大家用 AI 审计。
---
## ExitCommand
```java
public class ExitCommand implements Command {
private ConsoleView view;
public ExitCommand(ConsoleView v) { this.view = v; }
public String getName() { return "exit"; }
public void execute(String[] args, List<Article> articles) {
view.printSuccess("Bye!");
System.exit(0);
}
}
```
> ✅ 所有输出都通过 View → 将来改 GUI 只需换 View 实现
---
## Controller + main 组装
```java
// Controller 中持有 Map<String,Command>
// App.java 中:
ConsoleView view = new ConsoleView();
List<Article> articles = new ArrayList<>();
CrawlerController controller = new CrawlerController(view, articles);
view.printSuccess("Welcome to CLI Crawler!");
while (true) {
controller.handle(view.readLine());
}
```
> 🔁 完成交互循环
---
## 7️⃣ 架构反思:共享 List<Article> 的隐患
### 当前问题
- 所有 Command 都直接拿到 `List<Article>` 引用
- 任何一个命令都可以随意增、删、改列表
- 数据完全“裸奔”
> 🚨 就像酒店所有员工都能进保险箱
---
## 提问
- 如果 `CrawlCommand` 不小心把 `null` 塞进列表,`ListCommand` 会怎样?
- 如果我们要在添加文章时写日志,现在的设计能优雅实现吗?
### 预告解决方案(W10)
- **策略模式** + **仓库层(ArticleRepository)**
- 封装 `List`,对外只暴露 `add()`、`getAll()` 等安全接口
> W9 搭骨架,W10 装上盔甲
---
## 8️⃣ 实践任务(现场5分钟)
### 必做项
1. 使用 Maven 模板创建项目
2. 实现完整包结构(model/view/command/controller)
3. 实现 4 个 Command:help / list / crawl / exit
4. `list` 能展示已抓取的文章(目前存根即可)
5. 运行并测试循环
### 额外加分:代码找茬
- 检查是否仍有 `System.out` 直接调用
- 检查 ANSI 码是否硬编码在多个地方
---
## 验收标准
- [x] Maven 编译通过
- [x] Command 接口和 4 个实现在不同文件
- [x] Controller 里没有 switch-case
- [x] 新增命令只需新建类,不改 Controller
- [x] list 能正确显示空列表
- [x] 所有输出均通过 `ConsoleView`
- [x] ANSI 颜色码集中定义为常量
---
## 9️⃣ 课后作业
### 必做
1. **完善 Article**:增加 `author`、`publishDate` 字段
2. **★ HistoryCommand**:记录用户输入过的所有命令(用 `List<String>`
3. **AI 架构审计**:将类名发给 AI,指令:
> “作为Java架构审计师,请检查我的MVC三层划分是否存在越权行为?”
### 选做
- 命令别名(c 代替 crawl)
- URL 格式验证
- 暗色主题(修改一处常量)
- 思考题:分析 `List<Article>` 共享引用的风险(200字小结)
---
## 🤖 AI 协同升级
### 架构审计师任务(必做)
**步骤**:
1. 列出所有类名(不含方法实现)
2. 发给 AI
3. 指令:“检查 MVC 分层是否清晰,是否有越权行为”
### 进阶探究(选做)
> “假设我的 Command 接口中 execute 方法接收了一个 `List<Article>` 参数,请分析这种设计在工程上有什么隐患,并给出重构建议。”
---
## 📚 总结与过渡
### 本周成果
- ✅ 工程化包结构
- ✅ MVC 分层清晰
- ✅ Command 模式实现可扩展路由
- ✅ 所有输出走 View,常量集中管理
### 下周预告
- **策略模式**:封装爬取算法
- **仓库层(Repository)**:武装 `List<Article>`,解决共享隐患
> 🚀 从“写代码”到“造系统”,踏出坚实第一步!
---
## Q&A
### 常见问题
| 问题 | 解答 |
|------|------|
| IDEA 不识别 pom.xml | 右键 → Maven → Reload Project |
| 中文乱码 | Settings → File Encodings → UTF-8 |
| 输出颜色乱码 | Windows 建议使用 Windows Terminal |
| 我的 System.out 被批评 | View 才是唯一输出出口 |
---
## 谢谢!
### 课件已上传,模板在课程群
**保持工程洁癖,下周见!**

705
w11/java-cli/第10周——设计模式:灵活性与可扩展性.md

@ -0,0 +1,705 @@
# 教案:《高级程序设计》第10周——设计模式:灵活性与可扩展性
| 项目 | 内容 |
| -------- | ---------------------------------------------------------------------------- |
| **课程名称** | 高级程序设计 |
| **周次** | 第10周 |
| **主题** | 设计模式——灵活性与可扩展性 |
| **学时** | 2学时(90分钟) |
| **授课对象** | 已完成第9周CLI+MVC架构学习,具备Command模式基础 |
| **教学环境** | JDK 17+、IntelliJ IDEA、Maven |
| **前情提要** | W9搭建了CLI骨架:MVC分层 + Command路由,但留下了两大隐患——解析逻辑耦合在Command中、List\<Article\>共享引用裸奔 |
---
## 教学调整说明:为什么W10要在“骨架”上装“盔甲”?
> **W9成果**:一个可扩展的命令行骨架 → **W9痛点**:解析器与数据存储仍在“裸奔”
| 维度 | W9状态 | W10目标 |
|------|--------|---------|
| **架构** | MVC分层清晰 | MVC + 策略模式 + 仓库层 |
| **命令扩展** | 新增命令不改Controller | 新增解析器不改任何旧代码 |
| **数据安全** | List\<Article\>全员可写 | Repository封装,只暴露安全接口 |
| **解析逻辑** | 硬编码在CrawlCommand内 | 策略模式,按URL自动匹配 |
| **代码量** | ~8个类 | ~12个类,但每个更小更纯粹 |
**决策理由**:
1. W9学生已经感受到Command模式的好处——**多态带来的扩展性**
2. 策略模式是多态思想的又一次实战,是**接口抽象的深化**
3. 仓库层是“封装”这一OOP核心原则的落地,补上W9留下的课
4. 解析器工厂让学生看到**“自动匹配”**的威力——增加网站支持只需新增一个类
**更深层的教育价值**:
> W9教会学生“怎么把代码分开”,W10要教会学生“怎么把代码分开后还能优雅地合上”——**接口即合同,工厂即自动匹配,仓库即数据守卫**。这三句话,就是本周的全部精华。
---
## 一、教学目标
| 目标维度 | 具体描述 |
|----------|----------|
| **知识掌握** | 理解策略模式的定义与多态本质;掌握工厂模式的两类变体(工厂方法/简单工厂)及适用场景;理解仓库模式对数据访问的封装原理。 |
| **工程实践** | 能在爬虫项目中用策略模式封装不同网站的解析逻辑;能实现解析器工厂,根据URL自动匹配解析策略;能用Repository模式替代裸List,提供安全的数据访问接口。 |
| **思维转型** | 从“写死逻辑”转向“策略可插拔”;从“直接操作集合”转向“通过仓库存取”;理解“对扩展开放,对修改关闭”的开闭原则。 |
| **工具应用** | 利用AI审查策略模式实现是否真正解耦;让AI扮演“网站结构分析师”辅助编写具体解析策略;用AI生成Repository的安全接口建议。 |
---
## 二、教学重点与难点
| 项目 | 内容 | 突破方法 |
|------|------|----------|
| **重点** | 策略模式的多态本质、解析器工厂的自动匹配机制、Repository对数据访问的封装 | 以“新增网站需要改什么”为切入点,展示策略模式的开闭原则达成;通过“攻击”当前List裸奔的问题,引出Repository的必然性 |
| **难点** | 理解“接口即合同”的抽象思维、工厂模式中反射/Map注册的实现、仓库层与Strategy模式的协同 | 用“插座与电器”类比接口标准;现场演示从硬编码→工厂→反射的演进路径;用时序图展示“用户→Command→Strategy→Repository”的完整调用链 |
---
## 三、教学过程设计(90分钟)
| 环节 | 时间 | 教学内容 | 师生活动 | AI协同点 |
| -------------------------- | --- | ----------------------------------------------------------------- | -------------------------------------- | --------------------------- |
| **1. W9回顾与痛点暴露** | 8' | 回顾W9成果(CLI骨架),暴露两大隐患:①CrawlCommand里解析逻辑硬编码;②List\<Article\>全员可读可写 | **教师演示**:展示W9代码,用“事故场景”引发思考 | — |
| **2. 策略模式:解析器的“插头标准化”** | 18' | 策略模式定义、接口设计、多态调用、与Command模式的对比 | **类比**:插座与电器;**教师演示**:从if-else到策略模式的演进 | 让AI生成“策略模式vs switch-case”对比 |
| **3. 解析器工厂:自动匹配的魔法** | 14' | 工厂模式的两种形态(简单工厂→Map注册工厂),解析器工厂实现 | **教师演示**:先用if-else判断host,再升级为Map注册工厂 | 让AI解释工厂模式与策略模式如何协同 |
| **4. Repository模式:武装数据访问** | 12' | Repository定义、接口设计、替换List\<Article\>后的影响 | **教师演示**:在原代码中把List替换为Repository,展示改动点 | 学生用AI审计Repository接口的“最小完备性” |
| **5. 整体架构串联** | 8' | 用一张时序图串联:用户→CLI→Controller→Command→Strategy→Repository→Model | **师生互动**:让学生在白板上画出调用链 | — |
| **6. 代码落地** | 20' | 实现CrawlStrategy接口 + 两个策略 + 解析器工厂 + ArticleRepository | **教师演示**:分步写出代码,刻意埋入“策略匹配失败”的异常处理 | 完成后用AI检查策略模式实现 |
| **7. 架构反思与W11预告** | 5' | 当前架构还有什么隐患?(异常处理不统一、日志缺失)→ 预告W11健壮性工程 | **师生互动**:如果解析器工厂找不到匹配策略,会发生什么? | — |
| **8. 实践任务** | 5' | 实现策略模式和仓库层,完成本周代码升级 | 学生现场编码,教师巡视 | — |
---
## 四、核心教学内容脚本
### 4.1 W9回顾与痛点暴露(8分钟)
**教师口播**:
> "上节课我们搭了一个很漂亮的骨架——CLI+MVC+Command模式。我们先来表扬一下自己:新增一个命令,只要新建一个类,Controller零改动。但请大家想一个问题——"
**投影展示W9的CrawlCommand存根**:
```java
public class CrawlCommand implements Command {
// ...
public void execute(String[] args, List<Article> articles) {
if (args.length < 2) {
view.printError("Usage: crawl <url>");
return;
}
view.printInfo("Stub: Would crawl " + args[1]);
}
}
```
**提问引导**:
1. "这个存根下周要填坑了。假设我们现在要真正实现爬取,代码写在哪?"
2. "如果我要支持两个网站——比如一个技术博客和一个新闻网站——它们的HTML结构完全不一样,这个`execute`方法会变成什么样?"
**展示“噩梦版”CrawlCommand**:
```java
public void execute(String[] args, List<Article> articles) {
String url = args[1];
// 五十行if-else地狱...
if (url.contains("blog.example.com")) {
// 解析技术博客的HTML
Document doc = Jsoup.connect(url).get();
Elements titles = doc.select(".post-title");
for (Element e : titles) {
articles.add(new Article(e.text(), url, ""));
}
} else if (url.contains("news.example.com")) {
// 解析新闻网站的HTML
Document doc = Jsoup.connect(url).get();
Elements items = doc.select(".article-headline");
for (Element e : items) {
articles.add(new Article(e.text(), url, ""));
}
} else {
view.printError("Unsupported website!");
}
}
```
**痛点提炼**:
> "看到了吗?每支持一个新网站,就要在这里加一个`else if`。这就是W1我们痛批的'牵一发而动全身',只不过这次灾难地点从`main`搬到了`CrawlCommand`。"
>
> "更重要的是,我们上节课辛辛苦苦实现了Command模式,难道解析逻辑又要回到if-else地狱吗?**这就是W10要解决的第一个问题:怎么让解析逻辑也可插拔?**"
**第二个隐患——共享状态的回顾**:
> "还有一件事,我们上节课结束前提到的:`List<Article> articles`在所有Command之间共享。任何一个Command都可以往里面塞东西、删东西、甚至清空。这是W10要解决的第二个问题:**怎么给数据装上'防盗门'?**"
---
### 4.2 策略模式:解析器的“插头标准化”(18分钟)
#### 4.2.1 从类比切入
**教师口播**:
> "先讲个生活场景。你家里墙上有一个三孔插座,你可以插电视、插电脑、插手机充电器——任何符合这个标准的电器都能用。插座不在乎你是什么电器,它只认接口标准。"
**类比映射**:
| 生活场景 | 代码对应 |
|----------|----------|
| 三孔插座 | `CrawlStrategy` 接口 |
| 电视/电脑充电器 | 具体解析策略(BlogStrategy/NewsStrategy) |
| 电流 | 输入:URL + Document;输出:List\<Article\> |
| 你(使用者) | CrawlCommand |
| 插座面板 | 解析器工厂 |
> "策略模式的核心思想就是:**定义一个算法接口,让具体的算法实现可以互相替换,而使用算法的客户端不受影响。**"
#### 4.2.2 策略模式定义
```java
// src/main/java/com/crawler/strategy/CrawlStrategy.java
package com.crawler.strategy;
import com.crawler.model.Article;
import org.jsoup.nodes.Document;
import java.util.List;
public interface CrawlStrategy {
/**
* 从已获取的Document中解析文章列表
* @param url 原始请求URL(用于填充Article)
* @param doc Jsoup解析后的Document
* @return 解析出的文章列表
*/
List<Article> parse(String url, Document doc);
/**
* 判断此策略是否为给定URL服务
* @param url 待判断的URL
* @return true表示此策略可以处理该URL
*/
boolean supports(String url);
}
```
**教师口播**:
> "注意,策略接口里有两个方法。`parse`是干活的那个,`supports`是'我能不能干这个活'——这是什么?**这是合同!** 任何网站想被我们爬虫支持,就必须签署这份合同:告诉我你是不是我的客户(supports),以及怎么解析你(parse)。"
#### 4.2.3 具体策略实现示例
```java
// BlogStrategy.java - 技术博客解析策略
public class BlogStrategy implements CrawlStrategy {
@Override
public boolean supports(String url) {
return url.contains("blog.example.com");
}
@Override
public List<Article> parse(String url, Document doc) {
List<Article> articles = new ArrayList<>();
Elements titles = doc.select(".post-title");
for (Element e : titles) {
articles.add(new Article(e.text(), url, ""));
}
return articles;
}
}
// NewsStrategy.java - 新闻网站解析策略
public class NewsStrategy implements CrawlStrategy {
@Override
public boolean supports(String url) {
return url.contains("news.example.com");
}
@Override
public List<Article> parse(String url, Document doc) {
List<Article> articles = new ArrayList<>();
Elements items = doc.select(".article-headline");
for (Element e : items) {
articles.add(new Article(e.text(), url, ""));
}
return articles;
}
}
```
**对比:策略模式 vs 硬编码if-else**
| 维度 | if-else屎山 | 策略模式 |
|------|-------------|----------|
| 新增网站 | 改CrawlCommand,加else if | 新写一个类,实现CrawlStrategy |
| 修改解析逻辑 | 在CrawlCommand里翻找对应的else if | 只改对应策略类 |
| 测试 | 必须启动整个爬虫 | 单独对Strategy做单元测试 |
| 是否符合开闭原则 | ❌ 对修改开放 | ✅ 对扩展开放,对修改关闭 |
**与Command模式的对比(加深理解)**:
> "上节课Command模式,我们为每个命令定义一个类;这节课策略模式,我们为每个网站的解析算法定义一个类。**本质上都是同一个OOP思想:用多态替代条件分支。** 只不过Command的接口是`execute()`,Strategy的接口是`parse()`。"
>
> "这张图你们可以记下来:**接口是消除if-else的利器,多态是接口的灵魂。**"
---
### 4.3 解析器工厂:自动匹配的魔法(14分钟)
#### 4.3.1 问题引出
**教师口播**:
> "现在我们有A网站的策略、B网站的策略。问题来了:谁来选策略?谁来遍历所有策略,找到一个supports返回true的?"
>
> "如果把这个逻辑写在CrawlCommand里,那策略模式就白用了——CrawlCommand还是得'知道'有哪些策略。我们要的是一个黑盒子:**把URL丢进去,自动弹出一个合适的解析器。**"
#### 4.3.2 解析器工厂的实现
```java
// src/main/java/com/crawler/strategy/StrategyFactory.java
package com.crawler.strategy;
import java.util.ArrayList;
import java.util.List;
public class StrategyFactory {
private final List<CrawlStrategy> strategies = new ArrayList<>();
// 注册策略——新的网站只需在这里加一行
public StrategyFactory() {
strategies.add(new BlogStrategy());
strategies.add(new NewsStrategy());
// 未来增加新网站:strategies.add(new XxxStrategy());
}
/**
* 根据URL自动匹配解析策略
* @param url 目标URL
* @return 匹配的策略,如果没有匹配返回null
*/
public CrawlStrategy getStrategy(String url) {
for (CrawlStrategy s : strategies) {
if (s.supports(url)) {
return s;
}
}
return null; // 未找到匹配策略
}
}
```
**教师口播**:
> "这个工厂类足够简单:一个List存所有策略,一个方法遍历找到匹配的。但简单不等于不强大。**
>
> **关键点**:新增网站支持,只需要——"
1. 写一个`XxxStrategy`实现`CrawlStrategy`
2. 在工厂构造器里加一行`strategies.add(new XxxStrategy())`
>
> "CrawlCommand一行不改。这就是开闭原则的胜利。"
#### 4.3.3 从简单工厂到更高级的注册机制(拓展思维)
**教师口播**:
> "有同学可能会问:还要在工厂构造器里加一行,能不能做到完全零改动?当然可以——用反射或者SPI。"
**演示概念(不要求实现)**:
```java
// 进阶思路:扫描指定包下的所有CrawlStrategy实现类
// 用反射自动注册,真正做到“新增类即生效”
// 这是Spring框架的核心思想之一
```
> "这个技术我们暂时不要求掌握,但我希望你们知道:你现在写的每一个`new XxxStrategy()`,在未来都可能进化为框架级别的自动装配。**你现在建立的思维习惯,决定了你未来能走多高。**"
#### 4.3.4 重构后的CrawlCommand
```java
public class CrawlCommand implements Command {
private ConsoleView view;
private StrategyFactory strategyFactory;
private ArticleRepository repository; // 注意:这里是Repository了!
public CrawlCommand(ConsoleView v, StrategyFactory f, ArticleRepository r) {
this.view = v;
this.strategyFactory = f;
this.repository = r;
}
public String getName() { return "crawl"; }
public void execute(String[] args, List<Article> articles) {
if (args.length < 2) {
view.printError("Usage: crawl <url>");
return;
}
String url = args[1];
// 1. 工厂自动选策略
CrawlStrategy strategy = strategyFactory.getStrategy(url);
if (strategy == null) {
view.printError("No strategy found for: " + url);
return;
}
// 2. 抓取页面
view.printInfo("Crawling: " + url);
try {
Document doc = Jsoup.connect(url).get();
List<Article> parsed = strategy.parse(url, doc);
// 3. 通过仓库存入(而不是直接操作List)
for (Article a : parsed) {
repository.add(a);
}
view.printSuccess("Crawled " + parsed.size() + " articles.");
} catch (IOException e) {
view.printError("Failed to crawl: " + e.getMessage());
}
}
}
```
**教师口播**:
> "注意这个CrawlCommand现在的职责:拿到URL → 交给工厂选策略 → 执行解析 → 交给仓库存储。**它自己在干什么?在调度!** 这就是上节课我们讲的Controller的'调度思维',现在向Command内部延伸了。"
---
### 4.4 Repository模式:武装数据访问(12分钟)
#### 4.4.1 问题重提
**教师口播**:
> "回到上节课结束时的那个问题:`List<Article>`在所有Command之间共享。任何一个Command都可以做这些事——"
```java
articles.clear(); // 清空所有文章
articles.add(null); // 塞入null
articles.remove(0); // 随意删除
```
> "如果一个新同事接手开发,他不知道'不要动这个List'的潜规则,写了一个`articles.clear()`,你的`list`命令就突然什么都不显示了。**靠代码约定维护的秩序,早晚会被打破。我们需要实体的'规则'——代码层面的约束。**"
#### 4.4.2 ArticleRepository的定义
```java
// src/main/java/com/crawler/repository/ArticleRepository.java
package com.crawler.repository;
import com.crawler.model.Article;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class ArticleRepository {
private final List<Article> articles = new ArrayList<>();
/**
* 添加一篇文章。注意:不接受null,这是代码层面的规则,不是口头约定。
*/
public void add(Article article) {
if (article == null) {
throw new IllegalArgumentException("Article cannot be null");
}
articles.add(article);
}
/**
* 获取所有文章的只读视图
* 调用者无法通过此返回值修改内部数据
*/
public List<Article> getAll() {
return Collections.unmodifiableList(articles);
}
/**
* 获取文章数量
*/
public int size() {
return articles.size();
}
/**
* 清空(仅管理员可调——下一篇:权限控制)
*/
public void clear() {
articles.clear();
}
}
```
**教师口播**:
> "三个关键设计点——"
>
> - **add()拒绝null**:规则写在代码里,不是写在邮件里
> - **getAll()返回不可修改的视图**:`Collections.unmodifiableList()`——调用者如果尝试add/remove,会**直接抛异常**,不是'悄悄的bug'
> - **ClearCommand要清空数据?调`repository.clear()`**,而不是直接操作List
>
> "这就是面向对象的第一课——封装。把数据藏起来,只暴露安全的方法。从'直接操作集合'到'通过仓库存取',是程序员成熟度的分水岭。"
#### 4.4.3 仓库引入后的架构变化
**Command接口的execute方法调整**:
```java
// 调整前(W9)
public interface Command {
String getName();
void execute(String[] args, List<Article> articles);
}
// 调整后(W10)
public interface Command {
String getName();
void execute(String[] args, ArticleRepository repository);
}
```
**教师口播**:
> "这个改动很小——把`List<Article>`换成`ArticleRepository`。但语义完全不同:之前是'给你数据随便玩',现在是'给你一个安全的存取通道'。"
**所有Command同步调整**:
```java
// ListCommand.java - 调整后
public class ListCommand implements Command {
private ConsoleView view;
public ListCommand(ConsoleView v) { this.view = v; }
public String getName() { return "list"; }
public void execute(String[] args, ArticleRepository repository) {
view.display(repository.getAll()); // 通过仓库获取数据
}
}
// ClearCommand.java(新增示例)
public class ClearCommand implements Command {
private ConsoleView view;
public ClearCommand(ConsoleView v) { this.view = v; }
public String getName() { return "clear"; }
public void execute(String[] args, ArticleRepository repository) {
repository.clear();
view.printSuccess("All articles cleared.");
}
}
```
**Controller和main的调整**:
```java
// App.java - 调整后
public class App {
public static void main(String[] args) {
ConsoleView view = new ConsoleView();
ArticleRepository repository = new ArticleRepository(); // 替代 List<Article>
StrategyFactory factory = new StrategyFactory(); // 新增
CrawlerController controller = new CrawlerController(view, repository, factory);
view.printSuccess("Welcome to CLI Crawler v2.0!");
view.printInfo("Type 'help' for commands.");
while (true) {
controller.handle(view.readLine());
}
}
}
```
---
### 4.5 整体架构串联(8分钟)
**教师口播**:
> "现在我们把所有部件串起来,看看一个`crawl https://blog.example.com`命令走过的完整路径。"
**时序图(口述配白板绘制)**:
```
用户输入 "crawl https://blog.example.com"
ConsoleView.readLine()
CrawlerController.handle("crawl https://blog.example.com")
│ Map查找 "crawl" → CrawlCommand
CrawlCommand.execute(args, repository)
├─► StrategyFactory.getStrategy(url)
│ │ 遍历List<CrawlStrategy>
│ │ BlogStrategy.supports(url) → true!
│ ▼
│ 返回 BlogStrategy
├─► Jsoup.connect(url).get() → Document
├─► BlogStrategy.parse(url, doc) → List<Article>
└─► for each article: repository.add(article)
ArticleRepository.articles.add(article)
最终:ConsoleView.printSuccess("Crawled N articles.")
```
**教师口播**:
> "七步调用,每一步职责清晰:View负责输入输出,Controller负责路由,Command负责调度,Factory负责匹配,Strategy负责解析,Repository负责存储。**没有哪个类干了两个人的活,也没有哪个类不知道自己的活是什么。**"
>
> "这就是工程化——不是把代码写得快,是把代码写得对。"
---
### 4.6 代码落地(20分钟)
**教师准备**:课前准备一份“W9升级到W10”的改动清单,现场演示关键改动。
**改动清单**:
1. 新建`strategy/`包,创建`CrawlStrategy`接口
2. 新建`strategy/BlogStrategy.java`
3. 新建`strategy/NewsStrategy.java`
4. 新建`strategy/StrategyFactory.java`
5. 新建`repository/`包,创建`ArticleRepository.java`
6. 修改`Command`接口的`execute`签名
7. 修改`CrawlCommand`,引入`StrategyFactory`和`ArticleRepository`
8. 修改其余所有`Command`实现类
9. 修改`CrawlerController`构造器
10. 修改`App.java`
**教师演示关键步骤**(重点演示):
- `ArticleRepository`的`Collections.unmodifiableList()`
- `StrategyFactory`的遍历匹配逻辑
- `CrawlCommand`重写后的调度结构
**刻意埋入的“找茬点”**:
> "我在`StrategyFactory.getStrategy()`里,如果没有匹配的策略就返回`null`。然后在`CrawlCommand`里检查null。这其实叫'null object pattern的前奏'——如果我不想让Command检查null,我应该怎么改工厂?大家带着这个问题用AI探究。"
---
### 4.7 架构反思与W11预告(5分钟)
**教师口播**:
> "现在我们的架构比W9强壮多了:解析逻辑可插拔,数据访问有守卫。但还有一些漏洞——"
**逐一点破**:
1. **异常处理**:`CrawlCommand`用了一个笼统的`catch (IOException e)`,如果解析过程中抛出其他异常怎么办?
2. **网络超时**:如果目标网站3秒没响应,当前代码会一直等吗?
3. **日志缺失**:所有的成功/失败信息只输出到终端,如果程序半夜跑,第二天想看昨晚抓了多少——看不了。
4. **重试机制**:如果一次失败就直接报错,要不要给个重试的机会?
**W11预告**:
> "下周,我们会做三件事:**自定义异常体系**、**工程化日志框架**、**防御式编程与重试机制**。W9搭骨架,W10装盔甲,W11要让这个系统**经得起现实的毒打**。"
---
### 4.8 实践任务(5分钟)
**任务要求**:
1. 从W9代码出发,完成W10升级
2. 实现至少两个`CrawlStrategy`(可以是模拟的,不要求真实爬取)
3. 实现`StrategyFactory`和`ArticleRepository`
4. 确保所有Command通过Repository访问数据
5. 运行并测试完整流程
**验收标准**:
- [x] 新增策略类只需新建文件+工厂注册一行,其余代码零改动
- [x] `ArticleRepository`的`getAll()`返回不可修改视图
- [x] `CrawlCommand`不包含任何网站特定的解析逻辑
- [x] `StrategyFactory`能根据URL自动匹配正确的策略
- [x] 所有Command的`execute`方法签名已更新为`ArticleRepository`
- [x] 无任何地方直接操作`List<Article>`
---
## 五、课后作业
### 5.1 必做任务
1. **完善ArticleRepository**:增加`addAll(List<Article>)`批量添加方法,注意防御null
2. **★ AnalyzeCommand(集大成作业)**:
- 实现`analyze <url>`命令
- 内部调用`StrategyFactory`匹配策略
- 调用策略解析文章后,**不存到Repository**,而是分析统计信息:
- 文章总数
- 标题平均长度
- 按某种规则排名的Top 5
- 结果只输出,不存储
- **提示**:这就是策略的复用——同一个解析策略,既能为`crawl`服务(存入仓库),也能为`analyze`服务(仅分析)
3. **AI架构审计**:将完整代码的类图(或类名与方法签名列表)发给AI,指令:
> "作为Java架构审计师,请检查:①策略模式的实现是否正确解耦(CrawlCommand是否仍然包含网站特定逻辑);②Repository是否真正封装了数据访问(是否存在绕过Repository直接操作List的地方);③工厂的匹配逻辑是否存在性能隐患。请给出具体的改进建议。"
### 5.2 选做任务
1. **正则策略匹配**:将`Supports()`的判断从`url.contains()`改为正则表达式,让一张策略可以匹配一类URL
2. **默认策略(DefaultStrategy)**:当没有策略匹配时,提供一个通用的“标题提取”逻辑
3. **策略优先级**:给每个策略加一个`priority`字段,工厂按优先级匹配(而不是按注册顺序)
4. **思考并回答(200字)**
> "策略模式中,策略的`supports()`方法有可能让两个策略都返回true,这时该选哪个?`StrategyFactory`的遍历顺序会如何影响结果?你有什么解决方案?"
### 5.3 思考题
1. **Repository与List的区别是什么?** 如果Repository只是包了一层List,为什么还要用?
2. **策略工厂的演进**:如果网站数量增加到100个,逐个注册的写法还合适吗?你想到什么解决方案?
3. **`Collections.unmodifiableList()`返回的是什么?** 它真的“不可修改”吗?如果原List被修改,这个不可修改视图会怎样?
---
## 六、AI协同升级
### 架构审计师任务(必做)
**学生执行步骤**:
1. 画出当前项目的类依赖图(手绘或工具生成)
2. 将类名和依赖关系发给AI
3. 输入指令:
> "作为Java架构审计师,请检查这个爬虫项目的架构。重点关注:①策略模式是否真正实现了开闭原则(增加新网站是否真的只需新增类);②Repository封装是否完整(是否有绕过Repository的路径);③是否存在循环依赖。请逐一指出问题并给出改进建议。"
**预期AI输出**:
- 指出是否还存在“改一处影响多处”的耦合
- 判断Repository的API设计是否完备
- 评价整体架构的开闭原则达成度
### 进阶AI探究(选做)
> "假设我有一个CrawlStrategy接口和10个实现类。不用工厂模式,直接用一个Map<String, CrawlStrategy>存起来,key是策略名称。这和StrategyFactory设计有什么本质区别?各自的优缺点是什么?"
---
## 七、教学反思与调整记录
| 日期 | 事项 | 调整内容 |
|------|------|----------|
| 2026-05-01 | 首次编写 | 基于W9骨架,引入策略模式+工厂+Repository |
| 2026-05-07 | 结构优化 | 调整策略模式与工厂的讲解顺序,先策略后工厂更自然 |
---
## 附录1:W9到W10改动对照表
| 改动项 | W9代码 | W10代码 |
|--------|--------|---------|
| 数据存储 | `List<Article> articles` | `ArticleRepository repository` |
| Command接口 | `execute(String[], List<Article>)` | `execute(String[], ArticleRepository)` |
| 解析逻辑位置 | `CrawlCommand`内部 | 各`CrawlStrategy`实现类 |
| URL匹配 | 无(硬编码) | `StrategyFactory.getStrategy(url)` |
| 数据添加 | `articles.add(article)` | `repository.add(article)` |
| 数据读取 | 直接遍历`articles` | `repository.getAll()` |
## 附录2:常见问题速查
| 问题 | 解答 |
|------|------|
| 策略模式和Command模式有什么区别? | Command封装“动作”(做什么事),Strategy封装“算法”(怎么做)。在爬虫中:crawl是命令(动作),如何解析是策略(算法)。 |
| 工厂一定要叫Factory吗? | 不必须。但叫Factory意味着“创建对象”的职责,符合模式命名的惯例。 |
| `Collections.unmodifiableList()`有什么用? | 返回一个只读视图,调用add/remove等方法会抛`UnsupportedOperationException`。 |
| Repository和DAO有什么区别? | 在我们的上下文中可以视为同义词。严谨地说,Repository是领域驱动设计的概念,更偏向“集合语义”;DAO更偏数据库操作。 |
| 策略的`supports()`返回true但解析失败怎么办? | 那是策略实现的bug,该策略应修复。Factory不负责验证策略的正确性。 |
## 附录3:教学逻辑说明
| 顺序 | 内容 | 设计理由 |
|------|------|----------|
| 1 | W9回顾+痛点暴露 | 承上启下,从已知问题引出新知识 |
| 2 | 策略模式 | 解决解析逻辑耦合问题,深化多态理解 |
| 3 | 解析器工厂 | 解决策略选择问题,引入工厂模式 |
| 4 | Repository模式 | 解决数据安全问题,实践封装原则 |
| 5 | 架构串联 | 将所有部件统一,形成完整心智模型 |
| 6 | 代码落地 | 实践验证,从“听懂”到“会做” |
| 7 | 架构反思+预告 | 暴露新问题,为W11健壮性工程铺垫 |
---
## 版本说明
- **v1(本版)**:基于W9教案模式首次编写,包含策略模式、工厂模式、Repository模式的完整引入

7
w11/爬虫运行结果/data/古诗文数据.json

File diff suppressed because one or more lines are too long

81
w11/爬虫运行结果/data/古诗文数据.txt

@ -0,0 +1,81 @@
========================================
爬虫数据采集结果
========================================
生成时间: 2026年05月19日 10:44:25
========================================
【标题】
Gushiwen Collection
【作者】
Gushiwen
【发布日期】
2026-05-19
【来源链接】
https://www.gushiwen.cn/
【内容】
────────────────
No poems found. Trying alternative selector...
薄宦频移疾,当年久索居。哀同庾开府,瘦极沈尚书。城绿新阴远,江清返照虚。所思惟翰墨,从古待双鱼。——唐代·李商隐《有怀在蒙飞卿》https://www.guwendao.net/shiwenv_29f8b0666c1e.aspx
有怀在蒙飞卿 李商隐〔唐代〕 薄宦频移疾,当年久索居。 哀同庾开府,瘦极沈尚书。 城绿新阴远,江清返照虚。 所思惟翰墨,从古待双鱼。 完善 感怀 抒怀
凉风吹夜雨,萧瑟动寒林。正有高堂宴,能忘迟暮心。军中宜剑舞,塞上重笳音。不作边城将,谁知恩遇深!——唐代·张说《幽州夜饮》https://www.guwendao.net/shiwenv_072b1a3b49d9.aspx
幽州夜饮 张说〔唐代〕 凉风吹夜雨,萧瑟动寒林。 正有高堂宴,能忘迟暮心。 军中宜剑舞,塞上重笳音。 不作边城将,谁知恩遇深! 完善 边塞 写景 幽州
常识 xī西 bīn宾 1.旧时因宾位设于西侧,故称西宾,常作为对家塾教师或幕友的尊称。
不以智累心,不以私累己;寄治乱于法术,托是非于赏罚https://www.guwendao.net/mingju/juv_8abd61ea655f.aspx
唐寅 款鹤图局部 不以智累心,不以私累己;寄治乱于法术,托是非于赏罚 《韩非子·大体》 完善
伛偻溪头白发翁,暮年心事一枝筇。山衔落日青横野,鸦起平沙黑蔽空。天下可忧非一事,书生无地效孤忠。东山七月犹关念,未忍沉浮酒醆中。——宋代·陆游《溪上作二首·其二》https://www.guwendao.net/shiwenv_e05c7ec018c1.aspx
溪上作二首·其二 陆游〔宋代〕 伛偻溪头白发翁,暮年心事一枝筇。 山衔落日青横野,鸦起平沙黑蔽空。 天下可忧非一事,书生无地效孤忠。 东山七月犹关念,未忍沉浮酒醆中。 完善 抒情 忧国忧民 暮年 年老
新裂齐纨素,皎洁如霜雪。(皎洁 一作:鲜洁)裁作合欢扇,团团似明月。出入君怀袖,动摇微风发。常恐秋节至,凉飙夺炎热。(凉飙 一作:凉风)弃捐箧笥中,恩情中道绝。——两汉·班婕妤《怨歌行》https://www.guwendao.net/shiwenv_31c5fb3823cc.aspx
怨歌行 班婕妤〔两汉〕 新裂齐纨素,皎洁如霜雪。(皎洁 一作:鲜洁) 裁作合欢扇,团团似明月。 出入君怀袖,动摇微风发。 常恐秋节至,凉飙夺炎热。(凉飙 一作:凉风) 弃捐箧笥中,恩情中道绝。 完善 咏物 写人 宫怨 怨情 宫人 怨妇 怨恨 幽怨 懊悔 隐喻 寓事 宫中
曲水流觞,赏心乐事良辰。兰蕙光风,转头天气还新。明眸皓齿,看江头、有女如云。折花归去,绮罗陌上芳尘。能几多春。试听啼鸟殷勤。览物兴怀,向来哀乐纷纷。且题醉墨,似兰亭、列序时人。后之览者,又将有感斯文。——宋代·辛弃疾《新荷叶·上巳日吴子似谓古今无此词索赋》https://www.guwendao.net/shiwenv_6cdd4462db1a.aspx
新荷叶·上巳日吴子似谓古今无此词索赋 辛弃疾〔宋代〕 曲水流觞,赏心乐事良辰。兰蕙光风,转头天气还新。明眸皓齿,看江头、有女如云。折花归去,绮罗陌上芳尘。 能几多春。试听啼鸟殷勤。览物兴怀,向来哀乐纷纷。且题醉墨,似兰亭、列序时人。后之览者,又将有感斯文。 完善 宴席 宴会
名高前后事,回首一伤神。 —— 杜甫《发潭州》 重重似画,曲曲如屏。 —— 苏轼《行香子·过七里濑》 能变人间世,翛然是玉京。 —— 刘禹锡《八月十五日夜玩月》 新啼痕压旧啼痕,断肠人忆断肠人。 —— 王实甫《十二月过尧民歌·别情》 兵无常势,水无常形,能因敌变化而取胜者,谓之神。 —— 《孙子兵法·虚实篇》
人未己知,不可急求其知;人未己合,不可急与之合。https://www.guwendao.net/mingju/juv_31a713018e6d.aspx
曹兴 山水图局部 人未己知,不可急求其知;人未己合,不可急与之合。 《格言联璧·接物类》 完善
长忆西湖。尽日凭阑楼上望:三三两两钓鱼舟,岛屿正清秋。笛声依约芦花里,白鸟成行忽惊起。别来闲整钓鱼竿,思入水云寒。——宋代·潘阆《忆馀杭·长忆西湖》https://www.guwendao.net/shiwenv_0be6fa5203c6.aspx
忆馀杭·长忆西湖 潘阆〔宋代〕 长忆西湖。尽日凭阑楼上望:三三两两钓鱼舟,岛屿正清秋。 笛声依约芦花里,白鸟成行忽惊起。别来闲整钓鱼竿,思入水云寒。 完善 回忆 西湖 美景 忆昔 往事 水乡
常识 yōu耰 chú锄 1.犹锄耰。参见“耡耰 ”。
南登碣石馆,遥望黄金台。丘陵尽乔木,昭王安在哉?霸图今已矣,驱马复归来。——唐代·陈子昂《燕昭王》https://www.guwendao.net/shiwenv_82a2d6725e98.aspx
燕昭王 陈子昂〔唐代〕 南登碣石馆,遥望黄金台。 丘陵尽乔木,昭王安在哉? 霸图今已矣,驱马复归来。 完善 怀古
湖上雨晴时,秋水半篙初没。朱槛俯窥寒鉴,照衰颜华发。醉中吹坠白纶巾,溪风漾流月。独棹小舟归去,任烟波飘兀。——宋代·苏轼《好事近·湖上》https://www.guwendao.net/shiwenv_419ef8d31195.aspx
好事近·湖上 苏轼〔宋代〕 湖上雨晴时,秋水半篙初没。朱槛俯窥寒鉴,照衰颜华发。 醉中吹坠白纶巾,溪风漾流月。独棹小舟归去,任烟波飘兀。 完善 夜晚 写景 抒情 愁闷 湖水 夜景 湖泊 湖光
缀文者情动而辞发,观文者披文以入情https://www.guwendao.net/mingju/juv_2e8f13f64677.aspx
颜辉(传) 松荫论道图局部 缀文者情动而辞发,观文者披文以入情 《文心雕龙·知音》 完善
玉鉴尘生,凤奁香殄。懒蝉鬓之巧梳,闲缕衣之轻缘。苦寂寞于蕙宫,但凝思乎兰殿。信摽落之梅花,隔长门而不见。况乃花心飏恨,柳眼弄愁。暖风习习,春鸟啾啾。楼上黄昏兮,听风吹而回首;碧云日暮兮,对素月而凝眸。温泉不到,忆拾翠之旧游;长门深闭,嗟青鸾之信修。  忆昔太液清波,水光荡浮,笙歌赏宴,陪从宸旒。奏舞鸾之妙曲,乘画鷁之仙舟。君情缱绻,深叙绸缪。誓山海而常在,似日月而无休。  奈何嫉色庸庸,妒气冲冲。夺我之爱幸,斥我乎幽宫。思旧欢之莫得,梦相著乎朦胧。度花朝与月夕,羞懒对乎春风。欲相如之奏赋,奈世才之不工。属愁吟之未尽,已响动乎疏钟。空长叹而掩袂,踌躇步于楼东。(梦相 一作:想梦)——唐代·江采萍《楼东赋》https://www.guwendao.net/shiwenv_6edbcc1a7ec2.aspx
楼东赋 江采萍〔唐代〕   玉鉴尘生,凤奁香殄。懒蝉鬓之巧梳,闲缕衣之轻缘。苦寂寞于蕙宫,但凝思乎兰殿。信摽落之梅花,隔长门而不见。况乃花心飏恨,柳眼弄愁。暖风习习,春鸟啾啾。楼上黄昏兮,听风吹而回首;碧云日暮兮,对素月而凝眸。温泉不到,忆拾翠之旧游;长门深闭,嗟青鸾之信修。   忆昔太液清波,水光荡浮,笙歌赏宴,陪从宸旒。奏舞鸾之妙曲,乘画鷁之仙舟。君情缱绻,深叙绸缪。誓山海而常在,似日月而无休。   奈何嫉色庸庸,妒气冲冲。夺我之爱幸,斥我乎幽宫。思旧欢之莫得,梦相著乎朦胧。度花朝与月夕,羞懒对乎春风。欲相如之奏赋,奈世才之不工。属愁吟之未尽,已响动乎疏钟。空长叹而掩袂,踌躇步于楼东。(梦相 一作:想梦) 完善 女子 闺情
楚山碧岩岩,汉水碧汤汤。秀气结成象,孟氏之文章。今我讽遗文,思人至其乡。清风无人继,日暮空襄阳。南望鹿门山,蔼若有余芳。旧隐不知处,云深树苍苍。——唐代·白居易《游襄阳怀孟浩然》https://www.guwendao.net/shiwenv_9efa3d1d8d2a.aspx
游襄阳怀孟浩然 白居易〔唐代〕 楚山碧岩岩,汉水碧汤汤。 秀气结成象,孟氏之文章。 今我讽遗文,思人至其乡。 清风无人继,日暮空襄阳。 南望鹿门山,蔼若有余芳。 旧隐不知处,云深树苍苍。 完善 山水 怀人
滴不尽相思血泪抛红豆,开不完春柳春花满画楼,睡不稳纱窗风雨黄昏后,忘不了新愁与旧愁,咽不下玉粒金莼噎满喉,照不见菱花镜里形容瘦。 —— 《红楼梦·第二十八回》 何当一夕金风起,为我扫除天下热。 —— 《水浒传·第十六回》 枫香晚花静,锦水南山影。 —— 李贺《蜀国弦》 有则改之,无则加勉 —— 《传习录·卷下·右南大吉录》 韩子曰:“儒以文乱法,而侠以武犯禁。” —— 司马迁《游侠列传序》

7
w11/爬虫运行结果/data/豆瓣电影评分.json

@ -0,0 +1,7 @@
{
"title" : "Douban Movies",
"url" : "https://movie.douban.com/chart",
"content" : "1. 世界的主人 / 若问世界谁无伤(港) / 世界之主 ((102341人评价)) - Rating: 9.1\n2. 爱情抓马 / 抓马恋人(台) / 戏剧性婚礼(港) ((27347人评价)) - Rating: 7.0\n3. 杀的就是你 / 杀死你(港) / 他们要杀你(台) ((18784人评价)) - Rating: 6.9\n4. 蜂蜜的针 / 没有别的爱 / No Other Love ((43311人评价)) - Rating: 6.9\n5. 蒙特利尔,我的美人 / 蒙特利尔,我的爱人 / Montreal, My Beautiful ((12170人评价)) - Rating: 7.6\n6. 与王生活的男人 / 王命之徒(台) / 和王一起生活的男人 ((7834人评价)) - Rating: 7.4\n7. 巅峰猎杀 / 巅峰 ((13676人评价)) - Rating: 6.3\n8. 准备好了没2:我来了 / 爆血新婚夜2:豪门游戏(港) / 弑婚游戏:2度开局(台) ((13991人评价)) - Rating: 6.0\n9. 翠湖 / As The Water Flows ((17859人评价)) - Rating: 7.8\n10. 像我这样的爱情 / Someone Like Me ((5564人评价)) - Rating: 7.1\n",
"author" : "Douban",
"publishDate" : "2026-05-19"
}

30
w11/爬虫运行结果/data/豆瓣电影评分.txt

@ -0,0 +1,30 @@
========================================
爬虫数据采集结果
========================================
生成时间: 2026年05月19日 10:44:25
========================================
【标题】
Douban Movies
【作者】
Douban
【发布日期】
2026-05-19
【来源链接】
https://movie.douban.com/chart
【内容】
────────────────
1. 世界的主人 / 若问世界谁无伤(港) / 世界之主 ((102341人评价)) - Rating: 9.1
2. 爱情抓马 / 抓马恋人(台) / 戏剧性婚礼(港) ((27347人评价)) - Rating: 7.0
3. 杀的就是你 / 杀死你(港) / 他们要杀你(台) ((18784人评价)) - Rating: 6.9
4. 蜂蜜的针 / 没有别的爱 / No Other Love ((43311人评价)) - Rating: 6.9
5. 蒙特利尔,我的美人 / 蒙特利尔,我的爱人 / Montreal, My Beautiful ((12170人评价)) - Rating: 7.6
6. 与王生活的男人 / 王命之徒(台) / 和王一起生活的男人 ((7834人评价)) - Rating: 7.4
7. 巅峰猎杀 / 巅峰 ((13676人评价)) - Rating: 6.3
8. 准备好了没2:我来了 / 爆血新婚夜2:豪门游戏(港) / 弑婚游戏:2度开局(台) ((13991人评价)) - Rating: 6.0
9. 翠湖 / As The Water Flows ((17859人评价)) - Rating: 7.8
10. 像我这样的爱情 / Someone Like Me ((5564人评价)) - Rating: 7.1

7
w11/爬虫运行结果/data/长沙天气.json

@ -0,0 +1,7 @@
{
"title" : "Changsha Weather",
"url" : "https://www.tianqi.com/changsha/",
"content" : "City: Changsha\nTemperature: N/A\nWeather: N/A\nHumidity: N/A\nWind: N/A\nUpdate Time: \n\nForecast:\n",
"author" : "Weather API",
"publishDate" : "2026-05-19T10:44:24.997101700"
}

28
w11/爬虫运行结果/data/长沙天气.txt

@ -0,0 +1,28 @@
========================================
爬虫数据采集结果
========================================
生成时间: 2026年05月19日 10:44:25
========================================
【标题】
Changsha Weather
【作者】
Weather API
【发布日期】
2026-05-19T10:44:24.997101700
【来源链接】
https://www.tianqi.com/changsha/
【内容】
────────────────
City: Changsha
Temperature: N/A
Weather: N/A
Humidity: N/A
Wind: N/A
Update Time:
Forecast:
Loading…
Cancel
Save