FullTextProbeService.java
package net.sasasin.sreader.service;
import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.SyndFeed;
import java.net.URI;
import java.util.Optional;
import net.sasasin.sreader.config.FeedReaderProperties;
import net.sasasin.sreader.domain.ExtractionPlan;
import net.sasasin.sreader.domain.FeedEntrySelection;
import net.sasasin.sreader.domain.FullTextMethod;
import net.sasasin.sreader.domain.ProbeResult;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.springframework.stereotype.Service;
@Service
public class FullTextProbeService {
private final HttpFetchService httpFetchService;
private final PlaywrightHtmlSource playwrightHtmlSource;
private final HtmlTextExtractor htmlTextExtractor;
private final FeedDocumentService feedDocumentService;
private final FeedEntryPicker feedEntryPicker;
private final FeedEntryFullTextExtractor feedEntryFullTextExtractor;
private final FeedReaderProperties properties;
public FullTextProbeService(
HttpFetchService httpFetchService,
PlaywrightHtmlSource playwrightHtmlSource,
HtmlTextExtractor htmlTextExtractor,
FeedDocumentService feedDocumentService,
FeedEntryPicker feedEntryPicker,
FeedEntryFullTextExtractor feedEntryFullTextExtractor,
FeedReaderProperties properties) {
this.httpFetchService = httpFetchService;
this.playwrightHtmlSource = playwrightHtmlSource;
this.htmlTextExtractor = htmlTextExtractor;
this.feedDocumentService = feedDocumentService;
this.feedEntryPicker = feedEntryPicker;
this.feedEntryFullTextExtractor = feedEntryFullTextExtractor;
this.properties = properties;
}
public ProbeResult probeArticle(
URI articleUrl, FullTextMethod method, Optional<String> xpathOverride) {
if (method == FullTextMethod.FEED) {
throw new IllegalArgumentException("--method feed is not supported for probe article");
}
ExtractionPlan plan = ExtractionPlan.from(method);
checkPlaywrightEnabled(plan);
URI finalUrl;
String html;
String usedUrlForExtract;
switch (plan.sourceKind()) {
case HTTP -> {
try {
HttpFetchService.FetchedResource res = httpFetchService.get(articleUrl);
finalUrl = res.uri();
html = res.body();
usedUrlForExtract = finalUrl.toString();
} catch (Exception e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
throw new RuntimeException("HTTP fetch failed for " + articleUrl, e);
}
}
case PLAYWRIGHT -> {
RenderedPage page = renderPage(articleUrl, plan);
finalUrl = page.finalUri() != null ? page.finalUri() : articleUrl;
html = page.html();
usedUrlForExtract = finalUrl.toString();
}
default ->
throw new IllegalStateException("Unexpected source for article: " + plan.sourceKind());
}
String text =
htmlTextExtractor.extract(usedUrlForExtract, html, plan.extractorKind(), xpathOverride);
String title = extractTitleFromHtml(html);
return new ProbeResult(articleUrl, finalUrl, title, method, text == null ? "" : text);
}
public ProbeResult probeFeed(
URI feedUrl,
FullTextMethod method,
FeedEntrySelection selection,
Optional<String> xpathOverride) {
SyndFeed syndFeed = feedDocumentService.fetch(feedUrl);
boolean requireEntryLink = method != FullTextMethod.FEED;
Optional<SyndEntry> picked = feedEntryPicker.pick(syndFeed, selection, requireEntryLink);
if (picked.isEmpty()) {
throw new NoMatchingEntryException("No feed entry matched selection for " + feedUrl);
}
SyndEntry entry = picked.get();
URI entryLink = null;
if (method != FullTextMethod.FEED && entry.getLink() != null && !entry.getLink().isBlank()) {
try {
entryLink = httpFetchService.resolveRedirect(URI.create(entry.getLink()));
} catch (Exception ignored) {
entryLink = URI.create(entry.getLink());
}
}
String entryTitle = entry.getTitle();
if (method == FullTextMethod.FEED) {
if (xpathOverride.isPresent()) {
throw new IllegalArgumentException("--xpath is not applicable for --method feed");
}
String text = feedEntryFullTextExtractor.extract(entry).orElse("");
URI finalForResult = entryLink != null ? entryLink : feedUrl;
return new ProbeResult(feedUrl, finalForResult, entryTitle, method, text);
}
// non-feed: delegate to article logic using entry's link
if (entryLink == null) {
throw new NoMatchingEntryException("Selected entry has no link for " + feedUrl);
}
// recurse like, but to avoid stack use direct
ExtractionPlan plan = ExtractionPlan.from(method);
checkPlaywrightEnabled(plan);
URI finalUrl;
String html;
String usedUrl;
switch (plan.sourceKind()) {
case HTTP -> {
try {
HttpFetchService.FetchedResource res = httpFetchService.get(entryLink);
finalUrl = res.uri();
html = res.body();
usedUrl = finalUrl.toString();
} catch (Exception e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
throw new RuntimeException("HTTP fetch failed for entry " + entryLink, e);
}
}
case PLAYWRIGHT -> {
RenderedPage page = renderPage(entryLink, plan);
finalUrl = page.finalUri() != null ? page.finalUri() : entryLink;
html = page.html();
usedUrl = finalUrl.toString();
}
default -> throw new IllegalStateException("Unexpected for feed probe: " + plan.sourceKind());
}
String text = htmlTextExtractor.extract(usedUrl, html, plan.extractorKind(), xpathOverride);
String title = entryTitle != null ? entryTitle : extractTitleFromHtml(html);
return new ProbeResult(feedUrl, finalUrl, title, method, text == null ? "" : text);
}
private void checkPlaywrightEnabled(ExtractionPlan plan) {
if (plan.sourceKind() == ExtractionPlan.SourceKind.PLAYWRIGHT
&& !properties.playwright().enabled()) {
throw new PlaywrightDisabledException(
"Playwright is required for method but is disabled or misconfigured");
}
}
private RenderedPage renderPage(URI url, ExtractionPlan plan) {
try {
return playwrightHtmlSource.renderPage(url.toString(), plan.useInfyScroll());
} catch (IllegalStateException e) {
throw new PlaywrightDisabledException(e.getMessage());
}
}
private String extractTitleFromHtml(String html) {
try {
Document d = Jsoup.parse(html);
String t = d.title();
return (t != null && !t.isBlank()) ? t : null;
} catch (RuntimeException e) {
return null;
}
}
// public for CLI command to catch and map to specific exit codes
public static class NoMatchingEntryException extends RuntimeException {
public NoMatchingEntryException(String msg) {
super(msg);
}
}
public static class PlaywrightDisabledException extends RuntimeException {
public PlaywrightDisabledException(String msg) {
super(msg);
}
}
}