ContentCanonicalizationPlanner.java

package net.sasasin.sreader.service.canonicalization;

import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import net.sasasin.sreader.domain.ContentCanonicalizationCandidate;
import net.sasasin.sreader.domain.ContentCanonicalizationFullText;
import net.sasasin.sreader.domain.ContentCanonicalizationGroup;
import net.sasasin.sreader.domain.ContentCanonicalizationPlan;
import net.sasasin.sreader.domain.ContentCanonicalizationSurvivor;
import net.sasasin.sreader.service.article.HashIds;

/** Pure canonicalization business rules, including all survivor selection tie-breaks. */
final class ContentCanonicalizationPlanner {
  boolean needsChange(ContentCanonicalizationGroup group) {
    return group.members().size() > 1
        || group.members().stream().anyMatch(m -> !m.canonicalUrl().equals(group.canonicalUrl()));
  }

  ContentCanonicalizationPlan plan(ContentCanonicalizationGroup group) {
    List<ContentCanonicalizationCandidate> members = group.members();
    ContentCanonicalizationCandidate base = selectBaseMember(members);
    ContentCanonicalizationCandidate fetch = selectFetchMember(members);
    ContentCanonicalizationCandidate title = selectTitleMember(members, base);
    ContentCanonicalizationCandidate feedText = selectFeedTextMember(members, base);
    ContentCanonicalizationSurvivor survivor =
        new ContentCanonicalizationSurvivor(
            HashIds.md5(group.canonicalUrl()),
            base.feedUrlId(),
            selectSourceMember(members, group.canonicalUrl()).sourceUrl(),
            fetch.fetchUrl(),
            group.canonicalUrl(),
            title.title(),
            selectPublishedAt(members),
            feedText.feedText(),
            members.stream()
                .map(ContentCanonicalizationCandidate::createdAt)
                .min(OffsetDateTime::compareTo)
                .orElseThrow());
    return new ContentCanonicalizationPlan(
        group, survivor, selectFullText(members), detectFeedConflict(members));
  }

  ContentCanonicalizationCandidate selectSourceMember(
      List<ContentCanonicalizationCandidate> members, String canonicalUrl) {
    return members.stream()
        .filter(m -> !m.sourceUrl().equals(canonicalUrl))
        .min(oldest())
        .orElseGet(() -> members.stream().min(oldest()).orElseThrow());
  }

  ContentCanonicalizationCandidate selectFetchMember(
      List<ContentCanonicalizationCandidate> members) {
    return members.stream()
        .max(
            Comparator.comparing(ContentCanonicalizationCandidate::updatedAt)
                .thenComparing(ContentCanonicalizationCandidate::createdAt)
                .thenComparing(ContentCanonicalizationCandidate::id))
        .orElseThrow();
  }

  ContentCanonicalizationCandidate selectBaseMember(
      List<ContentCanonicalizationCandidate> members) {
    return members.stream().min(oldest()).orElseThrow();
  }

  ContentCanonicalizationCandidate selectTitleMember(
      List<ContentCanonicalizationCandidate> members, ContentCanonicalizationCandidate base) {
    return members.stream()
        .filter(m -> !blank(m.title()))
        .max(
            Comparator.comparing(ContentCanonicalizationCandidate::updatedAt)
                .thenComparing(ContentCanonicalizationCandidate::id))
        .orElse(base);
  }

  ContentCanonicalizationCandidate selectFeedTextMember(
      List<ContentCanonicalizationCandidate> members, ContentCanonicalizationCandidate base) {
    return members.stream()
        .filter(m -> !blank(m.feedText()))
        .max(
            Comparator.comparingInt((ContentCanonicalizationCandidate m) -> m.feedText().length())
                .thenComparing(ContentCanonicalizationCandidate::updatedAt)
                .thenComparing(ContentCanonicalizationCandidate::id))
        .orElse(base);
  }

  OffsetDateTime selectPublishedAt(List<ContentCanonicalizationCandidate> members) {
    return members.stream()
        .map(ContentCanonicalizationCandidate::publishedAt)
        .filter(Objects::nonNull)
        .min(OffsetDateTime::compareTo)
        .orElse(null);
  }

  Optional<ContentCanonicalizationFullText> selectFullText(
      List<ContentCanonicalizationCandidate> members) {
    return members.stream()
        .map(ContentCanonicalizationCandidate::fullText)
        .flatMap(Optional::stream)
        .filter(ft -> !blank(ft.text()))
        .max(
            Comparator.comparingInt((ContentCanonicalizationFullText ft) -> ft.text().length())
                .thenComparing(ContentCanonicalizationFullText::extractedAt)
                .thenComparing(
                    ContentCanonicalizationFullText::createdAt, Comparator.reverseOrder())
                .thenComparing(ContentCanonicalizationFullText::id));
  }

  boolean detectFeedConflict(List<ContentCanonicalizationCandidate> members) {
    return members.stream().map(ContentCanonicalizationCandidate::feedUrlId).distinct().count() > 1;
  }

  private Comparator<ContentCanonicalizationCandidate> oldest() {
    return Comparator.comparing(ContentCanonicalizationCandidate::createdAt)
        .thenComparing(ContentCanonicalizationCandidate::id);
  }

  private boolean blank(String value) {
    return value == null || value.isBlank();
  }
}