PlaywrightArticlePageSession.java

package net.sasasin.sreader.service.extraction.browser;

import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.Page;
import java.net.URI;
import java.util.Objects;
import net.sasasin.sreader.config.FeedReaderProperties;
import net.sasasin.sreader.service.autopagerize.ArticlePageSession;
import net.sasasin.sreader.service.autopagerize.PageLoadException;
import net.sasasin.sreader.service.autopagerize.PageSnapshot;
import net.sasasin.sreader.service.outcome.FailureKind;

/**
 * Short-lived standard Playwright session for one article pagination chain.
 *
 * <p>One {@link BrowserContext} and one {@link Page} are reused for every {@link #load(URI)} in the
 * chain. No extension, persistent profile, or user-data directory is used. {@code byteSize} is the
 * UTF-8 byte length of captured HTML (raw response bytes are not available from Playwright).
 *
 * <p>Callers must serialize access through {@link PlaywrightHtmlSource}; this type is not
 * thread-safe on its own.
 */
final class PlaywrightArticlePageSession implements ArticlePageSession {

  private final BrowserContext context;
  private final Page page;
  private final PlaywrightPageNavigator navigator;
  private boolean closeRequested;
  private boolean pageClosed;
  private boolean contextClosed;

  PlaywrightArticlePageSession(
      BrowserContext context, Page page, PlaywrightPageNavigator navigator) {
    this.context = Objects.requireNonNull(context, "context must not be null");
    this.page = Objects.requireNonNull(page, "page must not be null");
    this.navigator = Objects.requireNonNull(navigator, "navigator must not be null");
  }

  /**
   * Opens a viewport-matched context and a single page from the shared regular browser. On page
   * creation failure the context is closed before rethrowing.
   */
  static PlaywrightArticlePageSession open(
      PlaywrightRuntime runtime,
      FeedReaderProperties.Playwright settings,
      PlaywrightPageNavigator navigator) {
    Objects.requireNonNull(runtime, "runtime must not be null");
    Objects.requireNonNull(settings, "settings must not be null");
    Objects.requireNonNull(navigator, "navigator must not be null");
    BrowserContext context =
        runtime
            .browser()
            .newContext(
                new Browser.NewContextOptions()
                    .setViewportSize(settings.viewportWidth(), settings.viewportHeight()));
    RuntimeException primary = null;
    try {
      Page page = context.newPage();
      return new PlaywrightArticlePageSession(context, page, navigator);
    } catch (RuntimeException e) {
      primary = e;
    }
    primary = PlaywrightCloseSupport.close(primary, context::close);
    throw primary;
  }

  @Override
  public PageSnapshot load(URI uri) throws PageLoadException {
    Objects.requireNonNull(uri, "uri must not be null");
    if (closeRequested) {
      throw new PageLoadException(
          FailureKind.INVALID_INPUT, "Playwright article page session is closed");
    }
    try {
      navigator.navigate(page, uri);
      navigator.waitNetworkIdleBestEffort(page);
      RenderedPage rendered = navigator.capture(page, uri);
      // Playwright exposes rendered DOM HTML, not raw HTTP body bytes; UTF-8 length is the size
      // used for AutoPagerize max-page / max-total policy checks.
      return PageSnapshot.ofUtf8(uri, rendered.finalUri(), rendered.html());
    } catch (RuntimeException e) {
      if (isInterrupted(e)) {
        Thread.currentThread().interrupt();
        throw new PageLoadException(
            FailureKind.INTERRUPTED, "Playwright page load interrupted for " + uri, e);
      }
      throw new PageLoadException(
          FailureKind.RENDER, "Playwright page load failed for " + uri + ": " + message(e), e);
    }
  }

  @Override
  public void close() {
    if (contextClosed) {
      return;
    }
    closeRequested = true;
    RuntimeException primary = null;
    if (!pageClosed) {
      try {
        page.close();
        pageClosed = true;
      } catch (RuntimeException e) {
        primary = e;
      }
    }
    if (!contextClosed) {
      try {
        context.close();
        contextClosed = true;
        pageClosed = true;
      } catch (RuntimeException e) {
        primary =
            PlaywrightCloseSupport.close(
                primary,
                () -> {
                  throw e;
                });
      }
    }
    PlaywrightCloseSupport.throwIfPresent(primary);
  }

  private static boolean isInterrupted(Throwable error) {
    if (Thread.currentThread().isInterrupted()) {
      return true;
    }
    for (Throwable current = error; current != null; current = current.getCause()) {
      if (current instanceof InterruptedException) {
        return true;
      }
    }
    return false;
  }

  private static String message(Throwable e) {
    String message = e.getMessage();
    return message == null || message.isBlank() ? e.getClass().getSimpleName() : message;
  }
}