fix: 유통 피드 표시와 페이스북 수집 재시도
This commit is contained in:
parent
d25093dd44
commit
b7836cdee9
@ -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+)");
|
||||
/** 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 ObjectMapper objectMapper;
|
||||
@ -40,6 +43,21 @@ public class FacebookPageFeedClient implements SocialFeedClient {
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Facebook 공개 피드를 읽지 못했습니다: "
|
||||
+ (lastFailure == null ? "빈 응답" : friendly(lastFailure)), lastFailure);
|
||||
}
|
||||
|
||||
private SocialFeedBatch fetchFromHost(SocialChannel channel, String host) {
|
||||
try {
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
config.put("app_id", PAGE_PLUGIN_APP_ID);
|
||||
@ -56,7 +74,7 @@ public class FacebookPageFeedClient implements SocialFeedClient {
|
||||
config.put("is_xfbml", false);
|
||||
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("config_json", objectMapper.writeValueAsString(config))
|
||||
.queryParam("__a", "1")
|
||||
@ -73,7 +91,7 @@ public class FacebookPageFeedClient implements SocialFeedClient {
|
||||
return parseResponse(response.getBody(), Math.max(1, maxItems));
|
||||
} catch (Exception e) {
|
||||
if (e instanceof IllegalStateException state) throw state;
|
||||
throw new IllegalStateException("Facebook 공개 피드를 읽지 못했습니다: " + friendly(e), e);
|
||||
throw new IllegalStateException(host + " Facebook 피드 오류: " + friendly(e), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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'}):'게시 시각 미상'}
|
||||
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 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 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()]);
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
<!-- CSS (v= 캐시 버스팅 — CSS 바뀌면 숫자 올리기) -->
|
||||
<link rel="stylesheet" th:href="@{/css/variables.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) -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
@ -53,6 +54,7 @@
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
<th:block layout:fragment="script"></th:block>
|
||||
<th:block layout:fragment="scripts"></th:block>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user