feat: 유통 피드 미리보기와 영상 길이 표시
This commit is contained in:
parent
55f56fb390
commit
d70bb142c0
@ -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 {
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -9,33 +9,81 @@
|
||||
<h1>유통 피드</h1>
|
||||
<p class="sub">Facebook · Instagram · TikTok · YouTube 유통 채널의 새 영상을 모으고 텔레그램으로 알립니다.</p>
|
||||
</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()">
|
||||
<i data-lucide="refresh-cw" style="width:15px"></i> 지금 수집
|
||||
</button>
|
||||
</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="닫기">×</button>
|
||||
</div>
|
||||
<div class="modal-body social-source-card">
|
||||
<div class="social-source-head">
|
||||
<div><h2>수집 채널</h2><p>채널이나 프로필 주소를 붙여넣으면 플랫폼을 자동 판별합니다.</p></div>
|
||||
<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-secondary" id="addBtn" onclick="addChannel()">채널 등록</button>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<div class="social-section-title">
|
||||
<h2>수집 영상</h2><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>
|
||||
|
||||
<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="닫기">×</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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user