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 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 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<>(); 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 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 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 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"); 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); } }