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("");
+ }
+}