ProbeArticleCommand.java

package net.sasasin.sreader.cli;

import java.net.URI;
import java.util.Optional;
import java.util.concurrent.Callable;
import net.sasasin.sreader.domain.FullTextMethod;
import net.sasasin.sreader.domain.ProbeResult;
import net.sasasin.sreader.service.FullTextProbeService;
import org.springframework.stereotype.Component;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.Spec;

@Command(
    name = "article",
    description = {
      "Fetch a single article URL with the specified extraction method and print extracted text.",
      "STDOUT gets the body text only (unless --output). STDERR gets diagnostics only with"
          + " --verbose.",
      "Use --xpath to override extraction to a specific XPath (disables rule lookup and"
          + " readability).",
    },
    mixinStandardHelpOptions = true,
    usageHelpWidth = 100)
@Component
public class ProbeArticleCommand implements Callable<Integer> {

  private final FullTextProbeService fullTextProbeService;

  @Spec private CommandSpec spec;

  @Option(
      names = "--url",
      paramLabel = "<ARTICLE_URL>",
      description = "Article URL to probe (http/https only, no userinfo)",
      required = true)
  private String url;

  @Option(
      names = "--method",
      paramLabel = "<METHOD>",
      description =
          "Full text method: feed|http|http_readability|playwright|playwright_readability"
              + "|playwright_infy_scroll|playwright_infy_scroll_readability",
      required = true,
      converter = FullTextMethodConverter.class)
  private FullTextMethod method;

  @Option(
      names = "--xpath",
      paramLabel = "<XPATH>",
      description = "XPath to use instead of DB rules or readability (for testing a rule)")
  private String xpath;

  @Option(
      names = "--verbose",
      description = "Print diagnostic info (input/final URL, title, lengths) to STDERR")
  private boolean verbose;

  @Option(
      names = {"--output", "-o"},
      paramLabel = "<PATH>",
      description = "Write extracted text to this file instead of STDOUT")
  private String output;

  @Option(
      names = "--max-chars",
      paramLabel = "<N>",
      description = "Truncate output text to first N characters (extraction is still full)")
  private Integer maxChars;

  public ProbeArticleCommand(FullTextProbeService fullTextProbeService) {
    this.fullTextProbeService = fullTextProbeService;
  }

  @Override
  public Integer call() {
    try {
      URI uri = UrlValidator.validateHttpUrl(url, "--url", spec);

      if (method == FullTextMethod.FEED) {
        // usage error via picocli style
        throw new picocli.CommandLine.ParameterException(
            spec.commandLine(), "--method feed is not valid for 'probe article'");
      }

      Optional<String> xp =
          (xpath != null && !xpath.isBlank()) ? Optional.of(xpath) : Optional.empty();

      ProbeResult result = fullTextProbeService.probeArticle(uri, method, xp);

      ProbeOutputWriter writer = new ProbeOutputWriter(spec);
      if (result.text() == null || result.text().isBlank()) {
        if (verbose) {
          // still emit diagnostics
          writer.writeResult(result, true, output, maxChars);
        }
        return 4;
      }

      return writer.writeResult(result, verbose, output, maxChars);
    } catch (picocli.CommandLine.ParameterException pe) {
      throw pe; // let picocli handle as usage 2
    } catch (FullTextProbeService.PlaywrightDisabledException e) {
      spec.commandLine().getErr().println(e.getMessage());
      return 5;
    } catch (Exception e) {
      spec.commandLine().getErr().println("Error: " + e.getMessage());
      return 1;
    }
  }
}