fix: 유통 피드 표시와 페이스북 수집 재시도

This commit is contained in:
hehihoho3@gmail.com 2026-09-04 17:52:02 +09:00
parent d25093dd44
commit b7836cdee9
3 changed files with 24 additions and 4 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+)");
/** 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 RestTemplate restTemplate;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@ -40,6 +43,21 @@ public class FacebookPageFeedClient implements SocialFeedClient {
@Override @Override
public SocialFeedBatch fetch(SocialChannel channel) { 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;
}
}
throw new IllegalStateException("Facebook 공개 피드를 읽지 못했습니다: "
+ (lastFailure == null ? "빈 응답" : friendly(lastFailure)), lastFailure);
}
private SocialFeedBatch fetchFromHost(SocialChannel channel, String host) {
try { try {
Map<String, Object> config = new LinkedHashMap<>(); Map<String, Object> config = new LinkedHashMap<>();
config.put("app_id", PAGE_PLUGIN_APP_ID); config.put("app_id", PAGE_PLUGIN_APP_ID);
@ -56,7 +74,7 @@ public class FacebookPageFeedClient implements SocialFeedClient {
config.put("is_xfbml", false); config.put("is_xfbml", false);
config.put("referer_uri", ""); config.put("referer_uri", "");
URI uri = UriComponentsBuilder.fromHttpUrl("https://www.facebook.com/platform/plugin/tab/renderer/") URI uri = UriComponentsBuilder.fromHttpUrl("https://" + host + "/platform/plugin/tab/renderer/")
.queryParam("key", "timeline") .queryParam("key", "timeline")
.queryParam("config_json", objectMapper.writeValueAsString(config)) .queryParam("config_json", objectMapper.writeValueAsString(config))
.queryParam("__a", "1") .queryParam("__a", "1")
@ -73,7 +91,7 @@ public class FacebookPageFeedClient implements SocialFeedClient {
return parseResponse(response.getBody(), Math.max(1, maxItems)); return parseResponse(response.getBody(), Math.max(1, maxItems));
} catch (Exception e) { } catch (Exception e) {
if (e instanceof IllegalStateException state) throw state; if (e instanceof IllegalStateException state) throw state;
throw new IllegalStateException("Facebook 공개 피드를 읽지 못했습니다: " + friendly(e), e); throw new IllegalStateException(host + " Facebook 피드 오류: " + friendly(e), e);
} }
} }

View File

@ -57,7 +57,7 @@
function date(iso){return iso?new Date(iso).toLocaleString('ko-KR',{month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}):'게시 시각 미상'} function date(iso){return iso?new Date(iso).toLocaleString('ko-KR',{month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}):'게시 시각 미상'}
async function loadChannels(){const box=document.getElementById('channelList');try{const list=await api(`${API}/channels?topic=DISTRIBUTION`);box.innerHTML=list.length?list.map(c=>`<div class="social-channel"><span class="social-platform ${c.platform}">${c.platform}</span><div class="social-channel-main"><b>${esc(c.title)}</b><a href="${esc(c.sourceUrl)}" target="_blank" rel="noopener">${esc(c.sourceUrl)}</a><span class="social-channel-meta">${ago(c.lastCollectedAt)} 수집 · 신규 ${c.lastFound}건 · 알림 ${c.notificationEnabled?'켜짐':'꺼짐'}</span>${c.lastError?`<div class="social-channel-error">최근 오류: ${esc(c.lastError)}</div>`:''}</div><button class="social-delete" title="채널 삭제" onclick="removeChannel(${c.id})">×</button></div>`).join(''):'<p class="social-help">등록된 채널이 없습니다.</p>'}catch(e){box.innerHTML=`<div class="social-channel-error">채널을 불러오지 못했습니다: ${esc(e.message)}</div>`}} async function loadChannels(){const box=document.getElementById('channelList');try{const list=await api(`${API}/channels?topic=DISTRIBUTION`);box.innerHTML=list.length?list.map(c=>`<div class="social-channel"><span class="social-platform ${c.platform}">${c.platform}</span><div class="social-channel-main"><b>${esc(c.title)}</b><a href="${esc(c.sourceUrl)}" target="_blank" rel="noopener">${esc(c.sourceUrl)}</a><span class="social-channel-meta">${ago(c.lastCollectedAt)} 수집 · 신규 ${c.lastFound}건 · 알림 ${c.notificationEnabled?'켜짐':'꺼짐'}</span>${c.lastError?`<div class="social-channel-error">최근 오류: ${esc(c.lastError)}</div>`:''}</div><button class="social-delete" title="채널 삭제" onclick="removeChannel(${c.id})">×</button></div>`).join(''):'<p class="social-help">등록된 채널이 없습니다.</p>'}catch(e){box.innerHTML=`<div class="social-channel-error">채널을 불러오지 못했습니다: ${esc(e.message)}</div>`}}
async function loadVideos(){const grid=document.getElementById('videoGrid'),empty=document.getElementById('emptyState');try{const list=await api(`${API}/videos?topic=DISTRIBUTION`);document.getElementById('videoCount').textContent=`${list.length}건`;empty.classList.toggle('hidden',list.length>0);grid.innerHTML=list.map(v=>`<article class="card social-video"><a class="social-thumb" href="${esc(v.url)}" target="_blank" rel="noopener">${v.thumbnailUrl?`<img src="${esc(v.thumbnailUrl)}" alt="" loading="lazy">`:'<span class="social-no-thumb"><i data-lucide="play"></i></span>'}</a><div class="social-video-body"><h3><a href="${esc(v.url)}" target="_blank" rel="noopener">${esc(v.title)}</a></h3><div class="social-video-meta"><span>${esc(v.channelTitle)} · ${v.platform}</span><span>${date(v.publishedAt)}</span></div></div></article>`).join('');if(window.lucide)lucide.createIcons()}catch(e){toast('영상을 불러오지 못했습니다: '+e.message,true)}} async function loadVideos(){const grid=document.getElementById('videoGrid'),empty=document.getElementById('emptyState');try{const list=await api(`${API}/videos?topic=DISTRIBUTION`);document.getElementById('videoCount').textContent=`${list.length}건`;empty.classList.toggle('hidden',list.length>0);grid.innerHTML=list.map(v=>`<article class="card social-video"><a class="social-thumb" href="${esc(v.url)}" target="_blank" rel="noopener">${v.thumbnailUrl?`<img src="${esc(v.thumbnailUrl)}" alt="" loading="lazy">`:'<span class="social-no-thumb"><i data-lucide="play"></i></span>'}</a><div class="social-video-body"><h3><a href="${esc(v.url)}" target="_blank" rel="noopener">${esc(v.title)}</a></h3><div class="social-video-meta"><span>${esc(v.channelTitle)} · ${v.platform}</span><span>${date(v.publishedAt)}</span></div></div></article>`).join('');if(window.lucide)lucide.createIcons()}catch(e){toast('영상을 불러오지 못했습니다: '+e.message,true)}}
async function addChannel(){const input=document.getElementById('channelUrl'),button=document.getElementById('addBtn'),url=input.value.trim();if(!url){input.focus();return}button.disabled=true;try{const c=await api(`${API}/channels`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,topic:'DISTRIBUTION',notificationEnabled:document.getElementById('notifyEnabled').checked})});input.value='';toast(`${c.title} 채널을 등록했습니다`);await loadChannels()}catch(e){toast('등록 실패: '+e.message,true)}finally{button.disabled=false}} async function addChannel(){const input=document.getElementById('channelUrl'),button=document.getElementById('addBtn'),url=input.value.trim();if(!url){input.focus();return}button.disabled=true;try{const c=await api(`${API}/channels`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,topic:'DISTRIBUTION',notificationEnabled:document.getElementById('notifyEnabled').checked})});input.value='';toast(`${c.title} 채널을 등록했습니다 · 첫 수집을 시작합니다`);await loadChannels();await collectNow()}catch(e){toast('등록 실패: '+e.message,true)}finally{button.disabled=false}}
async function removeChannel(id){if(!confirm('이 채널과 수집 영상을 삭제할까요?'))return;try{await api(`${API}/channels/${id}`,{method:'DELETE'});toast('채널을 삭제했습니다');await Promise.all([loadChannels(),loadVideos()])}catch(e){toast('삭제 실패: '+e.message,true)}} async function removeChannel(id){if(!confirm('이 채널과 수집 영상을 삭제할까요?'))return;try{await api(`${API}/channels/${id}`,{method:'DELETE'});toast('채널을 삭제했습니다');await Promise.all([loadChannels(),loadVideos()])}catch(e){toast('삭제 실패: '+e.message,true)}}
async function collectNow(){const button=document.getElementById('collectBtn'),original=button.innerHTML;button.disabled=true;button.textContent='수집 중…';try{const r=await api(`${API}/collect?topic=DISTRIBUTION`,{method:'POST'});toast(`수집 완료 · 새 영상 ${r.savedVideos}건 · 알림 ${r.alerted}건${r.failed?` · 실패 ${r.failed}개`:''}`,r.failed>0);await Promise.all([loadChannels(),loadVideos()])}catch(e){toast('수집 실패: '+e.message,true)}finally{button.disabled=false;button.innerHTML=original;if(window.lucide)lucide.createIcons()}} async function collectNow(){const button=document.getElementById('collectBtn'),original=button.innerHTML;button.disabled=true;button.textContent='수집 중…';try{const r=await api(`${API}/collect?topic=DISTRIBUTION`,{method:'POST'});toast(`수집 완료 · 새 영상 ${r.savedVideos}건 · 알림 ${r.alerted}건${r.failed?` · 실패 ${r.failed}개`:''}`,r.failed>0);await Promise.all([loadChannels(),loadVideos()])}catch(e){toast('수집 실패: '+e.message,true)}finally{button.disabled=false;button.innerHTML=original;if(window.lucide)lucide.createIcons()}}
Promise.all([loadChannels(),loadVideos()]); Promise.all([loadChannels(),loadVideos()]);

View File

@ -24,6 +24,7 @@
<!-- CSS (v= 캐시 버스팅 — CSS 바뀌면 숫자 올리기) --> <!-- CSS (v= 캐시 버스팅 — CSS 바뀌면 숫자 올리기) -->
<link rel="stylesheet" th:href="@{/css/variables.css(v=20260617b)}"> <link rel="stylesheet" th:href="@{/css/variables.css(v=20260617b)}">
<link rel="stylesheet" th:href="@{/css/style.css(v=20260617b)}"> <link rel="stylesheet" th:href="@{/css/style.css(v=20260617b)}">
<th:block layout:fragment="styles"></th:block>
<!-- Lucide Icons (CDN for simplicity in SSR) --> <!-- Lucide Icons (CDN for simplicity in SSR) -->
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
@ -53,6 +54,7 @@
lucide.createIcons(); lucide.createIcons();
</script> </script>
<th:block layout:fragment="script"></th:block> <th:block layout:fragment="script"></th:block>
<th:block layout:fragment="scripts"></th:block>
</body> </body>
</html> </html>