From e44c3c7731a6bcc37a091019a1c6946c3dc18be7 Mon Sep 17 00:00:00 2001 From: gracefulBrown Date: Thu, 6 Jun 2024 02:12:56 +0900 Subject: [PATCH] fix(article): slack message for safe --- .../article/application/ArticleService.java | 78 ++++++----- .../prolog/article/application/RssClient.java | 33 +++++ .../application/dto/ArticleRequest.java | 2 +- .../prolog/article/domain/Article.java | 13 -- .../prolog/article/domain/Articles.java | 48 ------- .../prolog/article/domain/Description.java | 4 +- .../prolog/article/domain/ImageUrl.java | 8 -- .../prolog/article/domain/RssFeed.java | 132 ++++++++++++++++++ .../prolog/article/domain/RssFeeds.java | 32 +++++ .../article/application/RssClientTest.java | 44 ++++++ .../prolog/article/domain/ArticlesTest.java | 20 --- 11 files changed, 284 insertions(+), 130 deletions(-) create mode 100644 backend/src/main/java/wooteco/prolog/article/application/RssClient.java delete mode 100644 backend/src/main/java/wooteco/prolog/article/domain/Articles.java create mode 100644 backend/src/main/java/wooteco/prolog/article/domain/RssFeed.java create mode 100644 backend/src/main/java/wooteco/prolog/article/domain/RssFeeds.java create mode 100644 backend/src/test/java/wooteco/prolog/article/application/RssClientTest.java delete mode 100644 backend/src/test/java/wooteco/prolog/article/domain/ArticlesTest.java diff --git a/backend/src/main/java/wooteco/prolog/article/application/ArticleService.java b/backend/src/main/java/wooteco/prolog/article/application/ArticleService.java index 9b4806da1..de9ca0ff1 100644 --- a/backend/src/main/java/wooteco/prolog/article/application/ArticleService.java +++ b/backend/src/main/java/wooteco/prolog/article/application/ArticleService.java @@ -1,5 +1,6 @@ package wooteco.prolog.article.application; +import com.google.common.collect.Lists; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -10,7 +11,8 @@ import wooteco.prolog.article.application.dto.ArticleResponse; import wooteco.prolog.article.domain.Article; import wooteco.prolog.article.domain.ArticleFilterType; -import wooteco.prolog.article.domain.Articles; +import wooteco.prolog.article.domain.RssFeed; +import wooteco.prolog.article.domain.RssFeeds; import wooteco.prolog.article.domain.repository.ArticleRepository; import wooteco.prolog.common.exception.BadRequestException; import wooteco.prolog.login.ui.LoginMember; @@ -19,6 +21,7 @@ import wooteco.prolog.member.domain.MemberUpdatedEvent; import wooteco.prolog.member.domain.Role; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -37,6 +40,7 @@ public class ArticleService { private final ArticleRepository articleRepository; private final MemberService memberService; private final SlackService slackService; + private final RssClient rssClient; @Transactional public Long create(final ArticleRequest articleRequest, final LoginMember loginMember) { @@ -113,7 +117,7 @@ public void updateViewCount(final Long id) { @EventListener public void handleMemberUpdatedEvent(MemberUpdatedEvent event) { Member member = event.getMember(); - fetchArticleWhenMemberUpdated(member); + fetchArticlesOf(member); } @Transactional @@ -136,54 +140,52 @@ private List fetchArticleWithRssFeeds() { } private List fetchArticleWithRssFeedOf(Member member) { - try { - List
newArticles = findNewArticles(member); - if (newArticles.isEmpty()) { - return new ArrayList<>(); - } - - List
persistNewArticles = articleRepository.saveAll(newArticles); + List
newArticles = fetchArticlesOf(member); + if (newArticles.isEmpty()) { + return new ArrayList<>(); + } - // 가장 최신의 글만 슬랙 메시지로 알림 - persistNewArticles.stream() - .max(Comparator.comparing(Article::getPublishedAt)) - .ifPresent(slackService::sendSlackMessage); + // 가장 최신의 글만 슬랙 메시지로 알림 + newArticles.stream() + .max(Comparator.comparing(Article::getPublishedAt)) + .ifPresent(slackService::sendSlackMessage); - return persistNewArticles.stream() - .map(article -> ArticleResponse.of(article, member.getId())) - .collect(toList()); - } catch (Exception e) { - logger.error("Failed to fetch RSS feed for member: " + member.getId(), e); - throw new RssFeedException("Failed to fetch RSS feed for member: " + member.getId(), e); - } + // 저장된 모든 아티클 출력 + return newArticles.stream() + .map(article -> ArticleResponse.of(article, member.getId())) + .collect(toList()); } - private List fetchArticleWhenMemberUpdated(Member member) { + private List
fetchArticlesOf(Member member) { try { - List
newArticles = findNewArticles(member).stream() - .sorted(Comparator.comparing(Article::getPublishedAt).reversed()) - .limit(3) - .collect(toList()); + RssFeeds rssFeeds = rssClient.fromRssFeedBy(member.getRssFeedUrl()); + RssFeed latestRssFeed = rssFeeds.findLatestRssFeed(); + + // 만약에 가장 최신의 피드가 없으면? 끝 + if (rssFeeds.isEmpty()) { + new ArrayList<>(); + } - if (newArticles.isEmpty()) { - return new ArrayList<>(); + // 기존에 하나도 저장된 아티클이 없으면? 가장 최신의 피드를 저장 + List
existedArticles = articleRepository.findAllByMemberId(member.getId()); + if (existedArticles.isEmpty()) { + return Lists.newArrayList(articleRepository.save(latestRssFeed.toArticle(member))); } - List
persistNewArticles = articleRepository.saveAll(newArticles); + // 가장 최근 아티클의 발행시간 조회 + LocalDateTime latestArticlePublishedAt = existedArticles.stream() + .max(Comparator.comparing(Article::getPublishedAt)).get().getPublishedAt(); - return persistNewArticles.stream() - .map(article -> ArticleResponse.of(article, member.getId())) - .collect(toList()); + // 최근 아티클 발행 시간 이후로 작성된 피드 추출 + List articlesAfter = rssFeeds.findArticlesAfter(latestArticlePublishedAt); + + // 최근 아티클 발행 시간 이후로 작성된 피드를 모두 저장 + return articleRepository.saveAll(articlesAfter.stream() + .map(it -> it.toArticle(member)) + .collect(toList())); } catch (Exception e) { logger.error("Failed to fetch RSS feed for member: " + member.getId(), e); throw new RssFeedException("Failed to fetch RSS feed for member: " + member.getId(), e); } } - - private List
findNewArticles(Member member) { - Articles rssArticles = Articles.fromRssFeedBy(member); - List
existedArticles = articleRepository.findAllByMemberId(member.getId()); - - return rssArticles.findNewArticles(existedArticles); - } } diff --git a/backend/src/main/java/wooteco/prolog/article/application/RssClient.java b/backend/src/main/java/wooteco/prolog/article/application/RssClient.java new file mode 100644 index 000000000..476453015 --- /dev/null +++ b/backend/src/main/java/wooteco/prolog/article/application/RssClient.java @@ -0,0 +1,33 @@ +package wooteco.prolog.article.application; + +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.io.SyndFeedInput; +import com.rometools.rome.io.XmlReader; +import org.springframework.stereotype.Component; +import wooteco.prolog.article.domain.RssFeed; +import wooteco.prolog.article.domain.RssFeeds; + +import java.net.URL; +import java.util.ArrayList; + +import static java.util.stream.Collectors.toList; + +@Component +public class RssClient { + public RssFeeds fromRssFeedBy(String feedUrl) { + try { + URL feedSource = new URL(feedUrl); + SyndFeedInput input = new SyndFeedInput(); + SyndFeed syndFeed = input.build(new XmlReader(feedSource)); + String defaultImageUrl = RssFeed.extractDefaultImageUrl(syndFeed); + + return new RssFeeds(syndFeed.getEntries().stream() + .map(it -> RssFeed.of(it, defaultImageUrl)) + .collect(toList())); + + } catch (Exception e) { + e.printStackTrace(); + return new RssFeeds(new ArrayList<>()); + } + } +} diff --git a/backend/src/main/java/wooteco/prolog/article/application/dto/ArticleRequest.java b/backend/src/main/java/wooteco/prolog/article/application/dto/ArticleRequest.java index 91f3746af..b8e4e3fd5 100644 --- a/backend/src/main/java/wooteco/prolog/article/application/dto/ArticleRequest.java +++ b/backend/src/main/java/wooteco/prolog/article/application/dto/ArticleRequest.java @@ -26,6 +26,6 @@ public ArticleRequest(String title, String url, String imageUrl) { } public Article toArticle(final Member member) { - return new Article(member, new Title(title), new Description(description), new Url(url), new ImageUrl(imageUrl)); + return new Article(member, new Title(title), new Description(description), new Url(url), new ImageUrl(imageUrl), null); } } diff --git a/backend/src/main/java/wooteco/prolog/article/domain/Article.java b/backend/src/main/java/wooteco/prolog/article/domain/Article.java index 4ce915dea..2b96f0ca8 100644 --- a/backend/src/main/java/wooteco/prolog/article/domain/Article.java +++ b/backend/src/main/java/wooteco/prolog/article/domain/Article.java @@ -67,10 +67,6 @@ public class Article { @Embedded private ViewCount views; - public Article(Member member, Title title, Description description, Url url, ImageUrl imageUrl) { - this(member, title, description, url, imageUrl, null); - } - public Article(Member member, Title title, Url url, ImageUrl imageUrl) { this(member, title, new Description(), url, imageUrl, null); } @@ -87,15 +83,6 @@ public Article(Member member, Title title, Description description, Url url, Ima this.views = new ViewCount(); } - public static Article of(Member member, SyndFeed syndFeed, SyndEntry entry) { - Title title = new Title(entry.getTitle()); - ImageUrl imageUrl = ImageUrl.of(entry.getDescription().getValue(), syndFeed.getImage().getUrl()); - Description description = new Description(entry.getDescription().getValue()); - LocalDateTime publishedAt = entry.getPublishedDate().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); - - return new Article(member, title, description, new Url(entry.getLink()), imageUrl, publishedAt); - } - public void validateOwner(final Member member) { if (!member.equals(this.member)) { throw new BadRequestException(BadRequestCode.INVALID_ARTICLE_AUTHOR_EXCEPTION); diff --git a/backend/src/main/java/wooteco/prolog/article/domain/Articles.java b/backend/src/main/java/wooteco/prolog/article/domain/Articles.java deleted file mode 100644 index 8b2a54f49..000000000 --- a/backend/src/main/java/wooteco/prolog/article/domain/Articles.java +++ /dev/null @@ -1,48 +0,0 @@ -package wooteco.prolog.article.domain; - -import com.rometools.rome.feed.synd.SyndFeed; -import com.rometools.rome.io.SyndFeedInput; -import com.rometools.rome.io.XmlReader; -import lombok.AllArgsConstructor; -import lombok.Getter; -import wooteco.prolog.member.domain.Member; - -import java.net.URL; -import java.util.ArrayList; -import java.util.List; - -import static java.util.stream.Collectors.toList; - -@Getter -@AllArgsConstructor -public class Articles { - private final List
articles; - - public static Articles fromRssFeedBy(Member member) { - try { - URL feedSource = new URL(member.getRssFeedUrl()); - SyndFeedInput input = new SyndFeedInput(); - SyndFeed syndFeed = input.build(new XmlReader(feedSource)); - - return new Articles( - syndFeed.getEntries().stream() - .map(entry -> Article.of(member, syndFeed, entry)) - .collect(toList()) - ); - } catch (Exception e) { - e.printStackTrace(); - return new Articles(new ArrayList<>()); - } - } - - public List
findNewArticles(List
existedArticles) { - return this.articles.stream() - .filter(article -> !hasDuplicateUrl(existedArticles, article.getUrl().getUrl())) - .collect(toList()); - } - - private boolean hasDuplicateUrl(List
articles, String url) { - return articles.stream() - .anyMatch(article -> article.hasDuplicateUrl(url)); - } -} diff --git a/backend/src/main/java/wooteco/prolog/article/domain/Description.java b/backend/src/main/java/wooteco/prolog/article/domain/Description.java index f1f4f1e0b..983989f1e 100644 --- a/backend/src/main/java/wooteco/prolog/article/domain/Description.java +++ b/backend/src/main/java/wooteco/prolog/article/domain/Description.java @@ -23,8 +23,8 @@ public Description(String description) { if (description == null || description.isEmpty() || description.trim().isEmpty()) { description = "내용없음"; } - if (description.length() > 100) { - description = description.substring(0, 100); + if (description.length() > 200) { + description = description.substring(0, 200); } this.description = StringEscapeUtils.unescapeHtml4(Jsoup.clean(description, Safelist.none())); diff --git a/backend/src/main/java/wooteco/prolog/article/domain/ImageUrl.java b/backend/src/main/java/wooteco/prolog/article/domain/ImageUrl.java index bf838d5ad..54af0331b 100644 --- a/backend/src/main/java/wooteco/prolog/article/domain/ImageUrl.java +++ b/backend/src/main/java/wooteco/prolog/article/domain/ImageUrl.java @@ -31,12 +31,4 @@ public ImageUrl(String url) { } this.url = url.trim(); } - - public static ImageUrl of(String description, String defaultUrl) { - Document doc = Jsoup.parse(description); - Element img = doc.select("img").first(); - String url = img != null ? img.attr("src") : defaultUrl; - - return new ImageUrl(url); - } } diff --git a/backend/src/main/java/wooteco/prolog/article/domain/RssFeed.java b/backend/src/main/java/wooteco/prolog/article/domain/RssFeed.java new file mode 100644 index 000000000..04010d571 --- /dev/null +++ b/backend/src/main/java/wooteco/prolog/article/domain/RssFeed.java @@ -0,0 +1,132 @@ +package wooteco.prolog.article.domain; + +import com.rometools.rome.feed.synd.SyndContent; +import com.rometools.rome.feed.synd.SyndEntry; +import com.rometools.rome.feed.synd.SyndFeed; +import lombok.Getter; +import org.jdom2.Element; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import wooteco.prolog.member.domain.Member; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Objects; + +@Getter +public class RssFeed { + private String title; + private String description; + private String url; + private String imageUrl; + private LocalDateTime publishedAt; + + public static RssFeed of(SyndEntry entry, String defaultImageUrl) { + RssFeed rssFeed = new RssFeed(); + rssFeed.title = entry.getTitle(); + rssFeed.description = extractDescription(entry); + rssFeed.url = entry.getLink(); + rssFeed.imageUrl = extractImageUrl(entry, defaultImageUrl); + rssFeed.publishedAt = extractPublishedAt(entry); + + return rssFeed; + } + + private static LocalDateTime extractPublishedAt(SyndEntry entry) { + try { + return entry.getPublishedDate().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + } catch (Exception e) { + e.printStackTrace(); + return LocalDateTime.now(); + } + } + + private static String extractDescription(SyndEntry entry) { + try { + if (entry.getDescription() != null) { + return entry.getDescription().getValue(); + } + + Element groupElement = entry.getForeignMarkup().stream() + .filter(it -> Objects.equals(it.getName(), "group")) + .findFirst().orElseThrow(IllegalArgumentException::new); + + Element descElement = groupElement.getChildren().stream() + .filter(it -> Objects.equals(it.getName(), "description")) + .findFirst().orElseThrow(IllegalArgumentException::new); + + return descElement.getContent().get(0).getValue(); + } catch (Exception e) { + e.printStackTrace(); + return ""; + } + } + + private static String extractImageUrl(SyndEntry entry, String defaultImageUrl) { + String imageFromDescription = imageUrlFromDescription(entry); + if (!imageFromDescription.isEmpty()) { + return imageFromDescription; + } + + String imageFromMedia = extractThumbnailFromMedia(entry); + if (!imageFromMedia.isEmpty()) { + return imageFromMedia; + } + + return defaultImageUrl; + } + + private static String extractThumbnailFromMedia(SyndEntry entry) { + try { + Element groupElement = entry.getForeignMarkup().stream() + .filter(it -> Objects.equals(it.getName(), "group")) + .findFirst().orElseThrow(IllegalArgumentException::new); + + Element descElement = groupElement.getChildren().stream() + .filter(it -> Objects.equals(it.getName(), "thumbnail")) + .findFirst().orElseThrow(IllegalArgumentException::new); + + return descElement.getAttributeValue("url"); + } catch (Exception e) { + e.printStackTrace(); + return ""; + } + } + + public static String imageUrlFromDescription(SyndEntry entry) { + try { + SyndContent descriptionContent = entry.getDescription(); + if (descriptionContent == null) { + return ""; + } + + String description = descriptionContent.getValue(); + Document doc = Jsoup.parse(description); + org.jsoup.nodes.Element img = doc.select("img").first(); + return img != null ? img.attr("src") : ""; + } catch (Exception e) { + e.printStackTrace(); + return ""; + } + } + + public static String extractDefaultImageUrl(SyndFeed feed) { + try { + return feed.getImage().getUrl(); + } catch (Exception e) { + e.printStackTrace(); + return "https://avatars.githubusercontent.com/u/45747236?s=200&v=4"; + } + } + + public Article toArticle(Member member) { + return new Article( + member, + new Title(title), + new Description(description), + new Url(url), + new ImageUrl(imageUrl), + publishedAt + ); + } +} diff --git a/backend/src/main/java/wooteco/prolog/article/domain/RssFeeds.java b/backend/src/main/java/wooteco/prolog/article/domain/RssFeeds.java new file mode 100644 index 000000000..e3f35147d --- /dev/null +++ b/backend/src/main/java/wooteco/prolog/article/domain/RssFeeds.java @@ -0,0 +1,32 @@ +package wooteco.prolog.article.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; + +import static java.util.stream.Collectors.toList; + +@Getter +@AllArgsConstructor +public class RssFeeds { + private final List rssFeeds; + + public RssFeed findLatestRssFeed() { + return rssFeeds.stream() + .max(Comparator.comparing(RssFeed::getPublishedAt)) + .orElse(null); + } + + public boolean isEmpty() { + return rssFeeds.isEmpty(); + } + + public List findArticlesAfter(LocalDateTime latestArticlePublishedAt) { + return rssFeeds.stream() + .filter(rssFeed -> rssFeed.getPublishedAt().isAfter(latestArticlePublishedAt)) + .collect(toList()); + } +} diff --git a/backend/src/test/java/wooteco/prolog/article/application/RssClientTest.java b/backend/src/test/java/wooteco/prolog/article/application/RssClientTest.java new file mode 100644 index 000000000..bd3f1b051 --- /dev/null +++ b/backend/src/test/java/wooteco/prolog/article/application/RssClientTest.java @@ -0,0 +1,44 @@ +package wooteco.prolog.article.application; + +import org.junit.jupiter.api.Test; +import wooteco.prolog.article.domain.RssFeeds; + +import static org.assertj.core.api.Assertions.assertThat; + +class RssClientTest { + @Test + void fromTistoryRssFeedBy() { + RssClient rssClient = new RssClient(); + RssFeeds rssFeeds = rssClient.fromRssFeedBy("https://lazypazy.tistory.com/rss"); + + assertThat(rssFeeds.getRssFeeds()).isNotEmpty(); + } + + @Test + void fromYoutubeRssFeedBy() { + SSLUtil.disableSSLVerification(); + + RssClient rssClient = new RssClient(); + RssFeeds rssFeeds = rssClient.fromRssFeedBy("https://www.youtube.com/feeds/videos.xml?channel_id=UC-mOekGSesms0agFntnQang"); + + assertThat(rssFeeds.getRssFeeds()).isNotEmpty(); + } + + @Test + void fromVelogRssFeedBy() { + SSLUtil.disableSSLVerification(); + + RssClient rssClient = new RssClient(); + RssFeeds rssFeeds = rssClient.fromRssFeedBy("https://v2.velog.io/rss/junho5336"); + + assertThat(rssFeeds.getRssFeeds()).isNotEmpty(); + } + + @Test + void fromInvalidRssFeedBy() { + RssClient rssClient = new RssClient(); + RssFeeds rssFeeds = rssClient.fromRssFeedBy("https://v2.velog.io/rss/junho5336asdfasdf"); + + assertThat(rssFeeds.getRssFeeds()).isEmpty(); + } +} diff --git a/backend/src/test/java/wooteco/prolog/article/domain/ArticlesTest.java b/backend/src/test/java/wooteco/prolog/article/domain/ArticlesTest.java deleted file mode 100644 index 9e9f1c07c..000000000 --- a/backend/src/test/java/wooteco/prolog/article/domain/ArticlesTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package wooteco.prolog.article.domain; - -import org.junit.jupiter.api.Test; -import wooteco.prolog.article.application.SSLUtil; -import wooteco.prolog.member.domain.Member; - -import static org.assertj.core.api.Assertions.assertThat; -import static wooteco.prolog.member.domain.Role.CREW; - -class ArticlesTest { - @Test - void fromRssFeedBy() { - SSLUtil.disableSSLVerification(); - - final Member member = new Member(1L, "username", "nickname", CREW, 1L, "url", null, "https://lazypazy.tistory.com/rss"); - Articles articles = Articles.fromRssFeedBy(member); - assertThat(articles).isNotNull(); - assertThat(articles.getArticles()).hasSizeGreaterThan(1); - } -}