fix: 페이스북 공개 피드 대체 수집 경로 추가
This commit is contained in:
parent
b7836cdee9
commit
b01039c223
@ -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("&", "&");
|
||||||
|
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");
|
||||||
|
|||||||
@ -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/}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
[ 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