feat: 유통 소셜 피드와 텔레그램 알림 추가

This commit is contained in:
hehihoho3@gmail.com 2026-09-04 16:12:01 +09:00
parent ea08b6d6b7
commit d25093dd44
28 changed files with 1191 additions and 1 deletions

View File

@ -34,6 +34,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
implementation 'org.jsoup:jsoup:1.23.2'
// Swagger (SpringDoc) — 2.7.0+ is required for Spring Boot 3.4 (2.3.0 throws NoSuchMethodError on /v3/api-docs) // Swagger (SpringDoc) — 2.7.0+ is required for Spring Boot 3.4 (2.3.0 throws NoSuchMethodError on /v3/api-docs)
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'

View File

@ -37,6 +37,8 @@ public class FeedAlertService {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
List<ChannelVideo> golden = newVideos.stream() List<ChannelVideo> golden = newVideos.stream()
// 정치 피드는 수집·화면 표시만 유지하고 텔레그램에서는 제외한다.
.filter(v -> v.feedTopicOrDefault() != FeedTopic.POLITICS)
.filter(v -> FeedBadges.isGoldenTime(v.getPublishedAt(), now)) .filter(v -> FeedBadges.isGoldenTime(v.getPublishedAt(), now))
.sorted(Comparator.comparing(ChannelVideo::getPublishedAt).reversed()) .sorted(Comparator.comparing(ChannelVideo::getPublishedAt).reversed())
.toList(); .toList();

View File

@ -2,7 +2,8 @@ package com.hlab.yanalyst.domain.channel;
public enum FeedTopic { public enum FeedTopic {
ENTERTAINMENT, ENTERTAINMENT,
POLITICS; POLITICS,
DISTRIBUTION;
public static FeedTopic normalize(String value) { public static FeedTopic normalize(String value) {
if (value == null || value.isBlank()) return ENTERTAINMENT; if (value == null || value.isBlank()) return ENTERTAINMENT;

View File

@ -70,6 +70,7 @@ public class ShortformSubtitleService {
} }
private void notifyReady(ShortformJob job) { private void notifyReady(ShortformJob job) {
if (job.categoryOrDefault() == ShortformCategory.POLITICS) return;
telegramNotifier.sendMessage("✅ <b>숏폼 자막 생성 완료</b>\n" telegramNotifier.sendMessage("✅ <b>숏폼 자막 생성 완료</b>\n"
+ "분류: " + categoryLabel(job) + "\n" + "분류: " + categoryLabel(job) + "\n"
+ "영상: <code>" + job.getVideoId() + "</code>\n" + "영상: <code>" + job.getVideoId() + "</code>\n"
@ -78,6 +79,7 @@ public class ShortformSubtitleService {
} }
private void notifyFailure(ShortformJob job, String reason) { private void notifyFailure(ShortformJob job, String reason) {
if (job.categoryOrDefault() == ShortformCategory.POLITICS) return;
telegramNotifier.sendMessage("❌ <b>숏폼 자막 생성 실패</b>\n" telegramNotifier.sendMessage("❌ <b>숏폼 자막 생성 실패</b>\n"
+ "분류: " + categoryLabel(job) + "\n" + "분류: " + categoryLabel(job) + "\n"
+ "영상: <code>" + job.getVideoId() + "</code>\n" + "영상: <code>" + job.getVideoId() + "</code>\n"

View File

@ -0,0 +1,151 @@
package com.hlab.yanalyst.domain.social;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Facebook Page Plugin의 공개 타임라인에서 릴 목록을 읽는다. 로그인·Graph API 토큰이 필요 없다. */
@Component
@RequiredArgsConstructor
public class FacebookPageFeedClient implements SocialFeedClient {
private static final String PAGE_PLUGIN_APP_ID = "776730922422337";
private static final Pattern REEL_ID = Pattern.compile("/(?:reel|videos)/(\\d+)");
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@Value("${hlab.social-feed.max-items:25}")
private int maxItems;
@Override
public boolean supports(SocialPlatform platform) {
return platform == SocialPlatform.FACEBOOK;
}
@Override
public SocialFeedBatch fetch(SocialChannel channel) {
try {
Map<String, Object> config = new LinkedHashMap<>();
config.put("app_id", PAGE_PLUGIN_APP_ID);
config.put("href", channel.getSourceUrl());
config.put("width", 500);
config.put("height", 800);
config.put("has_cta", true);
config.put("has_small_header", false);
config.put("has_adapt_container_width", true);
config.put("has_cover", true);
config.put("has_posts", false);
config.put("tabs", "timeline");
config.put("can_personalize", false);
config.put("is_xfbml", false);
config.put("referer_uri", "");
URI uri = UriComponentsBuilder.fromHttpUrl("https://www.facebook.com/platform/plugin/tab/renderer/")
.queryParam("key", "timeline")
.queryParam("config_json", objectMapper.writeValueAsString(config))
.queryParam("__a", "1")
.build().encode().toUri();
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
headers.set(HttpHeaders.ACCEPT_LANGUAGE, "ko-KR,ko;q=0.9,en;q=0.8");
headers.set("X-Requested-With", "XMLHttpRequest");
ResponseEntity<String> response = restTemplate.exchange(uri, HttpMethod.GET,
new HttpEntity<>(headers), String.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new IllegalStateException("Facebook 공개 피드 응답이 비어 있습니다.");
}
return parseResponse(response.getBody(), Math.max(1, maxItems));
} catch (Exception e) {
if (e instanceof IllegalStateException state) throw state;
throw new IllegalStateException("Facebook 공개 피드를 읽지 못했습니다: " + friendly(e), e);
}
}
SocialFeedBatch parseResponse(String body, int limit) {
try {
String json = body == null ? "" : body.strip();
if (json.startsWith("for (;;);")) json = json.substring("for (;;);".length());
JsonNode root = objectMapper.readTree(json);
String markup = root.path("payload").path("content").path("markup").path("__html").asText("");
if (markup.isBlank()) throw new IllegalStateException("Facebook 공개 피드에 표시할 게시물이 없습니다.");
return parseMarkup(markup, limit);
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException("Facebook 공개 피드 형식이 변경되었습니다.", e);
}
}
static SocialFeedBatch parseMarkup(String markup, int limit) {
Document document = Jsoup.parse(markup, "https://www.facebook.com");
LinkedHashMap<String, SocialVideoCandidate> unique = new LinkedHashMap<>();
String channelTitle = null;
for (Element post : document.select("div[data-fte=1][data-ftr=1]")) {
Element reelLink = post.selectFirst("a[href*=/reel/], a[href*=/videos/]");
if (reelLink == null) continue;
Matcher matcher = REEL_ID.matcher(reelLink.attr("href"));
if (!matcher.find()) continue;
String id = matcher.group(1);
if (unique.containsKey(id)) continue;
Element titleElement = post.selectFirst("[data-testid=post_message]");
String title = titleElement == null ? "(제목 없음)" : titleElement.text().strip();
Element timeElement = post.selectFirst("abbr[data-utime]");
LocalDateTime publishedAt = parseUnixTime(timeElement == null ? null : timeElement.attr("data-utime"));
String thumbnail = findThumbnail(post);
unique.put(id, new SocialVideoCandidate(id, "https://www.facebook.com/reel/" + id,
title.isBlank() ? "(제목 없음)" : title, thumbnail, publishedAt));
if (channelTitle == null) {
Element titleNode = post.selectFirst("._2_79, a[title]");
if (titleNode != null && !titleNode.text().isBlank()) channelTitle = titleNode.text().strip();
if ((channelTitle == null || channelTitle.isBlank()) && titleNode != null) {
channelTitle = titleNode.attr("title").strip();
}
}
if (unique.size() >= limit) break;
}
return new SocialFeedBatch(channelTitle, null, new ArrayList<>(unique.values()));
}
private static String findThumbnail(Element post) {
for (Element image : post.select("img[src]")) {
String src = image.absUrl("src");
String classes = image.className();
if (!src.isBlank() && !src.contains("s50x50") && !classes.contains("_s0")) return src;
}
return null;
}
private static LocalDateTime parseUnixTime(String value) {
if (value == null || value.isBlank()) return null;
try {
return LocalDateTime.ofInstant(Instant.ofEpochSecond(Long.parseLong(value)), ZoneId.systemDefault());
} catch (NumberFormatException ignored) {
return null;
}
}
private static String friendly(Exception e) {
String message = e.getMessage();
if (message == null || message.isBlank()) return e.getClass().getSimpleName();
return message.length() <= 500 ? message : message.substring(0, 500);
}
}

View File

@ -0,0 +1,97 @@
package com.hlab.yanalyst.domain.social;
import com.hlab.yanalyst.domain.channel.FeedTopic;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Entity
@Table(name = "social_channels", uniqueConstraints =
@UniqueConstraint(name = "uk_social_channel_source_url", columnNames = "source_url"))
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class SocialChannel {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private SocialPlatform platform;
@Column(name = "source_url", nullable = false, length = 2000)
private String sourceUrl;
@Column(name = "external_channel_id", length = 200)
private String externalChannelId;
@Column(nullable = false, length = 300)
private String title;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private FeedTopic topic;
@Column(name = "notification_enabled", nullable = false)
private boolean notificationEnabled;
@Column(name = "last_collected_at")
private LocalDateTime lastCollectedAt;
@Column(name = "last_found", nullable = false)
private int lastFound;
@Column(name = "fail_count", nullable = false)
private int failCount;
@Column(name = "last_error", columnDefinition = "TEXT")
private String lastError;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
public static SocialChannel register(String url, String topic, boolean notificationEnabled) {
String normalizedUrl = SocialPlatform.normalizeChannelUrl(url);
SocialChannel channel = new SocialChannel();
channel.platform = SocialPlatform.fromUrl(normalizedUrl);
channel.sourceUrl = normalizedUrl;
channel.title = SocialPlatform.displayNameFromUrl(normalizedUrl);
channel.topic = FeedTopic.normalize(topic);
channel.notificationEnabled = notificationEnabled;
return channel;
}
public void updateMetadata(String title, String externalChannelId) {
if (title != null && !title.isBlank()) this.title = title.strip();
if (externalChannelId != null && !externalChannelId.isBlank()) this.externalChannelId = externalChannelId.strip();
}
public void configure(String topic, boolean notificationEnabled) {
this.topic = FeedTopic.normalize(topic);
this.notificationEnabled = notificationEnabled;
}
public void collectionSucceeded(int found) {
this.lastCollectedAt = LocalDateTime.now();
this.lastFound = Math.max(0, found);
this.failCount = 0;
this.lastError = null;
}
public void collectionFailed(String message) {
this.failCount++;
this.lastError = message == null || message.isBlank() ? "수집에 실패했습니다." : message;
}
}

View File

@ -0,0 +1,12 @@
package com.hlab.yanalyst.domain.social;
import com.hlab.yanalyst.domain.channel.FeedTopic;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface SocialChannelRepository extends JpaRepository<SocialChannel, Long> {
Optional<SocialChannel> findBySourceUrl(String sourceUrl);
List<SocialChannel> findAllByTopicOrderByIdAsc(FeedTopic topic);
}

View File

@ -0,0 +1,62 @@
package com.hlab.yanalyst.domain.social;
import com.hlab.yanalyst.domain.channel.FeedTopic;
import com.hlab.yanalyst.global.notify.TelegramNotifier;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class SocialFeedAlertService {
private static final int MAX_LINES = 8;
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("M/d HH:mm");
private final TelegramNotifier telegramNotifier;
public int notifyNewVideos(SocialChannel channel, List<SocialVideo> newVideos) {
if (channel == null || channel.getTopic() == FeedTopic.POLITICS || !channel.isNotificationEnabled()
|| newVideos == null || newVideos.isEmpty() || !telegramNotifier.isEnabled()) return 0;
List<SocialVideo> sorted = newVideos.stream()
.sorted(Comparator.comparing(SocialVideo::getPublishedAt,
Comparator.nullsLast(Comparator.reverseOrder())))
.toList();
String topicEmoji = channel.getTopic() == FeedTopic.DISTRIBUTION ? "📦" : "🎬";
String topicName = channel.getTopic() == FeedTopic.DISTRIBUTION ? "유통" : "예능";
StringBuilder message = new StringBuilder(topicEmoji).append(" <b>").append(topicName)
.append(" 새 영상 · ").append(esc(channel.getTitle())).append(" ")
.append(sorted.size()).append("건</b>");
int shown = Math.min(sorted.size(), MAX_LINES);
for (int i = 0; i < shown; i++) {
SocialVideo video = sorted.get(i);
message.append("\n\n• <a href=\"").append(esc(video.getUrl())).append("\">")
.append(esc(clip(video.getTitle()))).append("</a>");
if (video.getPublishedAt() != null) message.append(" · ").append(TIME.format(video.getPublishedAt()));
}
if (sorted.size() > shown) message.append("\n\n…외 ").append(sorted.size() - shown).append("건");
message.append("\n\nhttps://h-lab.tolag.shop/distribution-feed");
boolean sent = telegramNotifier.sendMessage(message.toString());
log.info("[SocialFeed] 텔레그램 알림: channel={}, sent={}, count={}", channel.getId(), sent, sorted.size());
return sent ? sorted.size() : 0;
}
private static String clip(String text) {
if (text == null || text.isBlank()) return "(제목 없음)";
return text.length() <= 90 ? text : text.substring(0, 89) + "…";
}
private static String esc(String text) {
if (text == null) return "-";
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;");
}
}

View File

@ -0,0 +1,10 @@
package com.hlab.yanalyst.domain.social;
import java.util.List;
public record SocialFeedBatch(String channelTitle, String externalChannelId,
List<SocialVideoCandidate> videos) {
public SocialFeedBatch {
videos = videos == null ? List.of() : List.copyOf(videos);
}
}

View File

@ -0,0 +1,6 @@
package com.hlab.yanalyst.domain.social;
public interface SocialFeedClient {
boolean supports(SocialPlatform platform);
SocialFeedBatch fetch(SocialChannel channel);
}

View File

@ -0,0 +1,52 @@
package com.hlab.yanalyst.domain.social;
import com.hlab.yanalyst.domain.channel.FeedTopic;
import com.hlab.yanalyst.global.common.ApiResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/social-feed")
@RequiredArgsConstructor
public class SocialFeedController {
private final SocialFeedService service;
public record AddChannelRequest(@NotBlank(message = "채널 URL을 입력하세요.") String url,
String topic, Boolean notificationEnabled) { }
@GetMapping("/channels")
public ApiResponse<List<SocialFeedService.ChannelDto>> channels(
@RequestParam(defaultValue = "DISTRIBUTION") String topic) {
return ApiResponse.ok(service.channels(topic));
}
@PostMapping("/channels")
public ApiResponse<SocialFeedService.ChannelDto> addChannel(@Valid @RequestBody AddChannelRequest request) {
String topic = request.topic() == null ? FeedTopic.DISTRIBUTION.name() : request.topic();
return ApiResponse.created(service.addChannel(request.url(), topic,
request.notificationEnabled() == null || request.notificationEnabled()));
}
@DeleteMapping("/channels/{id}")
public ApiResponse<Void> removeChannel(@PathVariable Long id) {
service.removeChannel(id);
return ApiResponse.ok(null);
}
@GetMapping("/videos")
public ApiResponse<List<SocialFeedService.VideoDto>> videos(
@RequestParam(defaultValue = "DISTRIBUTION") String topic) {
return ApiResponse.ok(service.videos(topic));
}
@PostMapping("/collect")
public ApiResponse<Map<String, Object>> collect(
@RequestParam(defaultValue = "DISTRIBUTION") String topic) {
return ApiResponse.ok(service.collect(topic));
}
}

View File

@ -0,0 +1,179 @@
package com.hlab.yanalyst.domain.social;
import com.hlab.yanalyst.domain.channel.FeedTopic;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
@Slf4j
@Service
@RequiredArgsConstructor
public class SocialFeedService {
private static final int MAX_FEED_ITEMS = 200;
private final SocialChannelRepository channelRepository;
private final SocialVideoRepository videoRepository;
private final List<SocialFeedClient> clients;
private final SocialFeedAlertService alertService;
@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-url:https://www.facebook.com/phutunet/reels/}") private String phutunetUrl;
public record ChannelDto(Long id, String platform, String sourceUrl, String externalChannelId,
String title, String topic, boolean notificationEnabled,
LocalDateTime lastCollectedAt, int lastFound, int failCount, String lastError) {
static ChannelDto from(SocialChannel channel) {
return new ChannelDto(channel.getId(), channel.getPlatform().name(), channel.getSourceUrl(),
channel.getExternalChannelId(), channel.getTitle(), channel.getTopic().name(),
channel.isNotificationEnabled(), channel.getLastCollectedAt(), channel.getLastFound(),
channel.getFailCount(), channel.getLastError());
}
}
public record VideoDto(Long id, String externalVideoId, String url, String title, String thumbnailUrl,
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());
}
}
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void registerDefaultChannels() {
if (!phutunetSeedEnabled || phutunetUrl == null || phutunetUrl.isBlank()) return;
String normalized = SocialPlatform.normalizeChannelUrl(phutunetUrl);
if (channelRepository.findBySourceUrl(normalized).isPresent()) return;
SocialChannel channel = SocialChannel.register(normalized, FeedTopic.DISTRIBUTION.name(), true);
channel.updateMetadata("Phutu Crafts", "321133534899178");
channelRepository.save(channel);
log.info("[SocialFeed] 기본 Facebook 유통 채널 등록: {}", normalized);
}
@Scheduled(cron = "${hlab.social-feed.cron:0 */15 * * * *}")
public void scheduledCollect() {
if (!enabled) return;
log.info("[SocialFeed] 자동 수집 완료: {}", collect(FeedTopic.DISTRIBUTION.name()));
}
@Transactional(readOnly = true)
public List<ChannelDto> channels(String topic) {
return channelRepository.findAllByTopicOrderByIdAsc(FeedTopic.normalize(topic)).stream()
.map(ChannelDto::from).toList();
}
@Transactional(readOnly = true)
public List<VideoDto> videos(String topic) {
return videoRepository.findFeed(FeedTopic.normalize(topic), PageRequest.of(0, MAX_FEED_ITEMS)).stream()
.map(VideoDto::from).toList();
}
@Transactional
public ChannelDto addChannel(String url, String topic, boolean notificationEnabled) {
String normalized = SocialPlatform.normalizeChannelUrl(url);
SocialChannel channel = channelRepository.findBySourceUrl(normalized)
.orElseGet(() -> SocialChannel.register(normalized, topic, notificationEnabled));
channel.configure(topic, notificationEnabled);
return ChannelDto.from(channelRepository.save(channel));
}
@Transactional
public void removeChannel(Long id) {
SocialChannel channel = getChannel(id);
videoRepository.deleteAllByChannelId(id);
channelRepository.delete(channel);
}
public Map<String, Object> collect(String topic) {
FeedTopic normalized = FeedTopic.normalize(topic);
List<SocialChannel> channels = channelRepository.findAllByTopicOrderByIdAsc(normalized);
int succeeded = 0, failed = 0, saved = 0, alerted = 0;
for (SocialChannel channel : channels) {
try {
CollectionResult result = collectOne(channel.getId());
succeeded++;
saved += result.saved();
alerted += result.alerted();
} catch (Exception e) {
failed++;
markFailure(channel.getId(), friendly(e));
log.warn("[SocialFeed] 채널 수집 실패: id={}, url={}", channel.getId(), channel.getSourceUrl(), e);
}
}
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("channels", channels.size());
summary.put("collected", succeeded);
summary.put("savedVideos", saved);
summary.put("alerted", alerted);
summary.put("failed", failed);
return summary;
}
@Transactional
public CollectionResult collectOne(Long channelId) {
SocialChannel channel = getChannel(channelId);
LocalDateTime previousCollection = channel.getLastCollectedAt();
boolean baselineCollection = previousCollection == null;
SocialFeedClient client = clients.stream().filter(c -> c.supports(channel.getPlatform())).findFirst()
.orElseThrow(() -> new IllegalStateException(channel.getPlatform() + " 수집기가 없습니다."));
SocialFeedBatch batch = client.fetch(channel);
channel.updateMetadata(batch.channelTitle(), batch.externalChannelId());
List<SocialVideo> inserted = new ArrayList<>();
for (SocialVideoCandidate candidate : batch.videos()) {
if (candidate.externalVideoId() == null || candidate.externalVideoId().isBlank()
|| candidate.url() == null || candidate.url().isBlank()
|| videoRepository.existsByChannelIdAndExternalVideoId(channelId, candidate.externalVideoId())) {
continue;
}
inserted.add(videoRepository.save(SocialVideo.from(channel, candidate)));
}
channel.collectionSucceeded(inserted.size());
channelRepository.save(channel);
// 최초 수집은 현재 목록을 기준점으로만 저장한다. 다음 수집부터 생긴 항목만 알린다.
List<SocialVideo> alertCandidates = baselineCollection ? List.of() : inserted.stream()
// 플러그인 목록 순서가 바뀌며 뒤늦게 발견된 과거 영상을 신규로 오인하지 않는다.
.filter(video -> video.getPublishedAt() == null
|| !video.getPublishedAt().isBefore(previousCollection.minusMinutes(5)))
.toList();
int alerted = baselineCollection ? 0 : alertService.notifyNewVideos(channel, alertCandidates);
return new CollectionResult(inserted.size(), alerted, baselineCollection);
}
public record CollectionResult(int saved, int alerted, boolean baselineCollection) { }
@Transactional
protected void markFailure(Long channelId, String message) {
channelRepository.findById(channelId).ifPresent(channel -> {
channel.collectionFailed(message);
channelRepository.save(channel);
});
}
private SocialChannel getChannel(Long id) {
return channelRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("소셜 채널을 찾을 수 없습니다: " + id));
}
private static String friendly(Exception e) {
Throwable cause = e;
while (cause.getCause() != null && cause.getMessage() == null) cause = cause.getCause();
String message = cause.getMessage();
if (message == null || message.isBlank()) return "수집에 실패했습니다.";
return message.length() <= 1200 ? message : message.substring(message.length() - 1200);
}
}

View File

@ -0,0 +1,85 @@
package com.hlab.yanalyst.domain.social;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;
import java.util.Set;
/** 유통 소재 채널에서 지원하는 소셜 플랫폼과 URL 검증 규칙. */
public enum SocialPlatform {
FACEBOOK(Set.of("facebook.com", "fb.watch")),
INSTAGRAM(Set.of("instagram.com")),
TIKTOK(Set.of("tiktok.com")),
YOUTUBE(Set.of("youtube.com", "youtu.be"));
private final Set<String> hosts;
SocialPlatform(Set<String> hosts) {
this.hosts = hosts;
}
public static SocialPlatform fromUrl(String value) {
URI uri = parse(value);
String host = uri.getHost().toLowerCase(Locale.ROOT);
for (SocialPlatform platform : values()) {
if (platform.hosts.stream().anyMatch(allowed -> host.equals(allowed) || host.endsWith("." + allowed))) {
return platform;
}
}
throw new IllegalArgumentException("Facebook, Instagram, TikTok, YouTube 채널 URL만 등록할 수 있습니다.");
}
public static String normalizeChannelUrl(String value) {
URI uri = parse(value);
SocialPlatform platform = fromUrl(value);
String path = uri.getPath() == null || uri.getPath().isBlank() ? "/" : uri.getPath();
validateChannelPath(platform, path);
while (path.length() > 1 && path.endsWith("/")) path = path.substring(0, path.length() - 1);
// Facebook Page Plugin은 /페이지/reels 주소를 직접 받으면 빈 응답을 내므로 페이지 본주소로 맞춘다.
if (platform == FACEBOOK) path = path.replaceFirst("(?i)/(?:reels|videos)$", "");
try {
return new URI("https", null, uri.getHost().toLowerCase(Locale.ROOT), -1, path, null, null).toString();
} catch (URISyntaxException e) {
throw new IllegalArgumentException("올바른 채널 URL을 입력하세요.");
}
}
static String displayNameFromUrl(String normalizedUrl) {
String path = URI.create(normalizedUrl).getPath();
String[] segments = path.split("/");
for (String segment : segments) {
if (!segment.isBlank() && !segment.equalsIgnoreCase("reels") && !segment.equalsIgnoreCase("videos")) {
return segment.startsWith("@") ? segment.substring(1) : segment;
}
}
return fromUrl(normalizedUrl).name();
}
private static URI parse(String value) {
if (value == null || value.isBlank()) throw new IllegalArgumentException("채널 URL을 입력하세요.");
if (value.trim().length() > 2000) throw new IllegalArgumentException("URL은 2,000자 이하여야 합니다.");
try {
URI uri = URI.create(value.trim());
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
if (!(scheme.equals("http") || scheme.equals("https")) || uri.getHost() == null || uri.getUserInfo() != null) {
throw new IllegalArgumentException("HTTP(S) 채널 URL을 입력하세요.");
}
return uri;
} catch (IllegalArgumentException e) {
if (e.getMessage() != null && (e.getMessage().contains("HTTP(S)") || e.getMessage().contains("2,000"))) throw e;
throw new IllegalArgumentException("올바른 채널 URL을 입력하세요.");
}
}
private static void validateChannelPath(SocialPlatform platform, String path) {
String lower = path.toLowerCase(Locale.ROOT);
boolean individualContent = switch (platform) {
case FACEBOOK -> lower.matches(".*/(?:reel|watch)/.*") || lower.matches(".*/videos/\\d+.*");
case INSTAGRAM -> lower.matches("/(?:p|reel|stories)/.*");
case TIKTOK -> lower.contains("/video/");
case YOUTUBE -> lower.equals("/watch") || lower.startsWith("/shorts/") || lower.startsWith("/embed/")
|| lower.startsWith("/live/") || platform == YOUTUBE && path.equals("/");
};
if (individualContent) throw new IllegalArgumentException("개별 영상이 아닌 채널·프로필 URL을 입력하세요.");
}
}

View File

@ -0,0 +1,59 @@
package com.hlab.yanalyst.domain.social;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Entity
@Table(name = "social_videos",
uniqueConstraints = @UniqueConstraint(name = "uk_social_video_channel_external",
columnNames = {"channel_id", "external_video_id"}),
indexes = @Index(name = "idx_social_video_published", columnList = "published_at"))
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class SocialVideo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "channel_id", nullable = false)
private SocialChannel channel;
@Column(name = "external_video_id", nullable = false, length = 300)
private String externalVideoId;
@Column(nullable = false, length = 2000)
private String url;
@Column(nullable = false, columnDefinition = "TEXT")
private String title;
@Column(name = "thumbnail_url", length = 4000)
private String thumbnailUrl;
@Column(name = "published_at")
private LocalDateTime publishedAt;
@CreatedDate
@Column(name = "collected_at", updatable = false)
private LocalDateTime collectedAt;
public static SocialVideo from(SocialChannel channel, SocialVideoCandidate candidate) {
SocialVideo video = new SocialVideo();
video.channel = channel;
video.externalVideoId = candidate.externalVideoId();
video.url = candidate.url();
video.title = candidate.title() == null || candidate.title().isBlank() ? "(제목 없음)" : candidate.title();
video.thumbnailUrl = candidate.thumbnailUrl();
video.publishedAt = candidate.publishedAt();
return video;
}
}

View File

@ -0,0 +1,11 @@
package com.hlab.yanalyst.domain.social;
import java.time.LocalDateTime;
public record SocialVideoCandidate(
String externalVideoId,
String url,
String title,
String thumbnailUrl,
LocalDateTime publishedAt
) { }

View File

@ -0,0 +1,19 @@
package com.hlab.yanalyst.domain.social;
import com.hlab.yanalyst.domain.channel.FeedTopic;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface SocialVideoRepository extends JpaRepository<SocialVideo, Long> {
boolean existsByChannelIdAndExternalVideoId(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")
List<SocialVideo> findFeed(@Param("topic") FeedTopic topic, Pageable pageable);
void deleteAllByChannelId(Long channelId);
}

View File

@ -0,0 +1,131 @@
package com.hlab.yanalyst.domain.social;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/** yt-dlp의 채널/사용자 목록 추출을 이용한 YouTube·TikTok·Instagram 수집기. */
@Component
@RequiredArgsConstructor
public class YtDlpSocialFeedClient implements SocialFeedClient {
private final ObjectMapper objectMapper;
@Value("${ytdlp.bin:yt-dlp}") private String ytdlpBin;
@Value("${hlab.social-feed.max-items:25}") private int maxItems;
@Value("${hlab.social-feed.timeout-seconds:90}") private long timeoutSeconds;
@Value("${hlab.social-feed.cookies-file:}") private String cookiesFile;
@Override
public boolean supports(SocialPlatform platform) {
return platform != SocialPlatform.FACEBOOK;
}
@Override
public SocialFeedBatch fetch(SocialChannel channel) {
List<String> command = buildCommand(ytdlpBin, cookiesFile, maxItems, channel.getSourceUrl());
try {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
ByteArrayOutputStream output = new ByteArrayOutputStream();
Thread drainer = Thread.ofVirtual().start(() -> {
try (var in = process.getInputStream()) { in.transferTo(output); }
catch (Exception ignored) { }
});
if (!process.waitFor(Math.max(1, timeoutSeconds), TimeUnit.SECONDS)) {
process.destroyForcibly();
throw new IllegalStateException("소셜 채널 수집 제한 시간을 초과했습니다.");
}
drainer.join(2000);
String text = output.toString(StandardCharsets.UTF_8);
if (process.exitValue() != 0) throw new IllegalStateException(errorTail(text));
return parse(text, channel.getPlatform(), Math.max(1, maxItems));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("소셜 채널 수집이 중단되었습니다.", e);
} catch (Exception e) {
if (e instanceof IllegalStateException state) throw state;
throw new IllegalStateException("yt-dlp로 채널을 읽지 못했습니다: " + e.getMessage(), e);
}
}
static List<String> buildCommand(String bin, String cookiesFile, int maxItems, String url) {
SocialPlatform.normalizeChannelUrl(url);
List<String> command = new ArrayList<>(List.of(bin, "--flat-playlist", "--dump-single-json",
"--playlist-end", String.valueOf(Math.max(1, maxItems)), "--skip-download", "--no-warnings"));
if (cookiesFile != null && !cookiesFile.isBlank()) command.addAll(List.of("--cookies", cookiesFile.trim()));
command.add(url);
return command;
}
SocialFeedBatch parse(String json, SocialPlatform platform, int limit) throws Exception {
JsonNode root = objectMapper.readTree(json);
String channelTitle = firstText(root, "channel", "uploader", "title");
String channelId = firstText(root, "channel_id", "uploader_id", "id");
List<SocialVideoCandidate> videos = new ArrayList<>();
for (JsonNode entry : root.path("entries")) {
if (entry == null || entry.isNull()) continue;
String id = firstText(entry, "id", "display_id");
if (id == null || id.isBlank()) continue;
String url = firstText(entry, "webpage_url", "url");
if (url == null || !url.startsWith("http")) url = canonicalUrl(platform, id);
String title = firstText(entry, "title", "description");
videos.add(new SocialVideoCandidate(id, url,
title == null || title.isBlank() ? "(제목 없음)" : title,
thumbnail(entry), publishedAt(entry)));
if (videos.size() >= limit) break;
}
return new SocialFeedBatch(channelTitle, channelId, videos);
}
private static String firstText(JsonNode node, String... fields) {
for (String field : fields) {
String value = node.path(field).asText(null);
if (value != null && !value.isBlank()) return value;
}
return null;
}
private static String thumbnail(JsonNode entry) {
String direct = entry.path("thumbnail").asText(null);
if (direct != null && !direct.isBlank()) return direct;
JsonNode thumbnails = entry.path("thumbnails");
if (thumbnails.isArray() && !thumbnails.isEmpty()) {
return thumbnails.get(thumbnails.size() - 1).path("url").asText(null);
}
return null;
}
private static LocalDateTime publishedAt(JsonNode entry) {
long timestamp = entry.path("timestamp").asLong(entry.path("release_timestamp").asLong(0));
if (timestamp > 0) return LocalDateTime.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault());
String uploadDate = entry.path("upload_date").asText(null);
if (uploadDate != null && uploadDate.matches("\\d{8}")) {
return LocalDate.parse(uploadDate, DateTimeFormatter.BASIC_ISO_DATE).atStartOfDay();
}
return null;
}
private static String canonicalUrl(SocialPlatform platform, String id) {
return switch (platform) {
case YOUTUBE -> "https://www.youtube.com/watch?v=" + id;
case TIKTOK -> "https://www.tiktok.com/video/" + id;
case INSTAGRAM -> "https://www.instagram.com/p/" + id + "/";
case FACEBOOK -> "https://www.facebook.com/reel/" + id;
};
}
private static String errorTail(String output) {
String clean = output == null ? "" : output.strip();
if (clean.length() > 1200) clean = clean.substring(clean.length() - 1200);
return clean.isBlank() ? "yt-dlp가 채널 목록을 반환하지 않았습니다." : clean;
}
}

View File

@ -74,6 +74,12 @@ public class WebController {
return "feed"; return "feed";
} }
@GetMapping("/distribution-feed")
public String distributionFeed(Model model) {
model.addAttribute("currentPage", "distribution-feed");
return "distribution-feed";
}
@GetMapping("/shortform") @GetMapping("/shortform")
public String shortform(Model model) { public String shortform(Model model) {
model.addAttribute("currentPage", "shortform"); model.addAttribute("currentPage", "shortform");

View File

@ -16,3 +16,6 @@ YOUTUBE_OAUTH_SETTINGS_FILE: config/youtube-oauth.properties
# 페이스북 영상 다운로드 파일 저장 경로 (기본: downloads/facebook) # 페이스북 영상 다운로드 파일 저장 경로 (기본: downloads/facebook)
# facebook-download: # facebook-download:
# dir: D:/downloads/facebook # dir: D:/downloads/facebook
# Instagram/TikTok 채널이 로그인 쿠키를 요구할 때 yt-dlp Netscape 쿠키 파일 경로를 지정합니다.
# SOCIAL_FEED_COOKIES_FILE: D:/config/social-cookies.txt

View File

@ -129,6 +129,18 @@ hlab:
# 생 조회수로 자르면 방금 올라온 영상까지 걸리므로 시간당 조회수로 거른다. # 생 조회수로 자르면 방금 올라온 영상까지 걸리므로 시간당 조회수로 거른다.
min-views-per-hour: ${FEED_PERSON_MIN_VPH:20} min-views-per-hour: ${FEED_PERSON_MIN_VPH:20}
# 유통 소재용 소셜 채널 수집. Facebook 공개 페이지는 Page Plugin 피드,
# YouTube·TikTok·Instagram은 yt-dlp 채널 추출기를 사용한다.
social-feed:
enabled: ${SOCIAL_FEED_ENABLED:true}
cron: ${SOCIAL_FEED_CRON:0 */15 * * * *}
max-items: ${SOCIAL_FEED_MAX_ITEMS:25}
timeout-seconds: ${SOCIAL_FEED_TIMEOUT_SECONDS:90}
cookies-file: ${SOCIAL_FEED_COOKIES_FILE:}
seed:
phutunet-enabled: ${SOCIAL_SEED_PHUTUNET_ENABLED:true}
phutunet-url: ${SOCIAL_SEED_PHUTUNET_URL:https://www.facebook.com/phutunet/reels/}
# 텔레그램 알림: 아침 추천 다이제스트 + 피드 골든타임 소재(신규 수집분이 업로드 24h 이내일 때). # 텔레그램 알림: 아침 추천 다이제스트 + 피드 골든타임 소재(신규 수집분이 업로드 24h 이내일 때).
# 토큰/챗ID 없으면 자동 no-op. # 토큰/챗ID 없으면 자동 no-op.
notify: notify:

View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head><title>h-lab - 유통 피드</title></head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<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>
<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>
<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="toast" class="social-toast" role="status"></div>
</div>
<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}}
</style>
</th:block>
<th:block layout:fragment="scripts">
<script>
const API='/api/social-feed';
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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'}):'게시 시각 미상'}
async function loadChannels(){const box=document.getElementById('channelList');try{const list=await api(`${API}/channels?topic=DISTRIBUTION`);box.innerHTML=list.length?list.map(c=>`<div class="social-channel"><span class="social-platform ${c.platform}">${c.platform}</span><div class="social-channel-main"><b>${esc(c.title)}</b><a href="${esc(c.sourceUrl)}" target="_blank" rel="noopener">${esc(c.sourceUrl)}</a><span class="social-channel-meta">${ago(c.lastCollectedAt)} 수집 · 신규 ${c.lastFound}건 · 알림 ${c.notificationEnabled?'켜짐':'꺼짐'}</span>${c.lastError?`<div class="social-channel-error">최근 오류: ${esc(c.lastError)}</div>`:''}</div><button class="social-delete" title="채널 삭제" onclick="removeChannel(${c.id})">×</button></div>`).join(''):'<p class="social-help">등록된 채널이 없습니다.</p>'}catch(e){box.innerHTML=`<div class="social-channel-error">채널을 불러오지 못했습니다: ${esc(e.message)}</div>`}}
async function loadVideos(){const grid=document.getElementById('videoGrid'),empty=document.getElementById('emptyState');try{const list=await api(`${API}/videos?topic=DISTRIBUTION`);document.getElementById('videoCount').textContent=`${list.length}건`;empty.classList.toggle('hidden',list.length>0);grid.innerHTML=list.map(v=>`<article class="card social-video"><a class="social-thumb" href="${esc(v.url)}" target="_blank" rel="noopener">${v.thumbnailUrl?`<img src="${esc(v.thumbnailUrl)}" alt="" loading="lazy">`:'<span class="social-no-thumb"><i data-lucide="play"></i></span>'}</a><div class="social-video-body"><h3><a href="${esc(v.url)}" target="_blank" rel="noopener">${esc(v.title)}</a></h3><div class="social-video-meta"><span>${esc(v.channelTitle)} · ${v.platform}</span><span>${date(v.publishedAt)}</span></div></div></article>`).join('');if(window.lucide)lucide.createIcons()}catch(e){toast('영상을 불러오지 못했습니다: '+e.message,true)}}
async function addChannel(){const input=document.getElementById('channelUrl'),button=document.getElementById('addBtn'),url=input.value.trim();if(!url){input.focus();return}button.disabled=true;try{const c=await api(`${API}/channels`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,topic:'DISTRIBUTION',notificationEnabled:document.getElementById('notifyEnabled').checked})});input.value='';toast(`${c.title} 채널을 등록했습니다`);await loadChannels()}catch(e){toast('등록 실패: '+e.message,true)}finally{button.disabled=false}}
async function removeChannel(id){if(!confirm('이 채널과 수집 영상을 삭제할까요?'))return;try{await api(`${API}/channels/${id}`,{method:'DELETE'});toast('채널을 삭제했습니다');await Promise.all([loadChannels(),loadVideos()])}catch(e){toast('삭제 실패: '+e.message,true)}}
async function collectNow(){const button=document.getElementById('collectBtn'),original=button.innerHTML;button.disabled=true;button.textContent='수집 중…';try{const r=await api(`${API}/collect?topic=DISTRIBUTION`,{method:'POST'});toast(`수집 완료 · 새 영상 ${r.savedVideos}건 · 알림 ${r.alerted}건${r.failed?` · 실패 ${r.failed}개`:''}`,r.failed>0);await Promise.all([loadChannels(),loadVideos()])}catch(e){toast('수집 실패: '+e.message,true)}finally{button.disabled=false;button.innerHTML=original;if(window.lucide)lucide.createIcons()}}
Promise.all([loadChannels(),loadVideos()]);
</script>
</th:block>
</body>
</html>

View File

@ -27,6 +27,9 @@
<a th:href="@{/politics-feed}" class="nav-item" th:classappend="${currentPage == 'politics-feed'} ? 'active'"> <a th:href="@{/politics-feed}" class="nav-item" th:classappend="${currentPage == 'politics-feed'} ? 'active'">
<i data-lucide="landmark" class="nav-icon"></i><span class="nav-text">정치 피드</span> <i data-lucide="landmark" class="nav-icon"></i><span class="nav-text">정치 피드</span>
</a> </a>
<a th:href="@{/distribution-feed}" class="nav-item" th:classappend="${currentPage == 'distribution-feed'} ? 'active'">
<i data-lucide="shopping-bag" class="nav-icon"></i><span class="nav-text">유통 피드</span>
</a>
<a th:href="@{/shortform}" class="nav-item" th:classappend="${currentPage == 'shortform'} ? 'active'"> <a th:href="@{/shortform}" class="nav-item" th:classappend="${currentPage == 'shortform'} ? 'active'">
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">숏폼 큐</span> <i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">숏폼 큐</span>
</a> </a>

View File

@ -0,0 +1,51 @@
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.global.notify.TelegramNotifier;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
class FeedAlertServiceTest {
@Test
void excludesPoliticsButKeepsEntertainmentAlerts() {
TelegramNotifier notifier = mock(TelegramNotifier.class);
when(notifier.isEnabled()).thenReturn(true);
when(notifier.sendMessage(anyString())).thenReturn(true);
FeedAlertService service = new FeedAlertService(notifier);
ChannelVideo entertainment = video("entertainment-id", FeedTopic.ENTERTAINMENT);
ChannelVideo politics = video("politics-video-id", FeedTopic.POLITICS);
int alerted = service.notifyGoldenTime("소재 피드", List.of(politics, entertainment));
assertThat(alerted).isEqualTo(1);
verify(notifier).sendMessage(argThat(message -> message.contains("entertainment-id")
&& !message.contains("politics-video-id")));
}
@Test
void skipsMessageWhenOnlyPoliticsVideosAreNew() {
TelegramNotifier notifier = mock(TelegramNotifier.class);
when(notifier.isEnabled()).thenReturn(true);
FeedAlertService service = new FeedAlertService(notifier);
assertThat(service.notifyGoldenTime("정치 피드", List.of(video("politics-video-id", FeedTopic.POLITICS))))
.isZero();
verify(notifier, never()).sendMessage(anyString());
}
private static ChannelVideo video(String id, FeedTopic topic) {
ChannelVideo video = ChannelVideo.builder()
.videoId(id)
.title(id)
.publishedAt(LocalDateTime.now().minusMinutes(5))
.build();
video.applyFeedInfo("channel", "채널", 1L, null, ChannelRole.SOURCE, topic);
return video;
}
}

View File

@ -11,6 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy; import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
@ -78,4 +79,23 @@ class ShortformSubtitleServiceTest {
assertThat(job.getSubtitleVtt()).isEqualTo("WEBVTT\n"); assertThat(job.getSubtitleVtt()).isEqualTo("WEBVTT\n");
verify(telegramNotifier).sendMessage(org.mockito.ArgumentMatchers.contains("자막 생성 완료")); verify(telegramNotifier).sendMessage(org.mockito.ArgumentMatchers.contains("자막 생성 완료"));
} }
@Test
void doesNotNotifyForPoliticsJob() throws Exception {
ShortformJobRepository repository = mock(ShortformJobRepository.class);
ShortformJob job = ShortformJob.create(
"https://www.youtube.com/watch?v=gDfGqRDubCg",
"gDfGqRDubCg",
ShortformCategory.POLITICS);
when(repository.findById(1L)).thenReturn(Optional.of(job));
TelegramNotifier telegramNotifier = mock(TelegramNotifier.class);
ShortformSubtitleService service = spy(new ShortformSubtitleService(
repository, mock(ShortformWhisperService.class), telegramNotifier));
doReturn("WEBVTT\n").when(service).collectOrTranscribe(1L, "gDfGqRDubCg");
service.collectAsync(1L);
assertThat(job.getSubtitleStatus()).isEqualTo(SubtitleStatus.READY);
verify(telegramNotifier, never()).sendMessage(org.mockito.ArgumentMatchers.anyString());
}
} }

