From 5878ed1eac529fa9ff340d92cc0bed628e3ba663 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 30 May 2026 17:52:13 +0800 Subject: [PATCH] improve repl and mvc structure --- pom.xml | 10 ++++ src/main/java/internal/hw/crawler/Main.java | 4 +- .../internal/hw/crawler/MainController.java | 25 ++++---- .../internal/hw/crawler/commands/Command.java | 4 ++ .../hw/crawler/commands/CrawlCommand.java | 11 +++- .../hw/crawler/commands/ExitCommand.java | 5 ++ .../hw/crawler/commands/HelpCommand.java | 41 +++++++++++-- .../hw/crawler/commands/InputParser.java | 32 ++++++++++ .../hw/crawler/commands/ListCommand.java | 5 ++ .../hw/crawler/commands/SaveCommand.java | 7 ++- .../crawler/strategies/crawl/CrawlUtils.java | 10 ++-- .../hw/crawler/views/CommandOutput.java | 1 + .../hw/crawler/views/ConsoleView.java | 58 ++++++++++++------- .../hw/crawler/views/ProgressTracker.java | 31 ++++++++++ 14 files changed, 195 insertions(+), 49 deletions(-) create mode 100644 src/main/java/internal/hw/crawler/commands/InputParser.java create mode 100644 src/main/java/internal/hw/crawler/views/ProgressTracker.java diff --git a/pom.xml b/pom.xml index 4f2c756..5102c39 100644 --- a/pom.xml +++ b/pom.xml @@ -34,5 +34,15 @@ logback-classic 1.5.25 + + org.jline + jline + 3.27.1 + + + org.jline + jline-terminal-jansi + 3.27.1 + \ No newline at end of file diff --git a/src/main/java/internal/hw/crawler/Main.java b/src/main/java/internal/hw/crawler/Main.java index efc7055..2a9af8d 100644 --- a/src/main/java/internal/hw/crawler/Main.java +++ b/src/main/java/internal/hw/crawler/Main.java @@ -20,7 +20,7 @@ public class Main { controller.registerCommand(new SaveCommand(repository, view)); controller.registerCommand(new HelpCommand(controller.getCommands(), view)); - view.printSuccess("Welcome to crawler. Type `help` for a list of available commands."); + view.success("Welcome to crawler. Type `help` for a list of available commands."); while (true) { try { String line = view.readLine(); @@ -32,7 +32,7 @@ public class Main { controller.handleInput(line); } catch (Exception e) { log.error("Unhandled exception in REPL loop", e); - view.printError("Unexpected error: " + e.getMessage()); + view.error("Unexpected error: " + e.getMessage()); } } } diff --git a/src/main/java/internal/hw/crawler/MainController.java b/src/main/java/internal/hw/crawler/MainController.java index 965d564..fa54231 100644 --- a/src/main/java/internal/hw/crawler/MainController.java +++ b/src/main/java/internal/hw/crawler/MainController.java @@ -2,6 +2,7 @@ package internal.hw.crawler; import internal.hw.crawler.commands.Command; import internal.hw.crawler.commands.CommandArg; +import internal.hw.crawler.commands.InputParser; import internal.hw.crawler.strategies.crawl.CrawlerException; import internal.hw.crawler.views.ConsoleView; import org.slf4j.Logger; @@ -30,31 +31,29 @@ public class MainController { } public void handleInput(String input) { - String text = input == null ? "" : input.trim(); - if (text.isEmpty()) { + InputParser.ParsedInput parsed = InputParser.parse(input); + if (parsed == null) { return; } - String[] args = text.split("\\s+"); - String cmdName = args[0].toLowerCase(); - Command command = commands.get(cmdName); + Command command = commands.get(parsed.command()); if (command == null) { - view.printError("Unknown command: " + cmdName); + view.error("Unknown command: " + parsed.command()); return; } - if (!validateArgs(command, args)) { + if (!validateArgs(command, parsed.args())) { return; } try { - command.execute(args); + command.execute(parsed.args()); } catch (CrawlerException e) { - log.warn("Crawler error in command '{}'", cmdName, e); - view.printError(e.getMessage()); + log.warn("Crawler error in command '{}'", parsed.command(), e); + view.error(e.getMessage()); } catch (Exception e) { - log.error("Unexpected error in command '{}'", cmdName, e); - view.printError("Internal error: " + e.getMessage()); + log.error("Unexpected error in command '{}'", parsed.command(), e); + view.error("Internal error: " + e.getMessage()); } } @@ -64,7 +63,7 @@ public class MainController { int provided = args.length - 1; if (provided < required) { - view.printError("Usage: " + command.getName() + " " + formatUsage(cmdArgs)); + view.error("Usage: " + command.getName() + " " + formatUsage(cmdArgs)); return false; } return true; diff --git a/src/main/java/internal/hw/crawler/commands/Command.java b/src/main/java/internal/hw/crawler/commands/Command.java index dbfa8fd..d9cc3e7 100644 --- a/src/main/java/internal/hw/crawler/commands/Command.java +++ b/src/main/java/internal/hw/crawler/commands/Command.java @@ -10,4 +10,8 @@ public interface Command { } void execute(String[] args) throws Exception; + + default String getDescription() { + return ""; + } } diff --git a/src/main/java/internal/hw/crawler/commands/CrawlCommand.java b/src/main/java/internal/hw/crawler/commands/CrawlCommand.java index 66347d1..86c952b 100644 --- a/src/main/java/internal/hw/crawler/commands/CrawlCommand.java +++ b/src/main/java/internal/hw/crawler/commands/CrawlCommand.java @@ -9,6 +9,7 @@ import internal.hw.crawler.strategies.crawl.CrawlNetworkException; import internal.hw.crawler.strategies.crawl.CrawlParseException; import internal.hw.crawler.strategies.crawl.CrawlUnsupportedException; import internal.hw.crawler.views.CommandOutput; +import internal.hw.crawler.views.ProgressTracker; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.slf4j.Logger; @@ -35,6 +36,11 @@ public class CrawlCommand implements Command { return "crawl"; } + @Override + public String getDescription() { + return "Crawl articles from a supported news website"; + } + @Override public List getArgs() { return List.of(new CommandArg("url", "The website to crawl", true)); @@ -47,10 +53,11 @@ public class CrawlCommand implements Command { URL url = new URL(urlRaw); CrawlStrategy strategy = crawlStrategyFactory.getStrategy(url); Document doc = Jsoup.connect(url.toString()).timeout(5000).get(); - CrawlUtils.setProgressCallback(msg -> System.out.print("\r" + msg)); + ProgressTracker tracker = new ProgressTracker(out); + CrawlUtils.setProgressCallback(tracker::update); try { List
articles = strategy.parse(url, doc); - System.out.println(); + tracker.done(); articles.stream().filter(Objects::nonNull).forEach(repository::add); out.success(String.format("Crawled %d articles from %s", articles.size(), urlRaw)); } finally { diff --git a/src/main/java/internal/hw/crawler/commands/ExitCommand.java b/src/main/java/internal/hw/crawler/commands/ExitCommand.java index 1464586..8c4434d 100644 --- a/src/main/java/internal/hw/crawler/commands/ExitCommand.java +++ b/src/main/java/internal/hw/crawler/commands/ExitCommand.java @@ -14,6 +14,11 @@ public class ExitCommand implements Command { return "exit"; } + @Override + public String getDescription() { + return "Exit the application"; + } + @Override public void execute(String[] args) { out.info("Goodbye."); diff --git a/src/main/java/internal/hw/crawler/commands/HelpCommand.java b/src/main/java/internal/hw/crawler/commands/HelpCommand.java index b9b3109..d5fc435 100644 --- a/src/main/java/internal/hw/crawler/commands/HelpCommand.java +++ b/src/main/java/internal/hw/crawler/commands/HelpCommand.java @@ -19,6 +19,11 @@ public class HelpCommand implements Command { return "help"; } + @Override + public String getDescription() { + return "Show available commands or detailed help for a specific command"; + } + @Override public List getArgs() { return List.of( @@ -37,10 +42,19 @@ public class HelpCommand implements Command { private void showAll() { out.info("Available commands:"); + out.print(""); + int nameWidth = commands.values().stream() + .mapToInt(c -> c.getName().length()).max().orElse(0) + 4; for (Command cmd : commands.values()) { - out.print(" " + cmd.getName()); + String desc = cmd.getDescription(); + if (desc.isEmpty()) { + out.print(" " + cmd.getName()); + } else { + out.print(String.format(" %-" + nameWidth + "s %s", cmd.getName(), desc)); + } } - out.info("Type `help ` to show detailed help for a specific command."); + out.print(""); + out.info("Type `help ` for detailed usage of a specific command."); } private void showDetail(String name) { @@ -50,18 +64,33 @@ public class HelpCommand implements Command { return; } - out.info("Command: " + cmd.getName()); + out.info("Command: " + cmd.getName()); + String desc = cmd.getDescription(); + if (!desc.isEmpty()) { + out.print("Description: " + desc); + } + List cmdArgs = cmd.getArgs(); if (cmdArgs.isEmpty()) { - out.print(" No arguments."); + out.print("Usage: " + cmd.getName()); return; } + StringBuilder usage = new StringBuilder(cmd.getName()); + for (CommandArg arg : cmdArgs) { + if (arg.required()) { + usage.append(" <").append(arg.name()).append(">"); + } else { + usage.append(" [").append(arg.name()).append("]"); + } + } + out.print("Usage: " + usage); + out.info("Arguments:"); for (CommandArg arg : cmdArgs) { - out.print(String.format(" %s %s %s", - arg.required() ? "[Required]" : " ", + out.print(String.format(" %-12s %s%s", arg.name(), + arg.required() ? "(required) " : "(optional) ", arg.description())); } } diff --git a/src/main/java/internal/hw/crawler/commands/InputParser.java b/src/main/java/internal/hw/crawler/commands/InputParser.java new file mode 100644 index 0000000..b1d4f63 --- /dev/null +++ b/src/main/java/internal/hw/crawler/commands/InputParser.java @@ -0,0 +1,32 @@ +package internal.hw.crawler.commands; + +public class InputParser { + private InputParser() {} + + public static ParsedInput parse(String input) { + String text = input == null ? "" : input.trim(); + if (text.isEmpty()) { + return null; + } + String[] parts = text.split("\\s+"); + return new ParsedInput(parts[0].toLowerCase(), parts); + } + + public static class ParsedInput { + private final String command; + private final String[] args; + + public ParsedInput(String command, String[] args) { + this.command = command; + this.args = args; + } + + public String command() { + return command; + } + + public String[] args() { + return args; + } + } +} diff --git a/src/main/java/internal/hw/crawler/commands/ListCommand.java b/src/main/java/internal/hw/crawler/commands/ListCommand.java index 46c464a..76ed2f2 100644 --- a/src/main/java/internal/hw/crawler/commands/ListCommand.java +++ b/src/main/java/internal/hw/crawler/commands/ListCommand.java @@ -18,6 +18,11 @@ public class ListCommand implements Command { return "list"; } + @Override + public String getDescription() { + return "List all crawled articles"; + } + @Override public void execute(String[] args) { for (Article article : articleRepository.getAll()) { diff --git a/src/main/java/internal/hw/crawler/commands/SaveCommand.java b/src/main/java/internal/hw/crawler/commands/SaveCommand.java index 69f40f3..6986a5f 100644 --- a/src/main/java/internal/hw/crawler/commands/SaveCommand.java +++ b/src/main/java/internal/hw/crawler/commands/SaveCommand.java @@ -13,7 +13,7 @@ import java.util.function.Function; import java.util.stream.Collectors; public class SaveCommand implements Command { - Gson gson = new Gson(); + private final Gson gson = new Gson(); private final ArticleRepository articleRepository; private final CommandOutput out; @@ -27,6 +27,11 @@ public class SaveCommand implements Command { return "save"; } + @Override + public String getDescription() { + return "Save articles to JSON file"; + } + @Override public void execute(String[] args) throws IOException { String filename = "articles.output.json"; diff --git a/src/main/java/internal/hw/crawler/strategies/crawl/CrawlUtils.java b/src/main/java/internal/hw/crawler/strategies/crawl/CrawlUtils.java index 603ab6e..abf5c92 100644 --- a/src/main/java/internal/hw/crawler/strategies/crawl/CrawlUtils.java +++ b/src/main/java/internal/hw/crawler/strategies/crawl/CrawlUtils.java @@ -16,17 +16,17 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; import java.util.function.BiFunction; -import java.util.function.Consumer; import java.util.regex.Pattern; import java.util.stream.Collectors; public class CrawlUtils { private static final Logger log = LoggerFactory.getLogger(CrawlUtils.class); private static final int THREAD_POOL_SIZE = 4; - private static final ThreadLocal> progressCallback = new ThreadLocal<>(); + private static final ThreadLocal> progressCallback = new ThreadLocal<>(); - public static void setProgressCallback(Consumer callback) { + public static void setProgressCallback(BiConsumer callback) { progressCallback.set(callback); } @@ -37,7 +37,7 @@ public class CrawlUtils { public static List
parseHomepage(Document doc, Pattern idRegex, BiFunction singleParser) { HttpCrawler crawler = new HttpCrawler(); - Consumer callback = progressCallback.get(); + BiConsumer callback = progressCallback.get(); List hrefs = new ArrayList<>(); for (Element link : doc.getElementsByTag("a")) { @@ -70,7 +70,7 @@ public class CrawlUtils { } finally { int completed = done.incrementAndGet(); if (callback != null) { - callback.accept("Progress: " + completed + "/" + total); + callback.accept(completed, total); } } }, executor)); diff --git a/src/main/java/internal/hw/crawler/views/CommandOutput.java b/src/main/java/internal/hw/crawler/views/CommandOutput.java index 2d6c1ae..7b5fe98 100644 --- a/src/main/java/internal/hw/crawler/views/CommandOutput.java +++ b/src/main/java/internal/hw/crawler/views/CommandOutput.java @@ -5,4 +5,5 @@ public interface CommandOutput { void info(String msg); void success(String msg); void error(String msg); + void printInline(String msg); } diff --git a/src/main/java/internal/hw/crawler/views/ConsoleView.java b/src/main/java/internal/hw/crawler/views/ConsoleView.java index 6e8f027..1a2802a 100644 --- a/src/main/java/internal/hw/crawler/views/ConsoleView.java +++ b/src/main/java/internal/hw/crawler/views/ConsoleView.java @@ -1,7 +1,12 @@ package internal.hw.crawler.views; -import java.util.NoSuchElementException; -import java.util.Scanner; +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; + +import java.io.IOException; +import java.io.PrintWriter; public class ConsoleView implements CommandOutput { private static final String ANSI_RESET = "\u001B[0m"; @@ -9,46 +14,59 @@ public class ConsoleView implements CommandOutput { private static final String ANSI_RED = "\u001B[31m"; private static final String ANSI_BLUE = "\u001B[34m"; - private final Scanner scanner = new Scanner(System.in); + private final LineReader reader; + private final PrintWriter writer; + + public ConsoleView() { + try { + Terminal terminal = TerminalBuilder.builder() + .system(true) + .build(); + this.reader = LineReaderBuilder.builder() + .terminal(terminal) + .build(); + this.writer = terminal.writer(); + } catch (IOException e) { + throw new RuntimeException("Failed to initialize terminal", e); + } + } public String readLine() { - System.out.print("> "); try { - return scanner.nextLine(); - } catch (NoSuchElementException | IllegalStateException e) { + String line = reader.readLine("> "); + return (line == null || line.isBlank()) ? null : line.trim(); + } catch (org.jline.reader.UserInterruptException | org.jline.reader.EndOfFileException e) { return null; } } @Override public void print(String msg) { - System.out.println(msg); + writer.println(msg); + writer.flush(); } - 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); + @Override + public void printInline(String msg) { + writer.print("\r" + msg); + writer.flush(); } @Override public void success(String msg) { - printSuccess(msg); + writer.println(ANSI_GREEN + msg + ANSI_RESET); + writer.flush(); } @Override public void error(String msg) { - printError(msg); + writer.println(ANSI_RED + msg + ANSI_RESET); + writer.flush(); } @Override public void info(String msg) { - printInfo(msg); + writer.println(ANSI_BLUE + msg + ANSI_RESET); + writer.flush(); } } diff --git a/src/main/java/internal/hw/crawler/views/ProgressTracker.java b/src/main/java/internal/hw/crawler/views/ProgressTracker.java new file mode 100644 index 0000000..eff551a --- /dev/null +++ b/src/main/java/internal/hw/crawler/views/ProgressTracker.java @@ -0,0 +1,31 @@ +package internal.hw.crawler.views; + +public class ProgressTracker { + private static final int BAR_WIDTH = 30; + private final CommandOutput out; + + public ProgressTracker(CommandOutput out) { + this.out = out; + } + + public void update(int done, int total) { + if (total <= 0) return; + int filled = (int) ((double) done / total * BAR_WIDTH); + StringBuilder bar = new StringBuilder("["); + for (int i = 0; i < BAR_WIDTH; i++) { + if (i < filled) { + bar.append("="); + } else if (i == filled) { + bar.append(">"); + } else { + bar.append(" "); + } + } + bar.append("] ").append(done).append("/").append(total); + out.printInline(bar.toString()); + } + + public void done() { + out.print(""); + } +}