5 changed files with 371 additions and 0 deletions
@ -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); |
|||
} |
|||
} |
|||
@ -0,0 +1,49 @@ |
|||
import java.util.ArrayList; |
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 仓库层:封装 List<Article>,对外只暴露安全接口 |
|||
* - getAll() 返回不可变视图,切断外部对内部列表的直接引用 |
|||
* - add() / addAll() 均进行 null 防御 |
|||
*/ |
|||
public class ArticleRepository { |
|||
private final List<Article> store = new ArrayList<>(); |
|||
|
|||
/** 添加单篇文章,忽略 null */ |
|||
public void add(Article article) { |
|||
if (article == null) return; |
|||
store.add(article); |
|||
} |
|||
|
|||
/** |
|||
* 必做1:addAll —— 批量添加,跳过 null 条目 |
|||
* 传入 null 列表本身也安全处理 |
|||
*/ |
|||
public void addAll(List<Article> articles) { |
|||
if (articles == null) return; |
|||
for (Article a : articles) { |
|||
add(a); // 复用 add() 的 null 防御
|
|||
} |
|||
} |
|||
|
|||
/** 返回只读视图,防止外部 clear()/add() 破坏内部状态 */ |
|||
public List<Article> 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(); } |
|||
} |
|||
@ -0,0 +1,163 @@ |
|||
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
|
|||
// ─────────────────────────────────────────────
|
|||
class CrawlCommand implements Command { |
|||
private final ConsoleView view; |
|||
private final ArticleRepository repo; |
|||
private final StrategySelector selector; |
|||
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(); |
|||
if (!URL_PAT.matcher(url).matches()) { |
|||
view.printError("URL 格式不合法:" + url); |
|||
return; |
|||
} |
|||
if (repo.findByUrl(url) != null) { |
|||
view.printWarning("已抓取过,跳过:" + url); |
|||
return; |
|||
} |
|||
ParseStrategy strategy = selector.select(url); |
|||
if (strategy == null) { |
|||
view.printError("无可用策略:" + url); |
|||
return; |
|||
} |
|||
Article article = strategy.parse(url); |
|||
repo.add(article); |
|||
view.printSuccess("已抓取 [" + strategy.getName() + "]:" + article.getTitle()); |
|||
} |
|||
} |
|||
|
|||
// ─────────────────────────────────────────────
|
|||
// 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); |
|||
} |
|||
} |
|||
@ -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<Article> articles) { |
|||
if (articles.isEmpty()) { |
|||
printWarning("暂无文章,请先使用 crawl <url> 抓取。"); |
|||
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(); |
|||
} |
|||
} |
|||
@ -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<String, Command> commands = new HashMap<>(); |
|||
private final List<String> 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()); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue