diff --git a/project/202403030325-刘子菡-期末实验报告.docx b/project/202403030325-刘子菡-期末实验报告.docx new file mode 100644 index 0000000..2158d6f Binary files /dev/null and b/project/202403030325-刘子菡-期末实验报告.docx differ diff --git a/project/202403030325-刘子菡-期末实验报告.md b/project/202403030325-刘子菡-期末实验报告.md new file mode 100644 index 0000000..be0bc85 --- /dev/null +++ b/project/202403030325-刘子菡-期末实验报告.md @@ -0,0 +1,357 @@ +# 《高级程序设计》项目报告 +爬虫项目开发全纪录 + +## 一、项目目标 + +### 1.1 功能目标 + +| 功能 | 描述 | 优先级 | +|------|------|--------| +| 基础爬虫 | 能够爬取单个网站的指定数据 | 高 | +| 面向对象重构 | 使用继承、接口、多态提高代码复用性 | 高 | +| 多网站支持 | 通过策略模式实现不同网站的解析,至少支持3个网站 | 高 | +| CLI交互 | 提供命令行界面,用户输入命令执行爬取、查询、导出等操作 | 高 | +| 命令模式 | 支持crawl、list、search、stat、export、help、exit、auto等命令 | 高 | +| 异常处理 | 自定义异常体系,统一处理爬取、解析、保存过程中的错误 | 中 | +| 数据持久化 | 爬取数据保存为CSV文件,每个网站独立保存,同时支持整体导出 | 高 | +| 一键自动执行 | auto命令自动依次爬取所有网站并导出统计结果 | 中 | + +### 1.2 预期效果 + +程序启动后显示命令提示,用户通过`help`查看命令列表。 +输入`crawl `,根据URL自动选择对应策略,爬取数据并保存为独立的CSV文件。 +输入`list`显示已爬取的所有记录。 +输入`search <关键词>`按标题搜索。 +输入`stat`统计评分分布(或新闻数量)。 +输入`export`将所有数据导出到`all.csv`。 +输入`auto`一键完成三个网站的爬取、列表、统计和导出。 +程序稳定运行,异常时有友好提示并打印堆栈。 + +## 二、项目进展(按周填写) + +### W1:基础爬虫实现 + +**本周任务:** +编写一个简单的爬虫,能够爬取豆瓣电影Top250的数据;将爬取到的数据在控制台打印输出;实现基础的URL请求、HTML解析、数据提取。 +**所学知识:** +1. Jsoup的基本用法:连接、获取Document、CSS选择器提取元素。 +2. 豆瓣电影页面的HTML结构分析。 +3. Java集合类(ArrayList)存储数据。 +**遇到的困难:** +1. 初次使用Jsoup时,不了解如何正确选择CSS选择器,导致数据提取失败。 +2. 豆瓣可能有简单反爬,直接请求偶尔返回验证页。 +**如何解决的:** +1.通过浏览器开发者工具查看元素,逐个尝试选择器,最终确定`".item"`、`".title"`等。 +2.在Jsoup连接时添加`User-Agent`头,模拟浏览器访问。 +**AI是如何帮助的:** +提供了示例代码,解释了CSS选择器的用法,帮助快速上手。 + +### W2:面向对象重构(继承、接口、多态) + +**本周任务:** +1. 将单一爬虫代码重构为面向对象结构:定义`Movie`模型类、`MovieRepository`仓库类。 +2. 创建`Crawler`抽象类和具体子类`DoubanCrawler`,使用继承复用公共爬取逻辑。 +3. 引入接口`ICrawler`,定义统一的`crawl()`方法,利用多态调用不同爬虫。 +**所学知识:** +1. Java的继承、接口、多态在实际项目中的应用。 +2. 设计模式中的“模板方法”思想(抽象类定义框架,子类实现细节)。 +**遇到的困难:** +1. 如何设计抽象类才能既复用代码又允许子类灵活扩展?例如,不同网站的解析逻辑差异很大。 +2. 仓库类存储数据后,如何在多个爬虫之间共享? + +**如何解决的:** +1. 将网络请求和分页逻辑放在抽象类中,将`parse()`方法定义为抽象方法,由子类实现。 +2. 使用依赖注入让所有爬虫共享同一个`MovieRepository`实例。 +**AI是如何帮助的:** +指导抽象类的设计,给出了`BaseCrawler`的代码示例,解释了何时使用继承、何时使用接口。 + +### W3:扩展为多网站爬取(策略模式) + +**本周任务:** +1. 将原有的继承结构改为**策略模式**:定义`MovieCrawlStrategy`接口,为每个网站编写具体策略类(`DoubanTop250Strategy`、`SinaNewsStrategy`、`DoubanBookStrategy`) +2. 实现策略工厂`MovieStrategyFactory`,根据URL动态返回对应策略。 +3. 修改`CrawlCommand`,支持用户输入URL后自动选择合适的策略进行爬取。 +4. 支持豆瓣电影Top250全部分页(250条)、新浪新闻(约100条)、豆瓣图书Top25(1页) +**所学知识:** +1. 策略模式的定义与优点:消除大量的if-else,便于扩展新网站。 +2. 工厂模式配合策略模式的使用。 +3. 不同网站的分页参数差异(`start` vs `offset`)。 +**遇到的困难:** +豆瓣图书的HTML结构与豆瓣电影不同,需要重新写解析逻辑。 +**如何解决的:** +1. 为豆瓣图书单独编写`DoubanBookStrategy`,选择正确的CSS选择器(`".item"`、`".pl2 a"`等)。 +2. 在工厂中同时注册三个策略,确保URL匹配正确。 +**AI是如何帮助的:** +提供`SinaNewsStrategy`的完整实现,调整分页参数,帮助解决豆瓣图书解析问题。 + +### W4:改造为CLI项目(命令模式) + +**本周任务:** +设计命令行交互界面:使用`ConsoleView`读取用户输入,循环处理命令。 +- 实现命令模式:定义`Command`接口,分别实现`CrawlCommand`、`ListCommand`、`SearchCommand`、`StatCommand`、`ExportCommand`、`HelpCommand`、`ExitCommand`、`AutoCommand`。 +- 创建`MovieController`,负责注册命令和分发用户输入。 + +**所学知识:** +命令模式在CLI中的典型应用。 +如何解析用户输入的参数(`args.split("\\s+")`)。 +控制台彩色输出(ANSI转义码)。 + +**遇到的困难:** +1. 如何管理多个命令实例?如何保证命令能够访问仓库和视图? +2. 用户在命令中输入的URL可能包含空格或特殊字符,需要正确处理。 + +**如何解决的:** +1. 在`MovieController`的构造函数中集中注册所有命令,每个命令都持有`ConsoleView`和`MovieRepository`的引用(通过构造函数注入)。 +2. 使用`String.trim().split("\\s+")`按空白符分割。 + +**AI是如何帮助的:** +1. 提供了命令模式的完整代码示例,包括`Command`接口和各个命令类的实现。 +2. 指导了`MovieController`的设计,并建议增加`auto`命令实现一键自动执行。 + +### W5:异常处理与数据存储完善 + +**本周任务:** +1. 建立自定义异常体系:`CrawlerException`基类,派生`CrawlFailedException`、`ParseFailedException`、`SaveFailedException`。 +2. 在爬取、解析、保存等关键位置使用自定义异常包装原始异常,并打印堆栈信息。 +3. 完善数据存储功能:每个网站爬取后立即保存为独立的CSV文件(`douban_movies.csv`、`sina_news.csv`、`douban_books.csv`),同时保留`export`命令导出所有数据的`movies.csv`。 +4.实现`StatCommand`对评分进行统计,`SearchCommand`支持关键词搜索。 + +**所学知识:** +1.自定义异常的设计与使用。 +2.OpenCSV库的使用:导出CSV文件。 +3.Java Stream API进行数据过滤和分组统计。 +**遇到的困难:** +1. 添加自定义异常后,接口方法签名需要声明`throws`,导致大量修改。 +2. CSV文件生成后,Eclipse工作区不自动刷新,找不到文件。 +**如何解决的:** +1. 保持接口的`parse`方法声明`throws ParseFailedException`,在命令实现中捕获并处理,不向上抛(保证CLI不崩溃)。 +2. 在Eclipse中刷新项目(F5)或在文件管理器中查看项目根目录。 +**AI是如何帮助的:** +1. 提供异常类的完整代码,指导在`CrawlCommand`和`ExportCommand`中合理使用自定义异常。 +2. 帮助调试CSV文件保存路径问题,指出相对路径指向项目根目录。 + +### W6:集成与测试 + +**本周任务:** +1. 整体联调,确保三个网站都能稳定爬取并生成CSV。 +2. 测试所有命令功能,修复边界情况(如仓库为空时的提示)。 +3. 编写实验报告,整理项目文档和类图。 + +**所学知识:** +- 项目集成与测试策略。 +- Mermaid语法绘制类图(用于实验报告)。 +- 技术文档撰写。 + +**遇到的困难:** +1. 豆瓣电影分页时偶尔超时,导致部分数据缺失。 +2. 新浪新闻的CSS选择器不够精确,抓取到非新闻链接。 + +**如何解决的:** +1. 增加超时时间到15秒,并在每页之间增加1.5秒延迟,减少请求频率。 +2. 调整新浪新闻解析逻辑:优先使用`".news-item"`,若为空则使用`"a[href*=/c/]"`作为备用,并限制最多30条。 + +**AI是如何帮助的:** +1. 提供类图的Mermaid代码,指导如何绘制和插入报告。 +2. 帮助总结项目亮点和不足,完善总结部分。 + +## 三、项目结构 + +### 3.1 最终包结构 +DoubanMovieCLI/ +├── src/ +│ └── main/ +│ └── java/ +│ └── com/ +│ └── example/ +│ └── moviecli/ +│ ├── Main.java +│ ├── controller/ +│ │ └── MovieController.java +│ ├── command/ +│ │ ├── Command.java +│ │ ├── CrawlCommand.java +│ │ ├── ListCommand.java +│ │ ├── SearchCommand.java +│ │ ├── StatCommand.java +│ │ ├── ExportCommand.java +│ │ ├── HelpCommand.java +│ │ ├── ExitCommand.java +│ │ └── AutoCommand.java +│ ├── model/ +│ │ └── Movie.java +│ ├── repository/ +│ │ └── MovieRepository.java +│ ├── strategy/ +│ │ ├── MovieCrawlStrategy.java +│ │ ├── DoubanTop250Strategy.java +│ │ ├── SinaNewsStrategy.java +│ │ ├── DoubanBookStrategy.java +│ │ └── MovieStrategyFactory.java +│ ├── view/ +│ │ └── ConsoleView.java +│ └── exception/ +│ ├── CrawlerException.java +│ ├── CrawlFailedException.java +│ ├── ParseFailedException.java +│ └── SaveFailedException.java + +### 3.2 类图 + +```mermaid +classDiagram + class Main { + +main() + } + class MovieController { + -Map~String,Command~ commands + +handle(String input) + } + class Command { + <> + +getName() String + +execute(String[]~args~, MovieRepository) + } + class CrawlCommand + class ListCommand + class SearchCommand + class StatCommand + class ExportCommand + class HelpCommand + class ExitCommand + class AutoCommand + Command <|.. CrawlCommand + Command <|.. ListCommand + Command <|.. SearchCommand + Command <|.. StatCommand + Command <|.. ExportCommand + Command <|.. HelpCommand + Command <|.. ExitCommand + Command <|.. AutoCommand + + class MovieRepository { + -List~Movie~ movies + +add(Movie) + +addAll(List~Movie~) + +getAll() List~Movie~ + } + class Movie { + -int rank + -String title + -String originalTitle + -String score + -String year + -String director + +getRank() int + +getTitle() String + } + class ConsoleView { + +readLine() String + +printSuccess(String) + +printError(String) + +printInfo(String) + } + class MovieCrawlStrategy { + <> + +supports(String) boolean + +parse(Document) List~Movie~ + } + class DoubanTop250Strategy + class SinaNewsStrategy + class DoubanBookStrategy + class MovieStrategyFactory { + -List~MovieCrawlStrategy~ strategies + +getStrategy(String) MovieCrawlStrategy + +register(MovieCrawlStrategy) + } + MovieCrawlStrategy <|.. DoubanTop250Strategy + MovieCrawlStrategy <|.. SinaNewsStrategy + MovieCrawlStrategy <|.. DoubanBookStrategy + + class CrawlerException + class CrawlFailedException + class ParseFailedException + class SaveFailedException + CrawlerException <|-- CrawlFailedException + CrawlerException <|-- ParseFailedException + CrawlerException <|-- SaveFailedException +``` + +## 四、成果展示 + +### 4.1 运行截图 + +> 图1:程序启动后显示欢迎信息及help命令列表 +>![alt text](image-1.png) + +> 图2:爬取豆瓣电影Top250,分页进度显示 +>![alt text](image-2.png) + +> 图3:list命令输出前35条电影 +> ![alt text](image-4.png) + +> 图4:stat命令输出评分分布 +>![alt text](image-5.png) + + +> 图5:auto命令依次执行三个网站爬取并保存独立CSV +>![alt text](image-7.png) + +> 图6:展台数据展示 +> 豆瓣电影: +![](image-8.png) +![alt text](image-9.png) +... +![](image-12.png) +新浪新闻 +![alt text](image-13.png) +... +![alt text](image-14.png) +豆瓣图书 +![alt text](image-15.png) +![alt text](image-16.png) +评分分布: +![alt text](image-17.png) + + + +### 4.2 功能测试 + +| 功能 | 测试结果 | 备注 | +|------|----------|------| +| `help` | ✅ 通过 | 显示所有命令及URL示例 | +| `crawl https://movie.douban.com/top250` | ✅ 通过 | 成功爬取250条,生成douban_movies.csv | +| `crawl https://news.sina.com.cn/` | ✅ 通过 | 成功爬取约100条新闻,生成sina_news.csv | +| `crawl https://book.douban.com/top250` | ✅ 通过 | 成功爬取25条图书,生成douban_books.csv | +| `list` | ✅ 通过 | 显示所有已爬取记录 | +| `search 肖申克` | ✅ 通过 | 筛选出包含关键词的记录 | +| `stat` | ✅ 通过 | 统计评分(或新闻来源)分布 | +| `export` | ✅ 通过 | 合并导出movies.csv | +| `auto` | ✅ 通过 | 自动依次完成三个网站爬取、列表、统计、导出 | +| `exit` | ✅ 通过 | 正常退出程序 | + + +## 五、总结 +本项目按照迭代开发的方式,从最简单的单一网站爬虫起步,逐步引入面向对象特性(继承、接口、多态),然后重构为策略模式以支持多网站,再添加CLI命令模式提升交互性,最后完善异常体系和数据存储。整个过程完整实践了软件工程的常见设计模式,并成功实现了三个不同网站的数据爬取与持久化。 + +1.主要成果包括: +(1)逐步完善程序: +每周一个里程碑,代码逐步演进,避免了前期过度设计。 +(2)设计模式应用: +策略模式:解决了不同网站解析逻辑的封装与切换。 +命令模式:使CLI功能易于扩展,新增命令只需实现接口并注册。 +工厂模式:策略工厂根据URL动态创建对应的策略。 +(3)异常处理: +自定义异常体系,在关键环节包装原始异常并打印堆栈,便于调试。 +(4)完整的CLI交互: +支持8个命令,提供auto一键自动化功能,用户体验良好。 +(5)数据持久化: +每个网站独立保存CSV,同时支持合并导出,数据可重复使用。 + +通过本项目,我深入理解了Java设计模式在实际开发中的价值,掌握了Jsoup爬虫的基本技巧以及应对反爬虫的简单策略(如User-Agent、请求延迟)。同时,对Maven/Eclipse的项目管理、CSV文件操作也有了实践认识。 + +2.未来可以继续改进的方向: + +(1)增加更多的网站策略(如知乎热榜、微博热搜)。 +(2)引入代理池应对更严格的反爬。 +(3)增加图形化界面或Web界面。 +(4)支持定时自动爬取和数据持久化到数据库。 diff --git a/project/AutoCommand.java b/project/AutoCommand.java new file mode 100644 index 0000000..38b0387 --- /dev/null +++ b/project/AutoCommand.java @@ -0,0 +1,47 @@ +package com.example.moviecli.command; + +import com.example.moviecli.repository.MovieRepository; +import com.example.moviecli.strategy.MovieStrategyFactory; +import com.example.moviecli.view.ConsoleView; + +public class AutoCommand implements Command { + private final ConsoleView view; + private final MovieStrategyFactory factory; + private final CrawlCommand crawlCommand; + + public AutoCommand(ConsoleView view, MovieStrategyFactory factory) { + this.view = view; + this.factory = factory; + this.crawlCommand = new CrawlCommand(view, factory); + } + + @Override + public String getName() { + return "auto"; + } + + @Override + public void execute(String[] args, MovieRepository repository) { + view.printInfo("开始自动执行预设任务..."); + + // 1. 豆瓣电影 Top250 + crawlCommand.execute(new String[]{"crawl", "https://movie.douban.com/top250"}, repository); + + // 2. 新浪新闻(替代猫眼) + crawlCommand.execute(new String[]{"crawl", "https://news.sina.com.cn/"}, repository); + + // 3. 豆瓣图书 Top50 + crawlCommand.execute(new String[]{"crawl", "https://book.douban.com/top250"}, repository); + + // 4. 列出所有数据 + new ListCommand(view).execute(new String[]{"list"}, repository); + + // 5. 统计评分分布 + new StatCommand(view).execute(new String[]{"stat"}, repository); + + // 6. 导出全部数据到 movies.csv + new ExportCommand(view).execute(new String[]{"export"}, repository); + + view.printSuccess("自动任务执行完毕!已生成三个独立 CSV 文件及总文件 movies.csv。"); + } +} \ No newline at end of file diff --git a/project/Command.java b/project/Command.java new file mode 100644 index 0000000..6691498 --- /dev/null +++ b/project/Command.java @@ -0,0 +1,8 @@ +package com.example.moviecli.command; + +import com.example.moviecli.repository.MovieRepository; + +public interface Command { + String getName(); + void execute(String[] args, MovieRepository repository); +} \ No newline at end of file diff --git a/project/CrawlCommand.java b/project/CrawlCommand.java new file mode 100644 index 0000000..1ec3ade --- /dev/null +++ b/project/CrawlCommand.java @@ -0,0 +1,197 @@ +package com.example.moviecli.command; + +import com.example.moviecli.model.Movie; +import com.example.moviecli.repository.MovieRepository; +import com.example.moviecli.strategy.MovieCrawlStrategy; +import com.example.moviecli.strategy.MovieStrategyFactory; +import com.example.moviecli.view.ConsoleView; +import com.example.moviecli.exception.CrawlFailedException; +import com.example.moviecli.exception.ParseFailedException; +import com.example.moviecli.exception.SaveFailedException; +import com.opencsv.CSVWriter; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; + +import java.io.FileWriter; +import java.util.ArrayList; +import java.util.List; + +public class CrawlCommand implements Command { + private final ConsoleView view; + private final MovieStrategyFactory factory; + + public CrawlCommand(ConsoleView view, MovieStrategyFactory factory) { + this.view = view; + this.factory = factory; + } + + @Override + public String getName() { + return "crawl"; + } + + @Override + public void execute(String[] args, MovieRepository repository) { + if (args.length < 2) { + view.printError("用法: crawl "); + view.printInfo("支持的 URL 示例:"); + view.printInfo(" https://movie.douban.com/top250"); + view.printInfo(" https://news.sina.com.cn/"); + view.printInfo(" https://book.douban.com/top250"); + return; + } + String url = args[1]; + MovieCrawlStrategy strategy = factory.getStrategy(url); + if (strategy == null) { + view.printError("不支持该 URL 的爬取策略: " + url); + return; + } + + if (url.contains("movie.douban.com/top250")) { + crawlDoubanTop250(strategy, repository); + } else if (url.contains("news.sina.com.cn")) { + crawlSinaNews(strategy, repository); + } else if (url.contains("book.douban.com/top250")) { + crawlDoubanBookTop50(strategy, repository); + } else { + crawlSinglePage(url, strategy, repository); + } + } + + /** 豆瓣电影 Top250 -> douban_movies.csv */ + private void crawlDoubanTop250(MovieCrawlStrategy strategy, MovieRepository repository) { + List allMovies = new ArrayList<>(); + int total = 0; + for (int start = 0; start < 250; start += 25) { + String pageUrl = "https://movie.douban.com/top250?start=" + start; + try { + view.printInfo("正在爬取: " + pageUrl); + Document doc = Jsoup.connect(pageUrl) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .timeout(15000) + .get(); + List pageMovies = strategy.parse(doc); + allMovies.addAll(pageMovies); + repository.addAll(pageMovies); + total += pageMovies.size(); + view.printInfo("已累计爬取 " + total + " 条..."); + Thread.sleep(1500); + } catch (ParseFailedException e) { + view.printError("解析失败: " + e.getMessage()); + e.printStackTrace(); + } catch (Exception e) { + CrawlFailedException ex = new CrawlFailedException("豆瓣电影爬取失败: " + pageUrl, e); + view.printError(ex.getMessage()); + ex.printStackTrace(); + } + } + view.printSuccess("豆瓣电影 Top250 全部爬取完成,共 " + total + " 条记录。"); + saveToCsv(allMovies, "douban_movies.csv"); + } + + /** 新浪新闻首页 -> sina_news.csv */ + private void crawlSinaNews(MovieCrawlStrategy strategy, MovieRepository repository) { + String url = "https://news.sina.com.cn/"; + try { + view.printInfo("正在爬取新浪新闻: " + url); + Document doc = Jsoup.connect(url) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .timeout(15000) + .get(); + List news = strategy.parse(doc); + repository.addAll(news); + view.printSuccess("新浪新闻爬取完成,共 " + news.size() + " 条记录。"); + saveToCsv(news, "sina_news.csv"); + } catch (ParseFailedException e) { + view.printError("解析失败: " + e.getMessage()); + e.printStackTrace(); + } catch (Exception e) { + CrawlFailedException ex = new CrawlFailedException("新浪新闻爬取失败: " + url, e); + view.printError(ex.getMessage()); + ex.printStackTrace(); + } + } + + /** 豆瓣图书 Top50 -> douban_books.csv */ + private void crawlDoubanBookTop50(MovieCrawlStrategy strategy, MovieRepository repository) { + List allMovies = new ArrayList<>(); + int total = 0; + for (int start = 0; start < 50; start += 25) { + String pageUrl = "https://book.douban.com/top250?start=" + start; + try { + view.printInfo("正在爬取: " + pageUrl); + Document doc = Jsoup.connect(pageUrl) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .timeout(15000) + .get(); + List pageMovies = strategy.parse(doc); + allMovies.addAll(pageMovies); + repository.addAll(pageMovies); + total += pageMovies.size(); + view.printInfo("已累计爬取 " + total + " 条..."); + Thread.sleep(1500); + } catch (ParseFailedException e) { + view.printError("解析失败: " + e.getMessage()); + e.printStackTrace(); + } catch (Exception e) { + CrawlFailedException ex = new CrawlFailedException("豆瓣图书爬取失败: " + pageUrl, e); + view.printError(ex.getMessage()); + ex.printStackTrace(); + } + } + view.printSuccess("豆瓣图书 Top50 爬取完成,共 " + total + " 条记录。"); + saveToCsv(allMovies, "douban_books.csv"); + } + + /** 单页兜底(未匹配的URL) */ + private void crawlSinglePage(String url, MovieCrawlStrategy strategy, MovieRepository repository) { + List allMovies = new ArrayList<>(); + try { + view.printInfo("正在爬取: " + url); + Document doc = Jsoup.connect(url) + .userAgent("Mozilla/5.0") + .timeout(10000) + .get(); + List movies = strategy.parse(doc); + allMovies.addAll(movies); + repository.addAll(movies); + view.printSuccess("爬取完成!共 " + movies.size() + " 条记录。"); + saveToCsv(allMovies, "unknown.csv"); + } catch (ParseFailedException e) { + view.printError("解析失败: " + e.getMessage()); + e.printStackTrace(); + } catch (Exception e) { + CrawlFailedException ex = new CrawlFailedException("爬取失败: " + url, e); + view.printError(ex.getMessage()); + ex.printStackTrace(); + } + } + + /** 保存电影/新闻列表到 CSV 文件 */ + private void saveToCsv(List items, String filename) { + if (items.isEmpty()) { + view.printInfo("没有数据可保存到 " + filename); + return; + } + try (CSVWriter writer = new CSVWriter(new FileWriter(filename))) { + String[] header = {"Rank", "Title", "OriginalTitle", "Score", "Year", "Director"}; + writer.writeNext(header); + for (Movie m : items) { + String[] line = { + String.valueOf(m.getRank()), + m.getTitle(), + m.getOriginalTitle(), + m.getScore(), + m.getYear(), + m.getDirector() + }; + writer.writeNext(line); + } + view.printSuccess("已保存 " + items.size() + " 条记录到 " + filename); + } catch (Exception e) { + SaveFailedException ex = new SaveFailedException("保存 " + filename + " 失败", e); + view.printError(ex.getMessage()); + ex.printStackTrace(); + } + } +} \ No newline at end of file diff --git a/project/ExitCommand.java b/project/ExitCommand.java new file mode 100644 index 0000000..2aead47 --- /dev/null +++ b/project/ExitCommand.java @@ -0,0 +1,23 @@ +package com.example.moviecli.command; + +import com.example.moviecli.repository.MovieRepository; +import com.example.moviecli.view.ConsoleView; + +public class ExitCommand implements Command { + private final ConsoleView view; + + public ExitCommand(ConsoleView view) { + this.view = view; + } + + @Override + public String getName() { + return "exit"; + } + + @Override + public void execute(String[] args, MovieRepository repository) { + view.printSuccess("再见!"); + System.exit(0); + } +} \ No newline at end of file diff --git a/project/ExportCommand.java b/project/ExportCommand.java new file mode 100644 index 0000000..32e4311 --- /dev/null +++ b/project/ExportCommand.java @@ -0,0 +1,52 @@ +package com.example.moviecli.command; + +import com.example.moviecli.model.Movie; +import com.example.moviecli.repository.MovieRepository; +import com.example.moviecli.view.ConsoleView; +import com.example.moviecli.exception.SaveFailedException; +import com.opencsv.CSVWriter; +import java.io.FileWriter; +import java.util.List; + +public class ExportCommand implements Command { + private final ConsoleView view; + + public ExportCommand(ConsoleView view) { + this.view = view; + } + + @Override + public String getName() { + return "export"; + } + + @Override + public void execute(String[] args, MovieRepository repository) { + List movies = repository.getAll(); + if (movies.isEmpty()) { + view.printError("没有数据可导出。"); + return; + } + try (CSVWriter writer = new CSVWriter(new FileWriter("movies.csv"))) { + String[] header = {"Rank", "Title", "OriginalTitle", "Score", "Year", "Director"}; + writer.writeNext(header); + for (Movie m : movies) { + String[] line = { + String.valueOf(m.getRank()), + m.getTitle(), + m.getOriginalTitle(), + m.getScore(), + m.getYear(), + m.getDirector() + }; + writer.writeNext(line); + } + view.printSuccess("导出成功:movies.csv"); + } catch (Exception e) { + // 使用自定义异常包装原始异常 + SaveFailedException ex = new SaveFailedException("导出CSV文件失败", e); + view.printError(ex.getMessage()); + ex.printStackTrace(); // 打印堆栈,体现使用了自定义异常 + } + } +} \ No newline at end of file