fix: 페이스북 공개 피드 대체 수집 경로 추가
This commit is contained in:
parent
b7836cdee9
commit
b01039c223
@ -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<String> 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<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<>();
|
||||
@ -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<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("&", "&");
|
||||
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");
|
||||
|
||||
@ -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/}
|
||||
|
||||
@ -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
|
||||
|
||||
[ 153K](https://www.facebook.com/reel/2234599224051571/?s=profile)
|
||||
|
||||
[ 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");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user