View File

@ -0,0 +1,35 @@
package com.hlab.yanalyst.domain.social;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class FacebookPageFeedClientTest {
@Test
void parsesUniqueReelsWithTitleAndPublishedTime() {
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>
<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>
<div data-testid="post_message">An Outdoor Light Hidden Inside a Tree</div>
</div>
""";
SocialFeedBatch batch = FacebookPageFeedClient.parseMarkup(markup, 25);
assertThat(batch.channelTitle()).isEqualTo("Phutu Crafts");
assertThat(batch.videos()).hasSize(2);
assertThat(batch.videos().get(0).externalVideoId()).isEqualTo("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).thumbnailUrl()).isEqualTo("https://cdn.example/thumb.jpg");
assertThat(batch.videos().get(0).publishedAt()).isNotNull();
}
}

View File

@ -0,0 +1,48 @@
package com.hlab.yanalyst.domain.social;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class SocialFeedServiceTest {
@Test
void firstCollectionCreatesBaselineAndOnlyLaterVideosAlert() {
SocialChannelRepository channels = mock(SocialChannelRepository.class);
SocialVideoRepository videos = mock(SocialVideoRepository.class);
SocialFeedClient client = mock(SocialFeedClient.class);
SocialFeedAlertService alerts = mock(SocialFeedAlertService.class);
SocialChannel channel = SocialChannel.register("https://facebook.com/phutunet/reels/", "DISTRIBUTION", true);
SocialVideoCandidate existing = candidate("old");
SocialVideoCandidate newlyPublished = candidate("new");
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(existing)),
new SocialFeedBatch("Phutu Crafts", null, List.of(newlyPublished)));
when(videos.existsByChannelIdAndExternalVideoId(eq(1L), anyString())).thenReturn(false);
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.CollectionResult baseline = service.collectOne(1L);
SocialFeedService.CollectionResult next = service.collectOne(1L);
assertThat(baseline.baselineCollection()).isTrue();
assertThat(baseline.alerted()).isZero();
assertThat(next.baselineCollection()).isFalse();
assertThat(next.alerted()).isEqualTo(1);
verify(alerts, times(1)).notifyNewVideos(eq(channel), anyList());
}
private static SocialVideoCandidate candidate(String id) {
return new SocialVideoCandidate(id, "https://facebook.com/reel/" + id, id, null,
LocalDateTime.now());
}
}

View File

@ -0,0 +1,35 @@
package com.hlab.yanalyst.domain.social;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class SocialPlatformTest {
@Test
void detectsAndNormalizesSupportedChannelUrls() {
assertThat(SocialPlatform.fromUrl("https://www.facebook.com/phutunet/reels/"))
.isEqualTo(SocialPlatform.FACEBOOK);
assertThat(SocialPlatform.fromUrl("https://www.instagram.com/shop/"))
.isEqualTo(SocialPlatform.INSTAGRAM);
assertThat(SocialPlatform.fromUrl("https://www.tiktok.com/@shop"))
.isEqualTo(SocialPlatform.TIKTOK);
assertThat(SocialPlatform.fromUrl("https://youtube.com/@shop"))
.isEqualTo(SocialPlatform.YOUTUBE);
assertThat(SocialPlatform.normalizeChannelUrl("http://WWW.FACEBOOK.COM/phutunet/reels/?ref=home"))
.isEqualTo("https://www.facebook.com/phutunet");
}
@Test
void rejectsUnknownHostsCredentialsAndIndividualVideos() {
assertThatThrownBy(() -> SocialPlatform.fromUrl("https://example.com/channel"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> SocialPlatform.fromUrl("https://user@facebook.com/page"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> SocialPlatform.normalizeChannelUrl("https://facebook.com/reel/1234"))
.hasMessageContaining("개별 영상");
assertThatThrownBy(() -> SocialPlatform.normalizeChannelUrl("https://youtube.com/watch?v=1234"))
.hasMessageContaining("개별 영상");
}
}

View File

@ -0,0 +1,30 @@
package com.hlab.yanalyst.domain.social;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class YtDlpSocialFeedClientTest {
@Test
void parsesFlatPlaylistJson() throws Exception {
String json = """
{"channel":"유통 채널","channel_id":"UC-shop","entries":[
{"id":"abcdefghijk","title":"새 상품 영상","url":"abcdefghijk","timestamp":1788448574,
"thumbnails":[{"url":"https://cdn.example/small.jpg"},{"url":"https://cdn.example/large.jpg"}]}
]}
""";
YtDlpSocialFeedClient client = new YtDlpSocialFeedClient(new ObjectMapper());
SocialFeedBatch batch = client.parse(json, SocialPlatform.YOUTUBE, 25);
assertThat(batch.channelTitle()).isEqualTo("유통 채널");
assertThat(batch.externalChannelId()).isEqualTo("UC-shop");
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.publishedAt()).isNotNull();
});
}
}