FeedDiscoveryService.java

package net.sasasin.sreader.service;

import java.net.URI;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.stereotype.Service;

@Service
public class FeedDiscoveryService {

  private final PlaywrightHtmlSource playwrightHtmlSource;

  public FeedDiscoveryService(PlaywrightHtmlSource playwrightHtmlSource) {
    this.playwrightHtmlSource = playwrightHtmlSource;
  }

  public List<URI> discover(URI siteUrl) {
    return discoverWithResult(siteUrl).feedUrls();
  }

  public DiscoveryResult discoverWithResult(URI siteUrl) {
    RenderedPage rendered = playwrightHtmlSource.renderPage(siteUrl.toString(), false);
    URI finalUri = rendered.finalUri() != null ? rendered.finalUri() : siteUrl;
    String base = finalUri.toString();
    Document doc = Jsoup.parse(rendered.html(), base);

    Elements links = doc.select("link[rel~=(?i)\\balternate\\b][href]");

    Set<String> seen = new LinkedHashSet<>();
    List<URI> results = new ArrayList<>();

    for (Element link : links) {
      String type = link.attr("type");
      if (!isFeedType(type)) {
        continue;
      }
      String href = link.absUrl("href");
      if (href.isBlank()) {
        href = link.attr("href");
        if (href.isBlank()) {
          continue;
        }
        // absUrl may fail if no base, fallback
        try {
          href = siteUrl.resolve(href).toString();
        } catch (Exception ignored) {
          continue;
        }
      }
      if (seen.add(href)) {
        try {
          results.add(URI.create(href));
        } catch (IllegalArgumentException ignored) {
          // skip bad
        }
      }
    }
    return new DiscoveryResult(siteUrl, finalUri, results);
  }

  private boolean isFeedType(String type) {
    if (type == null || type.isBlank()) {
      return false;
    }
    String t = type.toLowerCase(Locale.ROOT).trim();
    return t.contains("rss")
        || t.contains("atom")
        || t.equals("application/feed+json")
        || t.equals("application/xml")
        || t.equals("text/xml");
  }

  public record DiscoveryResult(URI inputUrl, URI finalUrl, List<URI> feedUrls) {}
}