ProbeArticleCommand.java

package net.sasasin.sreader.cli;

import java.util.Optional;
import java.util.concurrent.Callable;
import net.sasasin.sreader.domain.FullTextMethod;
import net.sasasin.sreader.service.probe.FullTextProbeService;
import net.sasasin.sreader.service.probe.ProbeOutcome;
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).",
      "Use --autopagerize-dataset-id with AutoPagerize methods to select a non-active dataset.",
    },
    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 extraction method. Invalid input reports all valid values.",
      required = true,
      converter = FullTextMethodConverter.class,
      completionCandidates = FullTextMethodCandidates.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 = "--autopagerize-dataset-id",
      paramLabel = "<ID>",
      description =
          "AutoPagerize dataset id (active when omitted). Only valid with AutoPagerize methods.")
  private Long autopagerizeDatasetId;

  @Option(
      names = "--verbose",
      description = "Print diagnostic info (method, URLs, AutoPagerize page trace) 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 {
      ProbeArticleCliRequest request =
          ProbeArticleCliRequest.create(
              spec, url, method, xpath, verbose, output, maxChars, autopagerizeDatasetId);

      ProbeOutcome outcome =
          fullTextProbeService.probeArticle(
              request.url(), request.method(), request.xpath(), request.autopagerizeDatasetId());
      return mapOutcome(outcome, request);
    } catch (picocli.CommandLine.ParameterException pe) {
      throw pe;
    } catch (RuntimeException e) {
      spec.commandLine().getErr().println("Error: " + e.getMessage());
      return CliExitCodes.EXECUTION_ERROR;
    }
  }

  private int mapOutcome(ProbeOutcome outcome, ProbeArticleCliRequest request) {
    ProbeOutputWriter writer = new ProbeOutputWriter(spec);
    return switch (outcome) {
      case ProbeOutcome.Succeeded succeeded ->
          writer.writeSucceeded(
              succeeded,
              request.verbose(),
              request.output().orElse(null),
              request.maxChars().orElse(null));
      case ProbeOutcome.NoContent noContent -> {
        if (request.verbose()) {
          writer.writeNoContentDiagnostics(noContent.document(), noContent.pagination());
        }
        yield CliExitCodes.EMPTY_RESULT;
      }
      case ProbeOutcome.NoMatchingEntry noMatch -> {
        spec.commandLine().getErr().println("No matching feed entry: " + noMatch.message());
        yield CliExitCodes.NO_MATCHING_ENTRY;
      }
      case ProbeOutcome.Skipped skipped -> {
        spec.commandLine().getErr().println(skipped.message());
        yield CliExitCodes.PLAYWRIGHT_DISABLED;
      }
      case ProbeOutcome.InvalidRequest invalid -> {
        spec.commandLine().getErr().println(invalid.message());
        yield CliExitCodes.USAGE_ERROR;
      }
      case ProbeOutcome.Failed failed -> {
        if (failed.failure().interrupted()) {
          Thread.currentThread().interrupt();
        }
        if (request.verbose()) {
          writer.writeFailureDiagnostics(
              null, failed.pagination(), Optional.of(failed.failure().message()));
        }
        spec.commandLine().getErr().println("Error: " + failed.failure().message());
        yield CliExitCodes.EXECUTION_ERROR;
      }
    };
  }
}