h-lab/src/main/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClient.java

223 lines
11 KiB
Java

package com.hlab.yanalyst.domain.social;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Facebook Page Plugin의 공개 타임라인에서 릴 목록을 읽는다. 로그인·Graph API 토큰이 필요 없다. */
@Component
@RequiredArgsConstructor
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<String> RENDERER_HOSTS = List.of(
"www.facebook.com", "ko-kr.facebook.com", "en-gb.facebook.com");
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@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;
}
@Override
public SocialFeedBatch fetch(SocialChannel channel) {
IllegalStateException lastFailure = null;
for (String host : RENDERER_HOSTS) {
try {
SocialFeedBatch batch = fetchFromHost(channel, host);
if (!batch.videos().isEmpty()) return batch;
lastFailure = new IllegalStateException("Facebook 공개 피드에 릴이 없습니다: " + host);
} catch (IllegalStateException e) {
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<String> 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<String, Object> config = new LinkedHashMap<>();
config.put("app_id", PAGE_PLUGIN_APP_ID);
config.put("href", channel.getSourceUrl());
config.put("width", 500);
config.put("height", 800);
config.put("has_cta", true);
config.put("has_small_header", false);
config.put("has_adapt_container_width", true);
config.put("has_cover", true);
config.put("has_posts", false);
config.put("tabs", "timeline");
config.put("can_personalize", false);
config.put("is_xfbml", false);
config.put("referer_uri", "");
URI uri = UriComponentsBuilder.fromHttpUrl("https://" + host + "/platform/plugin/tab/renderer/")
.queryParam("key", "timeline")
.queryParam("config_json", objectMapper.writeValueAsString(config))
.queryParam("__a", "1")
.build().encode().toUri();
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
headers.set(HttpHeaders.ACCEPT_LANGUAGE, "ko-KR,ko;q=0.9,en;q=0.8");
headers.set("X-Requested-With", "XMLHttpRequest");
ResponseEntity<String> response = restTemplate.exchange(uri, HttpMethod.GET,
new HttpEntity<>(headers), String.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new IllegalStateException("Facebook 공개 피드 응답이 비어 있습니다.");
}
return parseResponse(response.getBody(), Math.max(1, maxItems));
} catch (Exception e) {
if (e instanceof IllegalStateException state) throw state;
throw new IllegalStateException(host + " Facebook 피드 오류: " + friendly(e), e);
}
}
SocialFeedBatch parseResponse(String body, int limit) {
try {
String json = body == null ? "" : body.strip();
if (json.startsWith("for (;;);")) json = json.substring("for (;;);".length());
JsonNode root = objectMapper.readTree(json);
String markup = root.path("payload").path("content").path("markup").path("__html").asText("");
if (markup.isBlank()) throw new IllegalStateException("Facebook 공개 피드에 표시할 게시물이 없습니다.");
return parseMarkup(markup, limit);
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException("Facebook 공개 피드 형식이 변경되었습니다.", e);
}
}
static SocialFeedBatch parseMarkup(String markup, int limit) {
Document document = Jsoup.parse(markup, "https://www.facebook.com");
LinkedHashMap<String, SocialVideoCandidate> unique = new LinkedHashMap<>();
String channelTitle = null;
for (Element post : document.select("div[data-fte=1][data-ftr=1]")) {
Element reelLink = post.selectFirst("a[href*=/reel/], a[href*=/videos/]");
if (reelLink == null) continue;
Matcher matcher = REEL_ID.matcher(reelLink.attr("href"));
if (!matcher.find()) continue;
String id = matcher.group(1);
if (unique.containsKey(id)) continue;
Element titleElement = post.selectFirst("[data-testid=post_message]");
String title = titleElement == null ? "(제목 없음)" : titleElement.text().strip();
Element timeElement = post.selectFirst("abbr[data-utime]");
LocalDateTime publishedAt = parseUnixTime(timeElement == null ? null : timeElement.attr("data-utime"));
String thumbnail = findThumbnail(post);
unique.put(id, new SocialVideoCandidate(id, "https://www.facebook.com/reel/" + id,
title.isBlank() ? "(제목 없음)" : title, thumbnail, publishedAt));
if (channelTitle == null) {
Element titleNode = post.selectFirst("._2_79, a[title]");
if (titleNode != null && !titleNode.text().isBlank()) channelTitle = titleNode.text().strip();
if ((channelTitle == null || channelTitle.isBlank()) && titleNode != null) {
channelTitle = titleNode.attr("title").strip();
}
}
if (unique.size() >= limit) break;
}
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<String, SocialVideoCandidate> unique = new LinkedHashMap<>();
Matcher reelMatcher = READER_REEL.matcher(markdown == null ? "" : markdown);
while (reelMatcher.find() && unique.size() < limit) {
String thumbnail = reelMatcher.group(1).replace("&amp;", "&");
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");
String classes = image.className();
if (!src.isBlank() && !src.contains("s50x50") && !classes.contains("_s0")) return src;
}
return null;
}
private static LocalDateTime parseUnixTime(String value) {
if (value == null || value.isBlank()) return null;
try {
return LocalDateTime.ofInstant(Instant.ofEpochSecond(Long.parseLong(value)), ZoneId.systemDefault());
} catch (NumberFormatException ignored) {
return null;
}
}
private static String friendly(Exception e) {
String message = e.getMessage();
if (message == null || message.isBlank()) return e.getClass().getSimpleName();
return message.length() <= 500 ? message : message.substring(0, 500);
}
}