diff --git a/src/main/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClient.java b/src/main/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClient.java index 543eb18..a64b3e9 100644 --- a/src/main/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClient.java +++ b/src/main/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClient.java @@ -26,6 +26,9 @@ import java.util.regex.Pattern; public class FacebookPageFeedClient implements SocialFeedClient { private static final String PAGE_PLUGIN_APP_ID = "776730922422337"; private static final Pattern REEL_ID = Pattern.compile("/(?:reel|videos)/(\\d+)"); + private static final Pattern READER_REEL = Pattern.compile( + "\\[!\\[[^]]*]\\((https?://[^)]+)\\)[^]]*]\\(https?://(?:www\\.)?facebook\\.com/reel/(\\d+)[^)]*\\)"); + private static final Pattern READER_TITLE = Pattern.compile("(?m)^Title:\\s*(.+?)(?:\\s+Reels)?\\s*$"); /** Facebook가 데이터센터별로 빈 피드를 돌려주는 경우가 있어 공식 로케일 호스트를 순서대로 시도한다. */ private static final List RENDERER_HOSTS = List.of( "www.facebook.com", "ko-kr.facebook.com", "en-gb.facebook.com"); @@ -36,6 +39,9 @@ public class FacebookPageFeedClient implements SocialFeedClient { @Value("${hlab.social-feed.max-items:25}") private int maxItems; + @Value("${hlab.social-feed.facebook-reader-enabled:true}") + private boolean readerFallbackEnabled; + @Override public boolean supports(SocialPlatform platform) { return platform == SocialPlatform.FACEBOOK; @@ -53,10 +59,41 @@ public class FacebookPageFeedClient implements SocialFeedClient { lastFailure = e; } } + if (readerFallbackEnabled) { + try { + SocialFeedBatch batch = fetchViaReader(channel, false); + if (batch.videos().isEmpty()) batch = fetchViaReader(channel, true); + if (!batch.videos().isEmpty()) return batch; + } catch (IllegalStateException e) { + lastFailure = e; + } + } throw new IllegalStateException("Facebook 공개 피드를 읽지 못했습니다: " + (lastFailure == null ? "빈 응답" : friendly(lastFailure)), lastFailure); } + /** 운영 서버 IP가 Facebook에서 차단될 때 공개 페이지를 브라우저로 읽어 주는 Reader API를 사용한다. */ + private SocialFeedBatch fetchViaReader(SocialChannel channel, boolean noCache) { + String source = channel.getSourceUrl().replaceAll("/+$", "") + "/reels/"; + URI uri = URI.create("https://r.jina.ai/" + source); + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.USER_AGENT, "Mozilla/5.0"); + headers.set("X-Engine", "browser"); + if (noCache) headers.set("X-No-Cache", "true"); + else headers.set("X-Cache-Tolerance", "3600"); + try { + ResponseEntity response = restTemplate.exchange(uri, HttpMethod.GET, + new HttpEntity<>(headers), String.class); + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw new IllegalStateException("Facebook Reader 응답이 비어 있습니다."); + } + return parseReader(response.getBody(), Math.max(1, maxItems)); + } catch (Exception e) { + if (e instanceof IllegalStateException state) throw state; + throw new IllegalStateException("Facebook Reader 피드 오류: " + friendly(e), e); + } + } + private SocialFeedBatch fetchFromHost(SocialChannel channel, String host) { try { Map config = new LinkedHashMap<>(); @@ -143,6 +180,22 @@ public class FacebookPageFeedClient implements SocialFeedClient { return new SocialFeedBatch(channelTitle, null, new ArrayList<>(unique.values())); } + static SocialFeedBatch parseReader(String markdown, int limit) { + String channelTitle = null; + Matcher titleMatcher = READER_TITLE.matcher(markdown == null ? "" : markdown); + if (titleMatcher.find()) channelTitle = titleMatcher.group(1).strip(); + LinkedHashMap unique = new LinkedHashMap<>(); + Matcher reelMatcher = READER_REEL.matcher(markdown == null ? "" : markdown); + while (reelMatcher.find() && unique.size() < limit) { + String thumbnail = reelMatcher.group(1).replace("&", "&"); + String id = reelMatcher.group(2); + String title = (channelTitle == null ? "Facebook" : channelTitle) + " · Reel " + id; + unique.putIfAbsent(id, new SocialVideoCandidate(id, + "https://www.facebook.com/reel/" + id, title, thumbnail, null)); + } + return new SocialFeedBatch(channelTitle, null, new ArrayList<>(unique.values())); + } + private static String findThumbnail(Element post) { for (Element image : post.select("img[src]")) { String src = image.absUrl("src"); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7a14866..9e61eca 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -137,6 +137,7 @@ hlab: max-items: ${SOCIAL_FEED_MAX_ITEMS:25} timeout-seconds: ${SOCIAL_FEED_TIMEOUT_SECONDS:90} cookies-file: ${SOCIAL_FEED_COOKIES_FILE:} + facebook-reader-enabled: ${SOCIAL_FEED_FACEBOOK_READER_ENABLED:true} seed: phutunet-enabled: ${SOCIAL_SEED_PHUTUNET_ENABLED:true} phutunet-url: ${SOCIAL_SEED_PHUTUNET_URL:https://www.facebook.com/phutunet/reels/} diff --git a/src/test/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClientTest.java b/src/test/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClientTest.java index 352943e..83bac32 100644 --- a/src/test/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClientTest.java +++ b/src/test/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClientTest.java @@ -32,4 +32,22 @@ class FacebookPageFeedClientTest { assertThat(batch.videos().get(0).thumbnailUrl()).isEqualTo("https://cdn.example/thumb.jpg"); assertThat(batch.videos().get(0).publishedAt()).isNotNull(); } + + @Test + void parsesReaderFallbackReels() { + String markdown = """ + Title: Phutu Crafts Reels + + [![Image 4](https://cdn.example/one.jpg?x=1&y=2) 153K](https://www.facebook.com/reel/2234599224051571/?s=profile) + + [![Image 5](https://cdn.example/two.jpg) 1.8M](https://www.facebook.com/reel/926151600080380/) + """; + + SocialFeedBatch batch = FacebookPageFeedClient.parseReader(markdown, 25); + + assertThat(batch.channelTitle()).isEqualTo("Phutu Crafts"); + assertThat(batch.videos()).extracting(SocialVideoCandidate::externalVideoId) + .containsExactly("2234599224051571", "926151600080380"); + assertThat(batch.videos().get(0).thumbnailUrl()).isEqualTo("https://cdn.example/one.jpg?x=1&y=2"); + } }