You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

195 lines
7.9 KiB

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<Article> 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<Article> articles) {
view.printInfo("可用命令:");
view.printInfo(" crawl <url> 抓取 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<Article> 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<Article> ignored) {
if (args.length < 2 || args[1].isBlank()) {
view.printError("用法:crawl <url>");
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<Article> ignored) {
List<Article> all = repo.getAll();
if (all.isEmpty()) { view.printWarning("暂无文章。"); return; }
Map<String, Integer> 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<String> history;
HistoryCommand(ConsoleView v, List<String> h) { view = v; history = h; }
@Override public String getName() { return "history"; }
@Override
public void execute(String[] args, List<Article> 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<Article> ignored) {
view.printSuccess("Bye!");
System.exit(0);
}
}