feat: 유통 소셜 피드와 텔레그램 알림 추가
This commit is contained in:
parent
ea08b6d6b7
commit
d25093dd44
@ -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'
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
.replace("\"", """);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.hlab.yanalyst.domain.social;
|
||||||
|
|
||||||
|
public interface SocialFeedClient {
|
||||||
|
boolean supports(SocialPlatform platform);
|
||||||
|
SocialFeedBatch fetch(SocialChannel channel);
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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을 입력하세요.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) { }
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
67
src/main/resources/templates/distribution-feed.html
Normal file
67
src/main/resources/templates/distribution-feed.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
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>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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("개별 영상");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user