feat: 유통 피드 미리보기와 영상 길이 표시

This commit is contained in:
hehihoho3@gmail.com 2026-09-04 22:41:03 +09:00
parent 55f56fb390
commit d70bb142c0
12 changed files with 348 additions and 40 deletions

View File

@ -29,6 +29,9 @@ public class FacebookPageFeedClient implements SocialFeedClient {
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*$");
private static final Pattern KOREAN_DURATION = Pattern.compile("길이:\\s*(?:(\\d+)분,?\\s*)?(\\d+)초");
private static final Pattern ENGLISH_DURATION = Pattern.compile(
"(?i)length:\\s*(?:(\\d+)\\s*minutes?,?\\s*)?(\\d+)\\s*seconds?");
/** Facebook가 데이터센터별로 빈 피드를 돌려주는 경우가 있어 공식 로케일 호스트를 순서대로 시도한다. */
private static final List<String> RENDERER_HOSTS = List.of(
"www.facebook.com", "ko-kr.facebook.com", "en-gb.facebook.com");
@ -188,8 +191,9 @@ public class FacebookPageFeedClient implements SocialFeedClient {
Element timeElement = post.selectFirst("abbr[data-utime]");
LocalDateTime publishedAt = parseUnixTime(timeElement == null ? null : timeElement.attr("data-utime"));
String thumbnail = findThumbnail(post);
Integer durationSec = findDuration(post);
unique.put(id, new SocialVideoCandidate(id, "https://www.facebook.com/reel/" + id,
title.isBlank() ? "(제목 없음)" : title, thumbnail, publishedAt));
title.isBlank() ? "(제목 없음)" : title, thumbnail, publishedAt, durationSec));
if (channelTitle == null) {
Element titleNode = post.selectFirst("._2_79, a[title]");
@ -214,7 +218,7 @@ public class FacebookPageFeedClient implements SocialFeedClient {
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));
"https://www.facebook.com/reel/" + id, title, thumbnail, null, null));
}
return new SocialFeedBatch(channelTitle, null, new ArrayList<>(unique.values()));
}
@ -228,6 +232,26 @@ public class FacebookPageFeedClient implements SocialFeedClient {
return null;
}
private static Integer findDuration(Element post) {
for (Element element : post.select("[aria-label]")) {
String label = element.attr("aria-label");
Integer seconds = parseDurationLabel(label, KOREAN_DURATION);
if (seconds != null) return seconds;
seconds = parseDurationLabel(label, ENGLISH_DURATION);
if (seconds != null) return seconds;
}
return null;
}
private static Integer parseDurationLabel(String label, Pattern pattern) {
Matcher matcher = pattern.matcher(label == null ? "" : label);
if (!matcher.find()) return null;
int minutes = matcher.group(1) == null ? 0 : Integer.parseInt(matcher.group(1));
int seconds = Integer.parseInt(matcher.group(2));
int total = minutes * 60 + seconds;
return total > 0 ? total : null;
}
private static LocalDateTime parseUnixTime(String value) {
if (value == null || value.isBlank()) return null;
try {

View File

@ -25,6 +25,7 @@ public class SocialFeedService {
private final SocialVideoRepository videoRepository;
private final List<SocialFeedClient> clients;
private final SocialFeedAlertService alertService;
private final SocialVideoMetadataResolver metadataResolver;
@Value("${hlab.social-feed.enabled:true}") private boolean enabled;
@Value("${hlab.social-feed.seed.phutunet-enabled:true}") private boolean phutunetSeedEnabled;
@ -42,13 +43,14 @@ public class SocialFeedService {
}
public record VideoDto(Long id, String externalVideoId, String url, String title, String thumbnailUrl,
Integer durationSec,
LocalDateTime publishedAt, LocalDateTime collectedAt,
String channelTitle, String platform, String topic) {
static VideoDto from(SocialVideo video) {
SocialChannel channel = video.getChannel();
return new VideoDto(video.getId(), video.getExternalVideoId(), video.getUrl(), video.getTitle(),
video.getThumbnailUrl(), video.getPublishedAt(), video.getCollectedAt(), channel.getTitle(),
channel.getPlatform().name(), channel.getTopic().name());
video.getThumbnailUrl(), video.getDurationSec(), video.getPublishedAt(), video.getCollectedAt(),
channel.getTitle(), channel.getPlatform().name(), channel.getTopic().name());
}
}
@ -134,12 +136,28 @@ public class SocialFeedService {
channel.updateMetadata(batch.channelTitle(), batch.externalChannelId());
List<SocialVideo> inserted = new ArrayList<>();
for (SocialVideoCandidate candidate : batch.videos()) {
for (SocialVideoCandidate original : batch.videos()) {
SocialVideoCandidate candidate = original;
if (candidate.externalVideoId() == null || candidate.externalVideoId().isBlank()
|| candidate.url() == null || candidate.url().isBlank()
|| videoRepository.existsByChannelIdAndExternalVideoId(channelId, candidate.externalVideoId())) {
|| candidate.url() == null || candidate.url().isBlank()) {
continue;
}
Optional<SocialVideo> existing = videoRepository.findByChannelIdAndExternalVideoId(
channelId, candidate.externalVideoId());
if (existing.isPresent()) {
SocialVideo stored = existing.get();
if (stored.getDurationSec() == null) {
Integer durationSec = candidate.durationSec() == null
? metadataResolver.resolveDuration(candidate)
: candidate.durationSec();
stored.fillDuration(durationSec);
if (stored.getDurationSec() != null) videoRepository.save(stored);
}
continue;
}
if (candidate.durationSec() == null) {
candidate = candidate.withDurationSec(metadataResolver.resolveDuration(candidate));
}
inserted.add(videoRepository.save(SocialVideo.from(channel, candidate)));
}
channel.collectionSucceeded(inserted.size());

View File

@ -42,6 +42,9 @@ public class SocialVideo {
@Column(name = "published_at")
private LocalDateTime publishedAt;
@Column(name = "duration_sec")
private Integer durationSec;
@CreatedDate
@Column(name = "collected_at", updatable = false)
private LocalDateTime collectedAt;
@ -54,6 +57,11 @@ public class SocialVideo {
video.title = candidate.title() == null || candidate.title().isBlank() ? "(제목 없음)" : candidate.title();
video.thumbnailUrl = candidate.thumbnailUrl();
video.publishedAt = candidate.publishedAt();
video.durationSec = candidate.durationSec();
return video;
}
public void fillDuration(Integer value) {
if (durationSec == null && value != null && value > 0) durationSec = value;
}
}

View File

@ -7,5 +7,10 @@ public record SocialVideoCandidate(
String url,
String title,
String thumbnailUrl,
LocalDateTime publishedAt
) { }
LocalDateTime publishedAt,
Integer durationSec
) {
SocialVideoCandidate withDurationSec(Integer value) {
return new SocialVideoCandidate(externalVideoId, url, title, thumbnailUrl, publishedAt, value);
}
}

View File

@ -0,0 +1,79 @@
package com.hlab.yanalyst.domain.social;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** 채널 목록 응답에 빠진 영상 길이를 개별 영상 메타데이터에서 보충한다. */
@Slf4j
@Component
public class SocialVideoMetadataResolver {
private static final Pattern DURATION_LINE = Pattern.compile("(?m)^([0-9]+(?:\\.[0-9]+)?)\\s*$");
@Value("${ytdlp.bin:yt-dlp}") private String ytdlpBin;
@Value("${hlab.social-feed.metadata-timeout-seconds:45}") private long timeoutSeconds;
@Value("${hlab.social-feed.cookies-file:}") private String cookiesFile;
public Integer resolveDuration(SocialVideoCandidate candidate) {
if (candidate.durationSec() != null && candidate.durationSec() > 0) return candidate.durationSec();
List<String> command = buildCommand(ytdlpBin, cookiesFile, candidate.url());
Process process = null;
try {
process = new ProcessBuilder(command).redirectErrorStream(true).start();
ByteArrayOutputStream output = new ByteArrayOutputStream();
Process running = process;
Thread drainer = Thread.ofVirtual().start(() -> {
try (var in = running.getInputStream()) { in.transferTo(output); }
catch (Exception ignored) { }
});
if (!process.waitFor(Math.max(1, timeoutSeconds), TimeUnit.SECONDS)) {
process.destroyForcibly();
log.warn("[SocialFeed] 영상 길이 조회 시간 초과: {}", candidate.url());
return null;
}
drainer.join(2_000);
String text = output.toString(StandardCharsets.UTF_8);
if (process.exitValue() != 0) {
log.warn("[SocialFeed] 영상 길이 조회 실패: url={}, output={}", candidate.url(), tail(text));
return null;
}
return parseDurationSeconds(text);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} catch (Exception e) {
log.warn("[SocialFeed] 영상 길이 조회 실행 실패: url={}, error={}", candidate.url(), e.getMessage());
return null;
} finally {
if (process != null && process.isAlive()) process.destroyForcibly();
}
}
static List<String> buildCommand(String bin, String cookiesFile, String url) {
List<String> command = new ArrayList<>(List.of(bin, "--skip-download", "--no-playlist",
"--no-warnings", "--print", "%(duration)s"));
if (cookiesFile != null && !cookiesFile.isBlank()) command.addAll(List.of("--cookies", cookiesFile.trim()));
command.add(url);
return command;
}
static Integer parseDurationSeconds(String output) {
Matcher matcher = DURATION_LINE.matcher(output == null ? "" : output);
if (!matcher.find()) return null;
double seconds = Double.parseDouble(matcher.group(1));
return seconds > 0 ? Math.max(1, (int) Math.round(seconds)) : null;
}
private static String tail(String output) {
String clean = output == null ? "" : output.strip();
return clean.length() <= 500 ? clean : clean.substring(clean.length() - 500);
}
}

View File

@ -7,9 +7,10 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface SocialVideoRepository extends JpaRepository<SocialVideo, Long> {
boolean existsByChannelIdAndExternalVideoId(Long channelId, String externalVideoId);
Optional<SocialVideo> findByChannelIdAndExternalVideoId(Long channelId, String externalVideoId);
@Query("select v from SocialVideo v join fetch v.channel c where c.topic = :topic "
+ "order by coalesce(v.publishedAt, v.collectedAt) desc")

View File

@ -80,7 +80,7 @@ public class YtDlpSocialFeedClient implements SocialFeedClient {
String title = firstText(entry, "title", "description");
videos.add(new SocialVideoCandidate(id, url,
title == null || title.isBlank() ? "(제목 없음)" : title,
thumbnail(entry), publishedAt(entry)));
thumbnail(entry), publishedAt(entry), durationSec(entry)));
if (videos.size() >= limit) break;
}
return new SocialFeedBatch(channelTitle, channelId, videos);
@ -114,6 +114,11 @@ public class YtDlpSocialFeedClient implements SocialFeedClient {
return null;
}
private static Integer durationSec(JsonNode entry) {
double seconds = entry.path("duration").asDouble(0);
return seconds > 0 ? Math.max(1, (int) Math.round(seconds)) : null;
}
private static String canonicalUrl(SocialPlatform platform, String id) {
return switch (platform) {
case YOUTUBE -> "https://www.youtube.com/watch?v=" + id;

View File

@ -9,33 +9,81 @@
<h1>유통 피드</h1>
<p class="sub">Facebook · Instagram · TikTok · YouTube 유통 채널의 새 영상을 모으고 텔레그램으로 알립니다.</p>
</div>
<button class="btn btn-primary" id="collectBtn" onclick="collectNow()">
<i data-lucide="refresh-cw" style="width:15px"></i> 지금 수집
</button>
<div class="actions">
<button class="btn btn-secondary" onclick="openChannels()">
<i data-lucide="list-plus" style="width:15px"></i> 채널 관리
</button>
<button class="btn btn-primary" id="collectBtn" onclick="collectNow()">
<i data-lucide="refresh-cw" style="width:15px"></i> 지금 수집
</button>
</div>
</div>
<section class="card social-source-card">
<div class="social-source-head">
<div><h2>수집 채널</h2><p>채널이나 프로필 주소를 붙여넣으면 플랫폼을 자동 판별합니다.</p></div>
</div>
<div class="social-add">
<label class="sr-only" for="channelUrl">채널 URL</label>
<input id="channelUrl" type="url" placeholder="https://www.facebook.com/channel/reels/"
onkeydown="if(event.key==='Enter') addChannel()">
<label class="social-notify"><input id="notifyEnabled" type="checkbox" checked> 텔레그램 알림</label>
<button class="btn btn-secondary" id="addBtn" onclick="addChannel()">채널 등록</button>
</div>
<p class="social-help" id="addHelp">개별 영상 주소가 아닌 채널·프로필 주소를 입력하세요.</p>
<div id="channelList" class="social-channel-list"></div>
</section>
<div class="social-section-title">
<h2>수집 영상</h2><span id="videoCount" class="badge badge-muted">—</span>
<div class="card social-toolbar">
<label for="platformFilter">플랫폼
<select id="platformFilter" onchange="renderVideos()">
<option value="">전체</option>
<option value="FACEBOOK">Facebook</option>
<option value="INSTAGRAM">Instagram</option>
<option value="TIKTOK">TikTok</option>
<option value="YOUTUBE">YouTube</option>
</select>
</label>
<span class="spacer"></span>
<span id="videoCount" class="badge badge-muted">—</span>
</div>
<div id="videoGrid" class="social-video-grid"></div>
<div id="emptyState" class="social-empty hidden">
<i data-lucide="inbox"></i><b>아직 수집된 영상이 없습니다.</b>
<span>처음 수집한 목록은 기준점으로 저장되고, 그 다음 새 영상부터 텔레그램으로 알립니다.</span>
<span>채널을 등록하고 수집하면 영상이 여기에 표시됩니다. 첫 목록 이후의 새 영상부터 텔레그램으로 알립니다.</span>
</div>
<div id="channelModal" class="modal-overlay" onclick="if(event.target===this) closeChannels()">
<div class="modal-card social-channel-modal" role="dialog" aria-modal="true" aria-labelledby="channelModalTitle">
<div class="modal-head">
<h3 id="channelModalTitle">수집 채널 관리</h3>
<button class="modal-close" id="channelModalClose" onclick="closeChannels()" aria-label="닫기">&times;</button>
</div>
<div class="modal-body social-source-card">
<div class="social-source-head">
<p>채널이나 프로필 주소를 붙여넣으면 플랫폼을 자동 판별합니다.</p>
</div>
<div class="social-add">
<label class="sr-only" for="channelUrl">채널 URL</label>
<input id="channelUrl" type="url" placeholder="https://www.facebook.com/channel/reels/"
onkeydown="if(event.key==='Enter') addChannel()">
<label class="social-notify"><input id="notifyEnabled" type="checkbox" checked> 텔레그램 알림</label>
<button class="btn btn-primary" id="addBtn" onclick="addChannel()">채널 등록</button>
</div>
<p class="social-help" id="addHelp">개별 영상 주소가 아닌 채널·프로필 주소를 입력하세요.</p>
<div id="channelList" class="social-channel-list"></div>
</div>
</div>
</div>
<div id="videoModal" class="modal-overlay" onclick="if(event.target===this) closeVideo()">
<div class="modal-card social-preview-modal" role="dialog" aria-modal="true" aria-labelledby="previewTitle">
<div class="modal-head">
<h3 id="previewTitle" class="social-preview-title">영상 미리보기</h3>
<button class="modal-close" id="previewClose" onclick="closeVideo()" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<div id="previewFrame" class="social-preview-frame"></div>
<p id="previewMeta" class="social-preview-meta"></p>
<div class="social-url-row">
<input id="previewUrl" type="text" readonly aria-label="영상 URL">
<button class="btn btn-primary" onclick="copyPreviewUrl()">
<i data-lucide="copy" style="width:14px"></i> URL 복사
</button>
</div>
<div class="social-preview-actions">
<a id="previewOriginal" class="btn btn-secondary" href="#" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:14px"></i> 원본 열기
</a>
</div>
</div>
</div>
</div>
<div id="toast" class="social-toast" role="status"></div>
</div>
@ -43,7 +91,8 @@
<th:block layout:fragment="styles">
<style>
.social-source-card{padding:1.1rem;margin-bottom:1.5rem}.social-source-head{display:flex;justify-content:space-between;gap:1rem;margin-bottom:.8rem}.social-source-head h2,.social-section-title h2{font-size:1rem;font-weight:700;margin:0}.social-source-head p{font-size:.8rem;color:var(--text-3);margin:.25rem 0 0}.social-add{display:flex;align-items:center;gap:.55rem;flex-wrap:wrap}.social-add input[type=url]{flex:1;min-width:280px}.social-notify{display:flex;align-items:center;gap:.4rem;font-size:.8rem;color:var(--text-2);white-space:nowrap}.social-notify input{width:16px;height:16px}.social-help{font-size:.72rem;color:var(--text-3);margin:.5rem 0 0}.social-channel-list{display:flex;flex-direction:column;gap:.45rem;margin-top:1rem}.social-channel{display:flex;align-items:center;gap:.65rem;border-top:1px solid var(--border);padding-top:.7rem}.social-platform{font-size:.65rem;font-weight:800;padding:.22rem .42rem;border-radius:99px;background:var(--inset);color:var(--text-2)}.social-platform.FACEBOOK{background:#1877f21a;color:#1877f2}.social-platform.INSTAGRAM{background:#c135841a;color:#c13584}.social-platform.TIKTOK{background:#1111;color:var(--text)}.social-platform.YOUTUBE{background:#f001;color:#e21}.social-channel-main{min-width:0;flex:1}.social-channel-main b{font-size:.84rem;display:block}.social-channel-main a,.social-channel-meta{font-size:.7rem;color:var(--text-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}.social-channel-error{font-size:.7rem;color:var(--danger);margin-top:.15rem}.social-delete{border:1px solid var(--border);background:transparent;color:var(--text-3);border-radius:8px;width:34px;height:34px;cursor:pointer}.social-delete:hover{color:var(--danger);border-color:var(--danger)}.social-section-title{display:flex;align-items:center;gap:.55rem;margin-bottom:.75rem}.social-video-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(235px,1fr));gap:1rem}.social-video{overflow:hidden}.social-thumb{display:block;aspect-ratio:16/10;background:var(--inset);overflow:hidden}.social-thumb img{width:100%;height:100%;object-fit:cover;transition:transform .2s}.social-thumb:hover img{transform:scale(1.025)}.social-no-thumb{height:100%;display:grid;place-items:center;color:var(--text-3)}.social-video-body{padding:.8rem}.social-video-body h3{font-size:.86rem;line-height:1.4;margin:0 0 .55rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.social-video-body h3 a{color:var(--text);text-decoration:none}.social-video-meta{display:flex;align-items:center;justify-content:space-between;gap:.5rem;font-size:.7rem;color:var(--text-3)}.social-empty{padding:4rem 1rem;text-align:center;color:var(--text-3);display:flex;flex-direction:column;align-items:center;gap:.55rem}.social-empty svg{width:36px;height:36px}.social-empty b{color:var(--text-2)}.hidden{display:none!important}.social-toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,12px);opacity:0;background:#18181b;color:#fff;padding:.65rem 1rem;border-radius:9px;font-size:.8rem;pointer-events:none;transition:.2s;z-index:50}.social-toast.show{opacity:1;transform:translate(-50%,0)}.social-toast.err{background:var(--danger)}
@media(max-width:680px){.social-add{align-items:stretch}.social-add input[type=url]{min-width:100%;width:100%}.social-add .btn{margin-left:auto}}
.social-toolbar{display:flex;align-items:center;gap:.75rem;padding:.8rem 1rem;margin-bottom:1rem}.social-toolbar label{display:flex;align-items:center;gap:.6rem;font-size:.78rem;color:var(--text-3)}.social-toolbar select{min-width:150px}.social-toolbar .spacer{flex:1}.social-channel-modal{max-width:760px}.social-source-card{margin:0}.social-source-head{margin-bottom:.75rem}.social-video-grid{grid-template-columns:repeat(auto-fill,minmax(268px,1fr));gap:1rem}.social-video{display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;transition:border-color .18s ease,box-shadow .18s ease}.social-video:hover{border-color:var(--border-strong);box-shadow:var(--shadow)}.social-thumb{position:relative;width:100%;padding:0;border:0;aspect-ratio:16/9;cursor:pointer}.social-thumb:focus-visible,.social-title:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.social-thumb img{display:block}.social-no-thumb{width:100%;font-size:1.5rem}.social-duration{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.78);color:#fff;font-size:.7rem;font-weight:600;font-family:var(--font-mono);padding:1px 5px;border-radius:4px;line-height:1.5}.social-platform-badge{position:absolute;left:6px;top:6px;background:rgba(0,0,0,.72);color:#fff;font-size:.65rem;font-weight:700;padding:2px 6px;border-radius:4px}.social-video-body{display:flex;flex-direction:column;gap:.35rem;flex:1;padding:.7rem .8rem .55rem}.social-video-meta{justify-content:flex-start;min-width:0}.social-channel-name{font-weight:600;color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.social-title{width:100%;padding:0;border:0;background:none;text-align:left;font:600 .87rem/1.4 inherit;color:var(--text);cursor:pointer;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.social-title:hover{text-decoration:underline}.social-video-actions{display:flex;align-items:center;gap:.4rem;padding:.5rem .8rem .7rem}.social-video-actions .btn{padding:.5rem .7rem;min-height:38px;font-size:.78rem}.social-video-actions .grow{flex:1}.social-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;flex-shrink:0;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--r-sm);color:var(--text-2);cursor:pointer}.social-icon-btn:hover{background:var(--surface-2);color:var(--text)}.social-preview-modal{max-width:900px}.social-preview-title{padding-right:.5rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.social-preview-frame{width:100%;aspect-ratio:16/9;background:#000;border-radius:var(--r-sm);overflow:hidden}.social-preview-frame.portrait{aspect-ratio:9/16;max-height:68vh;width:auto;margin:0 auto}.social-preview-frame iframe{display:block;width:100%;height:100%;border:0}.social-preview-meta{margin:.65rem 0;color:var(--text-3);font-size:.78rem}.social-url-row{display:flex;gap:.5rem;align-items:center;margin-top:.75rem}.social-url-row input{flex:1;min-width:0;font-size:.78rem}.social-preview-actions{display:flex;gap:.5rem;margin-top:.65rem}.social-preview-actions .btn{min-height:40px}.social-toast{z-index:2000}
@media(max-width:680px){.social-add{align-items:stretch}.social-add input[type=url]{min-width:100%;width:100%}.social-add .btn{margin-left:auto}.social-video-grid{grid-template-columns:1fr}.social-url-row{align-items:stretch;flex-direction:column}.social-url-row .btn{justify-content:center}}
</style>
</th:block>
@ -54,12 +103,74 @@
async function api(url,options){const r=await fetch(url,options);const j=await r.json().catch(()=>({}));if(!r.ok||j.success===false)throw new Error(j.message||`HTTP ${r.status}`);return j.data}
let toastTimer;function toast(message,error){const el=document.getElementById('toast');el.textContent=message;el.classList.toggle('err',!!error);el.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove('show'),3500)}
function ago(iso){if(!iso)return '수집 대기';const ms=Date.now()-new Date(iso).getTime(),min=Math.max(0,Math.floor(ms/60000));if(min<60)return `${min}분 전`;const h=Math.floor(min/60);if(h<24)return `${h}시간 전`;return `${Math.floor(h/24)}일 전`}
function date(iso){return iso?new Date(iso).toLocaleString('ko-KR',{month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}):'게시 시각 미상'}
function duration(sec){if(sec==null||Number(sec)<=0)return '';const total=Math.round(Number(sec)),h=Math.floor(total/3600),m=Math.floor(total%3600/60),s=total%60;return h?`${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`:`${m}:${String(s).padStart(2,'0')}`}
const platformName=p=>({FACEBOOK:'Facebook',INSTAGRAM:'Instagram',TIKTOK:'TikTok',YOUTUBE:'YouTube'}[p]||p);
let videos=[];
let lastFocused=null;
let currentPreviewUrl='';
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(){try{videos=await api(`${API}/videos?topic=DISTRIBUTION`)||[];renderVideos()}catch(e){toast('영상을 불러오지 못했습니다: '+e.message,true)}}
function renderVideos(){
const platform=document.getElementById('platformFilter').value;
const list=platform?videos.filter(v=>v.platform===platform):videos;
const grid=document.getElementById('videoGrid'),empty=document.getElementById('emptyState');
document.getElementById('videoCount').textContent=`${list.length}건`;
empty.classList.toggle('hidden',list.length>0);
grid.innerHTML=list.map(v=>{
const dur=duration(v.durationSec);
return `<article class="social-video">
<button class="social-thumb" type="button" onclick="openVideo(${v.id})" aria-label="${esc(v.title)} 미리보기">
${v.thumbnailUrl?`<img src="${esc(v.thumbnailUrl)}" alt="" loading="lazy" width="480" height="270">`:'<span class="social-no-thumb"><i data-lucide="play"></i></span>'}
<span class="social-platform-badge">${esc(platformName(v.platform))}</span>
${dur?`<span class="social-duration">${dur}</span>`:''}
</button>
<div class="social-video-body">
<div class="social-video-meta"><span class="social-channel-name">${esc(v.channelTitle)}</span><span>·</span><span>${ago(v.publishedAt||v.collectedAt)}</span></div>
<button class="social-title" type="button" onclick="openVideo(${v.id})">${esc(v.title)}</button>
</div>
<div class="social-video-actions">
<button class="btn btn-primary grow" type="button" onclick="openVideo(${v.id})"><i data-lucide="play" style="width:14px"></i> 미리보기</button>
<button class="social-icon-btn" type="button" title="영상 URL 복사" aria-label="영상 URL 복사" onclick="copyVideoUrl(${v.id})"><i data-lucide="copy" style="width:16px"></i></button>
</div>
</article>`;
}).join('');
if(window.lucide)lucide.createIcons();
}
function embedUrl(v){
if(v.platform==='YOUTUBE')return `https://www.youtube.com/embed/${encodeURIComponent(v.externalVideoId)}?autoplay=1&rel=0`;
if(v.platform==='TIKTOK')return `https://www.tiktok.com/player/v1/${encodeURIComponent(v.externalVideoId)}?autoplay=1&loop=0`;
if(v.platform==='INSTAGRAM')return v.url.replace(/\/?$/,'/')+'embed/';
return `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(v.url)}&show_text=false&autoplay=true&width=500`;
}
function openVideo(id){
const v=videos.find(item=>item.id===id);if(!v)return;
lastFocused=document.activeElement;currentPreviewUrl=v.url;
document.getElementById('previewTitle').textContent=v.title;
document.getElementById('previewUrl').value=v.url;
document.getElementById('previewOriginal').href=v.url;
const dur=duration(v.durationSec);
document.getElementById('previewMeta').textContent=`${v.channelTitle} · ${platformName(v.platform)} · ${ago(v.publishedAt||v.collectedAt)}${dur?' · '+dur:''}`;
const frame=document.getElementById('previewFrame');
frame.classList.toggle('portrait',v.platform!=='YOUTUBE');
frame.innerHTML=`<iframe src="${esc(embedUrl(v))}" title="${esc(v.title)}" allow="autoplay; encrypted-media; picture-in-picture; fullscreen" allowfullscreen loading="eager"></iframe>`;
document.getElementById('videoModal').classList.add('open');
document.body.style.overflow='hidden';
if(window.lucide)lucide.createIcons();
document.getElementById('previewClose').focus();
}
function closeVideo(){const modal=document.getElementById('videoModal');if(!modal.classList.contains('open'))return;modal.classList.remove('open');document.getElementById('previewFrame').innerHTML='';document.body.style.overflow='';currentPreviewUrl='';if(lastFocused&&lastFocused.focus)lastFocused.focus();lastFocused=null}
function openChannels(){lastFocused=document.activeElement;document.getElementById('channelModal').classList.add('open');document.body.style.overflow='hidden';document.getElementById('channelModalClose').focus()}
function closeChannels(){const modal=document.getElementById('channelModal');if(!modal.classList.contains('open'))return;modal.classList.remove('open');document.body.style.overflow='';if(lastFocused&&lastFocused.focus)lastFocused.focus();lastFocused=null}
async function copyText(value){try{await navigator.clipboard.writeText(value);return true}catch(e){const input=document.createElement('textarea');input.value=value;input.setAttribute('readonly','');input.style.position='fixed';input.style.opacity='0';document.body.appendChild(input);input.select();const copied=document.execCommand('copy');input.remove();return copied}}
async function copyVideoUrl(id){const v=videos.find(item=>item.id===id);if(!v)return;const copied=await copyText(v.url);toast(copied?'영상 URL을 복사했습니다':'URL 복사에 실패했습니다',!copied)}
async function copyPreviewUrl(){if(!currentPreviewUrl)return;const copied=await copyText(currentPreviewUrl);toast(copied?'영상 URL을 복사했습니다':'URL 복사에 실패했습니다',!copied)}
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()}}
document.addEventListener('keydown',event=>{if(event.key!=='Escape')return;if(document.getElementById('videoModal').classList.contains('open'))closeVideo();else closeChannels()});
Promise.all([loadChannels(),loadVideos()]);
</script>
</th:block>

View File

@ -13,13 +13,17 @@ class FacebookPageFeedClientTest {
String markup = """
<div data-fte="1" data-ftr="1">
<div class="_2_79">Phutu Crafts</div>
<a href="/reel/2234599224051571/"><abbr data-utime="1788448574">15시간 전</abbr></a>
<a href="/reel/2234599224051571/" aria-label="동영상, 2026년 9월 3일, 길이: 16초">
<abbr data-utime="1788448574">15시간 전</abbr>
</a>
<div data-testid="post_message"><p>How to Fix a Broken Outlet</p></div>
<img class="video" src="https://cdn.example/thumb.jpg">
<a href="https://www.facebook.com/reel/2234599224051571/">댓글</a>
</div>
<div data-fte="1" data-ftr="1">
<a href="/reel/2125612062168174/"><abbr data-utime="1787935132">6일</abbr></a>
<a href="/reel/2125612062168174/" aria-label="동영상, 길이: 2분, 58초">
<abbr data-utime="1787935132">6일</abbr>
</a>
<div data-testid="post_message">An Outdoor Light Hidden Inside a Tree</div>
</div>
""";
@ -32,7 +36,9 @@ class FacebookPageFeedClientTest {
assertThat(batch.videos().get(0).url()).isEqualTo("https://www.facebook.com/reel/2234599224051571");
assertThat(batch.videos().get(0).title()).isEqualTo("How to Fix a Broken Outlet");
assertThat(batch.videos().get(0).thumbnailUrl()).isEqualTo("https://cdn.example/thumb.jpg");
assertThat(batch.videos().get(0).durationSec()).isEqualTo(16);
assertThat(batch.videos().get(0).publishedAt()).isNotNull();
assertThat(batch.videos().get(1).durationSec()).isEqualTo(178);
}
@Test

View File

@ -17,6 +17,7 @@ class SocialFeedServiceTest {
SocialVideoRepository videos = mock(SocialVideoRepository.class);
SocialFeedClient client = mock(SocialFeedClient.class);
SocialFeedAlertService alerts = mock(SocialFeedAlertService.class);
SocialVideoMetadataResolver metadata = mock(SocialVideoMetadataResolver.class);
SocialChannel channel = SocialChannel.register("https://facebook.com/phutunet/reels/", "DISTRIBUTION", true);
SocialVideoCandidate existing = candidate("old");
SocialVideoCandidate newlyPublished = candidate("new");
@ -26,10 +27,12 @@ class SocialFeedServiceTest {
when(client.fetch(channel)).thenReturn(
new SocialFeedBatch("Phutu Crafts", null, List.of(existing)),
new SocialFeedBatch("Phutu Crafts", null, List.of(newlyPublished)));
when(videos.existsByChannelIdAndExternalVideoId(eq(1L), anyString())).thenReturn(false);
when(videos.findByChannelIdAndExternalVideoId(eq(1L), anyString()))
.thenReturn(java.util.Optional.empty());
when(metadata.resolveDuration(any())).thenReturn(17);
when(videos.save(any(SocialVideo.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(alerts.notifyNewVideos(eq(channel), anyList())).thenReturn(1);
SocialFeedService service = new SocialFeedService(channels, videos, List.of(client), alerts);
SocialFeedService service = new SocialFeedService(channels, videos, List.of(client), alerts, metadata);
SocialFeedService.CollectionResult baseline = service.collectOne(1L);
SocialFeedService.CollectionResult next = service.collectOne(1L);
@ -41,8 +44,33 @@ class SocialFeedServiceTest {
verify(alerts, times(1)).notifyNewVideos(eq(channel), anyList());
}
@Test
void backfillsMissingDurationOnPreviouslyCollectedVideo() {
SocialChannelRepository channels = mock(SocialChannelRepository.class);
SocialVideoRepository videos = mock(SocialVideoRepository.class);
SocialFeedClient client = mock(SocialFeedClient.class);
SocialFeedAlertService alerts = mock(SocialFeedAlertService.class);
SocialVideoMetadataResolver metadata = mock(SocialVideoMetadataResolver.class);
SocialChannel channel = SocialChannel.register("https://facebook.com/phutunet/reels/", "DISTRIBUTION", true);
SocialVideoCandidate candidate = candidate("existing");
SocialVideo stored = SocialVideo.from(channel, candidate);
when(channels.findById(1L)).thenReturn(java.util.Optional.of(channel));
when(client.supports(SocialPlatform.FACEBOOK)).thenReturn(true);
when(client.fetch(channel)).thenReturn(new SocialFeedBatch("Phutu Crafts", null, List.of(candidate)));
when(videos.findByChannelIdAndExternalVideoId(1L, "existing"))
.thenReturn(java.util.Optional.of(stored));
when(metadata.resolveDuration(candidate)).thenReturn(17);
SocialFeedService service = new SocialFeedService(channels, videos, List.of(client), alerts, metadata);
service.collectOne(1L);
assertThat(stored.getDurationSec()).isEqualTo(17);
verify(videos).save(stored);
}
private static SocialVideoCandidate candidate(String id) {
return new SocialVideoCandidate(id, "https://facebook.com/reel/" + id, id, null,
LocalDateTime.now());
LocalDateTime.now(), null);
}
}

View File

@ -0,0 +1,21 @@
package com.hlab.yanalyst.domain.social;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SocialVideoMetadataResolverTest {
@Test
void parsesAndRoundsYtDlpDuration() {
assertThat(SocialVideoMetadataResolver.parseDurationSeconds("16.81\n")).isEqualTo(17);
assertThat(SocialVideoMetadataResolver.parseDurationSeconds("NA\n")).isNull();
}
@Test
void buildsArgumentSafeMetadataCommand() {
assertThat(SocialVideoMetadataResolver.buildCommand("yt-dlp", "", "https://facebook.com/reel/123"))
.containsExactly("yt-dlp", "--skip-download", "--no-playlist", "--no-warnings",
"--print", "%(duration)s", "https://facebook.com/reel/123");
}
}

View File

@ -12,6 +12,7 @@ class YtDlpSocialFeedClientTest {
String json = """
{"channel":"유통 채널","channel_id":"UC-shop","entries":[
{"id":"abcdefghijk","title":"새 상품 영상","url":"abcdefghijk","timestamp":1788448574,
"duration":42.4,
"thumbnails":[{"url":"https://cdn.example/small.jpg"},{"url":"https://cdn.example/large.jpg"}]}
]}
""";
@ -24,6 +25,7 @@ class YtDlpSocialFeedClientTest {
assertThat(batch.videos()).singleElement().satisfies(video -> {
assertThat(video.url()).isEqualTo("https://www.youtube.com/watch?v=abcdefghijk");
assertThat(video.thumbnailUrl()).isEqualTo("https://cdn.example/large.jpg");
assertThat(video.durationSec()).isEqualTo(42);
assertThat(video.publishedAt()).isNotNull();
});
}