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( private static final Pattern READER_REEL = Pattern.compile(
"\\[!\\[[^]]*]\\((https?://[^)]+)\\)[^]]*]\\(https?://(?:www\\.)?facebook\\.com/reel/(\\d+)[^)]*\\)"); "\\[!\\[[^]]*]\\((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 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가 데이터센터별로 빈 피드를 돌려주는 경우가 있어 공식 로케일 호스트를 순서대로 시도한다. */ /** 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");
@ -188,8 +191,9 @@ public class FacebookPageFeedClient implements SocialFeedClient {
Element timeElement = post.selectFirst("abbr[data-utime]"); Element timeElement = post.selectFirst("abbr[data-utime]");
LocalDateTime publishedAt = parseUnixTime(timeElement == null ? null : timeElement.attr("data-utime")); LocalDateTime publishedAt = parseUnixTime(timeElement == null ? null : timeElement.attr("data-utime"));
String thumbnail = findThumbnail(post); String thumbnail = findThumbnail(post);
Integer durationSec = findDuration(post);
unique.put(id, new SocialVideoCandidate(id, "https://www.facebook.com/reel/" + id, 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) { if (channelTitle == null) {
Element titleNode = post.selectFirst("._2_79, a[title]"); Element titleNode = post.selectFirst("._2_79, a[title]");
@ -214,7 +218,7 @@ public class FacebookPageFeedClient implements SocialFeedClient {
String id = reelMatcher.group(2); String id = reelMatcher.group(2);
String title = (channelTitle == null ? "Facebook" : channelTitle) + " · Reel " + id; String title = (channelTitle == null ? "Facebook" : channelTitle) + " · Reel " + id;
unique.putIfAbsent(id, new SocialVideoCandidate(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())); return new SocialFeedBatch(channelTitle, null, new ArrayList<>(unique.values()));
} }
@ -228,6 +232,26 @@ public class FacebookPageFeedClient implements SocialFeedClient {
return null; 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) { private static LocalDateTime parseUnixTime(String value) {
if (value == null || value.isBlank()) return null; if (value == null || value.isBlank()) return null;
try { try {

View File

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

View File

@ -42,6 +42,9 @@ public class SocialVideo {
@Column(name = "published_at") @Column(name = "published_at")
private LocalDateTime publishedAt; private LocalDateTime publishedAt;
@Column(name = "duration_sec")
private Integer durationSec;
@CreatedDate @CreatedDate
@Column(name = "collected_at", updatable = false) @Column(name = "collected_at", updatable = false)
private LocalDateTime collectedAt; private LocalDateTime collectedAt;
@ -54,6 +57,11 @@ public class SocialVideo {
video.title = candidate.title() == null || candidate.title().isBlank() ? "(제목 없음)" : candidate.title(); video.title = candidate.title() == null || candidate.title().isBlank() ? "(제목 없음)" : candidate.title();
video.thumbnailUrl = candidate.thumbnailUrl(); video.thumbnailUrl = candidate.thumbnailUrl();
video.publishedAt = candidate.publishedAt(); video.publishedAt = candidate.publishedAt();
video.durationSec = candidate.durationSec();
return video; 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 url,
String title, String title,
String thumbnailUrl, 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 org.springframework.data.repository.query.Param;
import java.util.List; import java.util.List;
import java.util.Optional;
public interface SocialVideoRepository extends JpaRepository<SocialVideo, Long> { 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 " @Query("select v from SocialVideo v join fetch v.channel c where c.topic = :topic "
+ "order by coalesce(v.publishedAt, v.collectedAt) desc") + "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"); String title = firstText(entry, "title", "description");
videos.add(new SocialVideoCandidate(id, url, videos.add(new SocialVideoCandidate(id, url,
title == null || title.isBlank() ? "(제목 없음)" : title, title == null || title.isBlank() ? "(제목 없음)" : title,
thumbnail(entry), publishedAt(entry))); thumbnail(entry), publishedAt(entry), durationSec(entry)));
if (videos.size() >= limit) break; if (videos.size() >= limit) break;
} }
return new SocialFeedBatch(channelTitle, channelId, videos); return new SocialFeedBatch(channelTitle, channelId, videos);
@ -114,6 +114,11 @@ public class YtDlpSocialFeedClient implements SocialFeedClient {
return null; 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) { private static String canonicalUrl(SocialPlatform platform, String id) {
return switch (platform) { return switch (platform) {
case YOUTUBE -> "https://www.youtube.com/watch?v=" + id; case YOUTUBE -> "https://www.youtube.com/watch?v=" + id;

View File

@ -9,33 +9,81 @@
<h1>유통 피드</h1> <h1>유통 피드</h1>
<p class="sub">Facebook · Instagram · TikTok · YouTube 유통 채널의 새 영상을 모으고 텔레그램으로 알립니다.</p> <p class="sub">Facebook · Instagram · TikTok · YouTube 유통 채널의 새 영상을 모으고 텔레그램으로 알립니다.</p>
</div> </div>
<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()"> <button class="btn btn-primary" id="collectBtn" onclick="collectNow()">
<i data-lucide="refresh-cw" style="width:15px"></i> 지금 수집 <i data-lucide="refresh-cw" style="width:15px"></i> 지금 수집
</button> </button>
</div> </div>
</div>
<section class="card social-source-card"> <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>
</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"> <div class="social-source-head">
<div><h2>수집 채널</h2><p>채널이나 프로필 주소를 붙여넣으면 플랫폼을 자동 판별합니다.</p></div> <p>채널이나 프로필 주소를 붙여넣으면 플랫폼을 자동 판별합니다.</p>
</div> </div>
<div class="social-add"> <div class="social-add">
<label class="sr-only" for="channelUrl">채널 URL</label> <label class="sr-only" for="channelUrl">채널 URL</label>
<input id="channelUrl" type="url" placeholder="https://www.facebook.com/channel/reels/" <input id="channelUrl" type="url" placeholder="https://www.facebook.com/channel/reels/"
onkeydown="if(event.key==='Enter') addChannel()"> onkeydown="if(event.key==='Enter') addChannel()">
<label class="social-notify"><input id="notifyEnabled" type="checkbox" checked> 텔레그램 알림</label> <label class="social-notify"><input id="notifyEnabled" type="checkbox" checked> 텔레그램 알림</label>
<button class="btn btn-secondary" id="addBtn" onclick="addChannel()">채널 등록</button> <button class="btn btn-primary" id="addBtn" onclick="addChannel()">채널 등록</button>
</div> </div>
<p class="social-help" id="addHelp">개별 영상 주소가 아닌 채널·프로필 주소를 입력하세요.</p> <p class="social-help" id="addHelp">개별 영상 주소가 아닌 채널·프로필 주소를 입력하세요.</p>
<div id="channelList" class="social-channel-list"></div> <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> </div>
<div id="videoGrid" class="social-video-grid"></div> </div>
<div id="emptyState" class="social-empty hidden"> </div>
<i data-lucide="inbox"></i><b>아직 수집된 영상이 없습니다.</b>
<span>처음 수집한 목록은 기준점으로 저장되고, 그 다음 새 영상부터 텔레그램으로 알립니다.</span> <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>
<div id="toast" class="social-toast" role="status"></div> <div id="toast" class="social-toast" role="status"></div>
</div> </div>
@ -43,7 +91,8 @@
<th:block layout:fragment="styles"> <th:block layout:fragment="styles">
<style> <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)} .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> </style>
</th:block> </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} 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)} 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 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 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 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()}}
document.addEventListener('keydown',event=>{if(event.key!=='Escape')return;if(document.getElementById('videoModal').classList.contains('open'))closeVideo();else closeChannels()});
Promise.all([loadChannels(),loadVideos()]); Promise.all([loadChannels(),loadVideos()]);
</script> </script>
</th:block> </th:block>

View File

@ -13,13 +13,17 @@ class FacebookPageFeedClientTest {
String markup = """ String markup = """
<div data-fte="1" data-ftr="1"> <div data-fte="1" data-ftr="1">
<div class="_2_79">Phutu Crafts</div> <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> <div data-testid="post_message"><p>How to Fix a Broken Outlet</p></div>
<img class="video" src="https://cdn.example/thumb.jpg"> <img class="video" src="https://cdn.example/thumb.jpg">
<a href="https://www.facebook.com/reel/2234599224051571/">댓글</a> <a href="https://www.facebook.com/reel/2234599224051571/">댓글</a>
</div> </div>
<div data-fte="1" data-ftr="1"> <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 data-testid="post_message">An Outdoor Light Hidden Inside a Tree</div>
</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).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).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).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(0).publishedAt()).isNotNull();
assertThat(batch.videos().get(1).durationSec()).isEqualTo(178);
} }
@Test @Test

