diff --git a/w11/Article.java b/w11/Article.java new file mode 100644 index 0000000..ca402a0 --- /dev/null +++ b/w11/Article.java @@ -0,0 +1,36 @@ +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * Model: 文章实体,只存放数据,无任何 I/O 代码 + */ +public class Article { + private String title; + private String url; + private String content; + private String author; // 作业1:新增 author + private LocalDate publishDate; // 作业1:新增 publishDate + + public Article(String title, String url, String content, + String author, LocalDate publishDate) { + this.title = title; + this.url = url; + this.content = content; + this.author = author; + this.publishDate = publishDate; + } + + // ── getters ── + public String getTitle() { return title; } + public String getUrl() { return url; } + public String getContent() { return content; } + public String getAuthor() { return author; } + public LocalDate getPublishDate() { return publishDate; } + + @Override + public String toString() { + String dateStr = (publishDate != null) + ? publishDate.format(DateTimeFormatter.ISO_LOCAL_DATE) : "unknown"; + return String.format("[%s] %s (by %s, %s)", url, title, author, dateStr); + } +} diff --git a/w11/ArticleRepository.java b/w11/ArticleRepository.java new file mode 100644 index 0000000..ad96840 --- /dev/null +++ b/w11/ArticleRepository.java @@ -0,0 +1,49 @@ +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 仓库层:封装 List
,对外只暴露安全接口 + * - getAll() 返回不可变视图,切断外部对内部列表的直接引用 + * - add() / addAll() 均进行 null 防御 + */ +public class ArticleRepository { + private final List
store = new ArrayList<>(); + + /** 添加单篇文章,忽略 null */ + public void add(Article article) { + if (article == null) return; + store.add(article); + } + + /** + * 必做1:addAll —— 批量添加,跳过 null 条目 + * 传入 null 列表本身也安全处理 + */ + public void addAll(List
articles) { + if (articles == null) return; + for (Article a : articles) { + add(a); // 复用 add() 的 null 防御 + } + } + + /** 返回只读视图,防止外部 clear()/add() 破坏内部状态 */ + public List
getAll() { + return Collections.unmodifiableList(store); + } + + /** 按 URL 查找,找不到返回 null */ + public Article findByUrl(String url) { + if (url == null) return null; + return store.stream() + .filter(a -> url.equals(a.getUrl())) + .findFirst() + .orElse(null); + } + + public int size() { return store.size(); } + public boolean isEmpty() { return store.isEmpty(); } + + /** 清空(仅供测试使用) */ + void clear() { store.clear(); } +} diff --git a/w11/Commands.java b/w11/Commands.java new file mode 100644 index 0000000..e85fc00 --- /dev/null +++ b/w11/Commands.java @@ -0,0 +1,195 @@ +import java.util.function.Supplier; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +// ───────────────────────────────────────────── +// Command 接口 +// ───────────────────────────────────────────── +interface Command { + String getName(); + void execute(String[] args, List
articles); +} + +// ───────────────────────────────────────────── +// HelpCommand +// ───────────────────────────────────────────── +class HelpCommand implements Command { + private final ConsoleView view; + HelpCommand(ConsoleView v) { this.view = v; } + + @Override public String getName() { return "help"; } + + @Override + public void execute(String[] args, List
articles) { + view.printInfo("可用命令:"); + view.printInfo(" crawl 抓取 URL(别名: c)"); + view.printInfo(" list 列出所有文章"); + view.printInfo(" analyze 策略命中统计"); + view.printInfo(" history 历史命令记录"); + view.printInfo(" help 显示帮助"); + view.printInfo(" exit 退出"); + } +} + +// ───────────────────────────────────────────── +// ListCommand +// ───────────────────────────────────────────── +class ListCommand implements Command { + private final ConsoleView view; + private final ArticleRepository repo; + ListCommand(ConsoleView v, ArticleRepository r) { view = v; repo = r; } + + @Override public String getName() { return "list"; } + + @Override + public void execute(String[] args, List
ignored) { + view.display(repo.getAll()); + } +} + +// ───────────────────────────────────────────── +// CrawlCommand(W11升级:异常体系 + 指数退避 + 断路器) +// ───────────────────────────────────────────── +class CrawlCommand implements Command { + private final ConsoleView view; + private final ArticleRepository repo; + private final StrategySelector selector; + // 选做:断路器,连续失败3次熔断,冷却10秒 + private final CircuitBreaker breaker = new CircuitBreaker(3, 10_000); + + private static final Pattern URL_PAT = + Pattern.compile("^https?://[^\\s/$.?#].[^\\s]*$"); + + CrawlCommand(ConsoleView v, ArticleRepository r, StrategySelector s) { + view = v; repo = r; selector = s; + } + + @Override public String getName() { return "crawl"; } + public String getAlias() { return "c"; } + + @Override + public void execute(String[] args, List
ignored) { + if (args.length < 2 || args[1].isBlank()) { + view.printError("用法:crawl "); + return; + } + String url = args[1].trim(); + + // 必做1:URL 格式校验改为抛出 UrlFormatException(Unchecked) + try { + validateUrl(url); + } catch (UrlFormatException e) { + view.printError(e.getMessage()); + return; + } + + if (repo.findByUrl(url) != null) { + view.printWarning("已抓取过,跳过:" + url); + return; + } + + ParseStrategy strategy = selector.select(url); + if (strategy == null) { + view.printError("无可用策略:" + url); + return; + } + + // 必做2:指数退避重试 + 选做:断路器保护 + final ParseStrategy finalStrategy = strategy; + boolean success = RetryUtils.retryWithFastFail(() -> + breaker.call(() -> { + Article article = finalStrategy.parse(url); + repo.add(article); + view.printSuccess("已抓取 [" + finalStrategy.getName() + "]:" + article.getTitle()); + return true; + }), 3 + ); + + if (!success) { + view.printError("抓取失败(已重试/熔断):" + url); + if (breaker.getState() == CircuitBreaker.State.OPEN) { + view.printWarning("断路器已熔断,请稍后再试。"); + } + } + } + + /** 必做1:集中校验,校验失败抛 UrlFormatException */ + private void validateUrl(String url) { + if (!URL_PAT.matcher(url).matches()) { + throw new UrlFormatException(url); + } + } +} + +// ───────────────────────────────────────────── +// AnalyzeCommand +// ───────────────────────────────────────────── +class AnalyzeCommand implements Command { + private final ConsoleView view; + private final ArticleRepository repo; + private final StrategySelector selector; + + AnalyzeCommand(ConsoleView v, ArticleRepository r, StrategySelector s) { + view = v; repo = r; selector = s; + } + + @Override public String getName() { return "analyze"; } + + @Override + public void execute(String[] args, List
ignored) { + List
all = repo.getAll(); + if (all.isEmpty()) { view.printWarning("暂无文章。"); return; } + + Map hits = new HashMap<>(); + int unmatched = 0; + for (Article a : all) { + ParseStrategy s = selector.select(a.getUrl()); + if (s == null) unmatched++; + else hits.merge(s.getName(), 1, Integer::sum); + } + + view.printBold("── 策略分析报告(共 " + all.size() + " 篇)──"); + for (ParseStrategy s : selector.getAll()) { + view.printInfo(String.format(" %-25s priority=%-4d 命中 %d 篇", + s.getName(), s.getPriority(), hits.getOrDefault(s.getName(), 0))); + } + if (unmatched > 0) view.printWarning("无策略匹配:" + unmatched + " 篇"); + } +} + +// ───────────────────────────────────────────── +// HistoryCommand +// ───────────────────────────────────────────── +class HistoryCommand implements Command { + private final ConsoleView view; + private final List history; + HistoryCommand(ConsoleView v, List h) { view = v; history = h; } + + @Override public String getName() { return "history"; } + + @Override + public void execute(String[] args, List
ignored) { + if (history.isEmpty()) { view.printInfo("暂无历史记录。"); return; } + view.printBold("── 历史命令(共 " + history.size() + " 条)──"); + for (int i = 0; i < history.size(); i++) + view.printInfo(String.format("%3d %s", i + 1, history.get(i))); + } +} + +// ───────────────────────────────────────────── +// ExitCommand +// ───────────────────────────────────────────── +class ExitCommand implements Command { + private final ConsoleView view; + ExitCommand(ConsoleView v) { this.view = v; } + + @Override public String getName() { return "exit"; } + + @Override + public void execute(String[] args, List
ignored) { + view.printSuccess("Bye!"); + System.exit(0); + } +} diff --git a/w11/ConsoleView.java b/w11/ConsoleView.java new file mode 100644 index 0000000..41031c4 --- /dev/null +++ b/w11/ConsoleView.java @@ -0,0 +1,66 @@ +import java.util.List; +import java.util.Scanner; + +/** + * View: 所有 I/O 集中在这里,颜色常量统一管理 + * 选做暗色主题:只需改 THEME_PRIMARY / THEME_SECONDARY 两个常量即可 + */ +public class ConsoleView { + + // ── ANSI 基础色 ── + private static final String ANSI_RESET = "\033[0m"; + private static final String ANSI_GREEN = "\033[32m"; + private static final String ANSI_RED = "\033[31m"; + private static final String ANSI_YELLOW = "\033[33m"; + private static final String ANSI_CYAN = "\033[36m"; + private static final String ANSI_BOLD = "\033[1m"; + + // ── 选做:主题常量(暗色主题只改这两行)── + private static final String THEME_PRIMARY = ANSI_CYAN; // 暗色主题可换 ANSI_GREEN + private static final String THEME_SECONDARY = ANSI_YELLOW; // 暗色主题可换 ANSI_CYAN + + private final Scanner scanner = new Scanner(System.in); + + public void printSuccess(String msg) { + System.out.println(ANSI_GREEN + msg + ANSI_RESET); + } + + public void printError(String msg) { + System.out.println(ANSI_RED + "[ERROR] " + msg + ANSI_RESET); + } + + public void printInfo(String msg) { + System.out.println(THEME_PRIMARY + msg + ANSI_RESET); + } + + public void printWarning(String msg) { + System.out.println(THEME_SECONDARY + "[WARN] " + msg + ANSI_RESET); + } + + public void printBold(String msg) { + System.out.println(ANSI_BOLD + msg + ANSI_RESET); + } + + /** 展示文章列表 */ + public void display(List
articles) { + if (articles.isEmpty()) { + printWarning("暂无文章,请先使用 crawl 抓取。"); + return; + } + printBold("── 文章列表 (" + articles.size() + " 篇) ──"); + for (int i = 0; i < articles.size(); i++) { + Article a = articles.get(i); + if (a == null) { + printWarning((i + 1) + ". [null 条目,已跳过]"); + continue; + } + System.out.printf(THEME_PRIMARY + "%2d. %s" + ANSI_RESET + "%n", i + 1, a); + } + } + + /** 读取一行用户输入,显示提示符 */ + public String readLine() { + System.out.print(ANSI_BOLD + "> " + ANSI_RESET); + return scanner.nextLine().trim(); + } +} diff --git a/w11/Main.java b/w11/Main.java new file mode 100644 index 0000000..c9cc125 --- /dev/null +++ b/w11/Main.java @@ -0,0 +1,57 @@ +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +// ───────────────────────────────────────────── +// Controller +// ───────────────────────────────────────────── +class CrawlerController { + private final ConsoleView view; + private final ArticleRepository repo; + private final Map commands = new HashMap<>(); + private final List history = new ArrayList<>(); + + CrawlerController(ConsoleView view, ArticleRepository repo, StrategySelector selector) { + this.view = view; + this.repo = repo; + + CrawlCommand crawl = new CrawlCommand(view, repo, selector); + reg(crawl.getName(), crawl); + reg(crawl.getAlias(), crawl); + reg(new ListCommand(view, repo)); + reg(new AnalyzeCommand(view, repo, selector)); + reg(new HistoryCommand(view, history)); + reg(new HelpCommand(view)); + reg(new ExitCommand(view)); + } + + private void reg(Command cmd) { commands.put(cmd.getName(), cmd); } + private void reg(String key, Command cmd) { commands.put(key, cmd); } + + public void handle(String input) { + if (input == null || input.isBlank()) return; + history.add(input); + String[] parts = input.trim().split("\\s+"); + Command cmd = commands.get(parts[0].toLowerCase()); + if (cmd == null) view.printError("未知命令:" + parts[0] + "(输入 help)"); + else cmd.execute(parts, repo.getAll()); + } +} + +// ───────────────────────────────────────────── +// Main 入口 +// ───────────────────────────────────────────── +public class Main { + public static void main(String[] args) { + ConsoleView view = new ConsoleView(); + ArticleRepository repo = new ArticleRepository(); + StrategySelector selector = new StrategySelector(); + CrawlerController ctrl = new CrawlerController(view, repo, selector); + + view.printSuccess("Welcome to CLI Crawler W10!"); + view.printInfo("输入 help 查看命令。"); + + while (true) ctrl.handle(view.readLine()); + } +}