fix: 페이스북 공개 피드 대체 수집 경로 추가

This commit is contained in:
hehihoho3@gmail.com 2026-09-04 17:57:46 +09:00
parent b7836cdee9
commit b01039c223
3 changed files with 72 additions and 0 deletions

View File

@ -26,6 +26,9 @@ import java.util.regex.Pattern;
public class FacebookPageFeedClient implements SocialFeedClient { public class FacebookPageFeedClient implements SocialFeedClient {
private static final String PAGE_PLUGIN_APP_ID = "776730922422337"; private static final String PAGE_PLUGIN_APP_ID = "776730922422337";
private static final Pattern REEL_ID = Pattern.compile("/(?:reel|videos)/(\\d+)"); 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가 데이터센터별로 빈 피드를 돌려주는 경우가 있어 공식 로케일 호스트를 순서대로 시도한다. */ /** Facebook가 데이터센터별로 빈 피드를 돌려주는 경우가 있어 공식 로케일 호스트를 순서대로 시도한다. */
private static final List<String> RENDERER_HOSTS = List.of( private static final List<String> RENDERER_HOSTS = List.of(
"www.facebook.com", "ko-kr.facebook.com", "en-gb.facebook.com"); "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}") @Value("${hlab.social-feed.max-items:25}")
private int maxItems; private int maxItems;
@Value("${hlab.social-feed.facebook-reader-enabled:true}")
private boolean readerFallbackEnabled;
@Override @Override
public boolean supports(SocialPlatform platform) { public boolean supports(SocialPlatform platform) {
return platform == SocialPlatform.FACEBOOK; return platform == SocialPlatform.FACEBOOK;
@ -53,10 +59,41 @@ public class FacebookPageFeedClient implements SocialFeedClient {
lastFailure = 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 공개 피드를 읽지 못했습니다: " throw new IllegalStateException("Facebook 공개 피드를 읽지 못했습니다: "
+ (lastFailure == null ? "빈 응답" : friendly(lastFailure)), lastFailure); + (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) { private SocialFeedBatch fetchFromHost(SocialChannel channel, String host) {
try { try {
Map<String, Object> config = new LinkedHashMap<>(); Map<String, Object> config = new LinkedHashMap<>();
@ -143,6 +180,22 @@ public class FacebookPageFeedClient implements SocialFeedClient {
return new SocialFeedBatch(channelTitle, null, new ArrayList<>(unique.values())); 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) { private static String findThumbnail(Element post) {
for (Element image : post.select("img[src]")) { for (Element image : post.select("img[src]")) {
String src = image.absUrl("src"); String src = image.absUrl("src");

View File

@ -137,6 +137,7 @@ hlab:
max-items: ${SOCIAL_FEED_MAX_ITEMS:25} max-items: ${SOCIAL_FEED_MAX_ITEMS:25}
timeout-seconds: ${SOCIAL_FEED_TIMEOUT_SECONDS:90} timeout-seconds: ${SOCIAL_FEED_TIMEOUT_SECONDS:90}
cookies-file: ${SOCIAL_FEED_COOKIES_FILE:} cookies-file: ${SOCIAL_FEED_COOKIES_FILE:}
facebook-reader-enabled: ${SOCIAL_FEED_FACEBOOK_READER_ENABLED:true}
seed: seed:
phutunet-enabled: ${SOCIAL_SEED_PHUTUNET_ENABLED:true} phutunet-enabled: ${SOCIAL_SEED_PHUTUNET_ENABLED:true}
phutunet-url: ${SOCIAL_SEED_PHUTUNET_URL:https://www.facebook.com/phutunet/reels/} phutunet-url: ${SOCIAL_SEED_PHUTUNET_URL:https://www.facebook.com/phutunet/reels/}

View File

@ -32,4 +32,22 @@ class FacebookPageFeedClientTest {
assertThat(batch.videos().get(0).thumbnailUrl()).isEqualTo("https://cdn.example/thumb.jpg"); assertThat(batch.videos().get(0).thumbnailUrl()).isEqualTo("https://cdn.example/thumb.jpg");
assertThat(batch.videos().get(0).publishedAt()).isNotNull(); 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");
}
} }