View File

@ -17,6 +17,7 @@ class SocialFeedServiceTest {
SocialVideoRepository videos = mock(SocialVideoRepository.class); SocialVideoRepository videos = mock(SocialVideoRepository.class);
SocialFeedClient client = mock(SocialFeedClient.class); SocialFeedClient client = mock(SocialFeedClient.class);
SocialFeedAlertService alerts = mock(SocialFeedAlertService.class); SocialFeedAlertService alerts = mock(SocialFeedAlertService.class);
SocialVideoMetadataResolver metadata = mock(SocialVideoMetadataResolver.class);
SocialChannel channel = SocialChannel.register("https://facebook.com/phutunet/reels/", "DISTRIBUTION", true); SocialChannel channel = SocialChannel.register("https://facebook.com/phutunet/reels/", "DISTRIBUTION", true);
SocialVideoCandidate existing = candidate("old"); SocialVideoCandidate existing = candidate("old");
SocialVideoCandidate newlyPublished = candidate("new"); SocialVideoCandidate newlyPublished = candidate("new");
@ -26,10 +27,12 @@ class SocialFeedServiceTest {
when(client.fetch(channel)).thenReturn( when(client.fetch(channel)).thenReturn(
new SocialFeedBatch("Phutu Crafts", null, List.of(existing)), new SocialFeedBatch("Phutu Crafts", null, List.of(existing)),
new SocialFeedBatch("Phutu Crafts", null, List.of(newlyPublished))); 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(videos.save(any(SocialVideo.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(alerts.notifyNewVideos(eq(channel), anyList())).thenReturn(1); 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 baseline = service.collectOne(1L);
SocialFeedService.CollectionResult next = service.collectOne(1L); SocialFeedService.CollectionResult next = service.collectOne(1L);
@ -41,8 +44,33 @@ class SocialFeedServiceTest {
verify(alerts, times(1)).notifyNewVideos(eq(channel), anyList()); 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) { private static SocialVideoCandidate candidate(String id) {
return new SocialVideoCandidate(id, "https://facebook.com/reel/" + id, id, null, 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 = """ String json = """
{"channel":"유통 채널","channel_id":"UC-shop","entries":[ {"channel":"유통 채널","channel_id":"UC-shop","entries":[
{"id":"abcdefghijk","title":"새 상품 영상","url":"abcdefghijk","timestamp":1788448574, {"id":"abcdefghijk","title":"새 상품 영상","url":"abcdefghijk","timestamp":1788448574,
"duration":42.4,
"thumbnails":[{"url":"https://cdn.example/small.jpg"},{"url":"https://cdn.example/large.jpg"}]} "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(batch.videos()).singleElement().satisfies(video -> {
assertThat(video.url()).isEqualTo("https://www.youtube.com/watch?v=abcdefghijk"); assertThat(video.url()).isEqualTo("https://www.youtube.com/watch?v=abcdefghijk");
assertThat(video.thumbnailUrl()).isEqualTo("https://cdn.example/large.jpg"); assertThat(video.thumbnailUrl()).isEqualTo("https://cdn.example/large.jpg");
assertThat(video.durationSec()).isEqualTo(42);
assertThat(video.publishedAt()).isNotNull(); assertThat(video.publishedAt()).isNotNull();
}); });
} }