diff --git a/src/main/java/dgu/newsee/domain/news/controller/NewsController.java b/src/main/java/dgu/newsee/domain/crawlednews/controller/NewsController.java similarity index 74% rename from src/main/java/dgu/newsee/domain/news/controller/NewsController.java rename to src/main/java/dgu/newsee/domain/crawlednews/controller/NewsController.java index 9183c52..af14b12 100644 --- a/src/main/java/dgu/newsee/domain/news/controller/NewsController.java +++ b/src/main/java/dgu/newsee/domain/crawlednews/controller/NewsController.java @@ -1,9 +1,9 @@ -package dgu.newsee.domain.news.controller; +package dgu.newsee.domain.crawlednews.controller; -import dgu.newsee.domain.news.dto.NewsCrawlRequestDTO; -import dgu.newsee.domain.news.dto.NewsCrawlResponseDTO; -import dgu.newsee.domain.news.entity.News; -import dgu.newsee.domain.news.service.NewsService; +import dgu.newsee.domain.crawlednews.dto.NewsCrawlRequestDTO; +import dgu.newsee.domain.crawlednews.dto.NewsCrawlResponseDTO; +import dgu.newsee.domain.crawlednews.entity.NewsOrigin; +import dgu.newsee.domain.crawlednews.service.NewsService; import dgu.newsee.global.payload.ApiResponse; import dgu.newsee.global.payload.ResponseCode; import dgu.newsee.global.security.CustomUserDetails; @@ -30,10 +30,10 @@ public ApiResponse crawlNews( try { Long userId = userDetails.getUserId(); - News news = newsService.crawlAndSave(request, userId); + NewsOrigin newsOrigin = newsService.crawlAndSave(request, userId); return ApiResponse.success( - new NewsCrawlResponseDTO(news), + new NewsCrawlResponseDTO(newsOrigin), ResponseCode.COMMON_SUCCESS ); } catch (IllegalArgumentException e) { diff --git a/src/main/java/dgu/newsee/domain/news/dto/NewsCrawlRequestDTO.java b/src/main/java/dgu/newsee/domain/crawlednews/dto/NewsCrawlRequestDTO.java similarity index 68% rename from src/main/java/dgu/newsee/domain/news/dto/NewsCrawlRequestDTO.java rename to src/main/java/dgu/newsee/domain/crawlednews/dto/NewsCrawlRequestDTO.java index a57a56d..9d15d65 100644 --- a/src/main/java/dgu/newsee/domain/news/dto/NewsCrawlRequestDTO.java +++ b/src/main/java/dgu/newsee/domain/crawlednews/dto/NewsCrawlRequestDTO.java @@ -1,4 +1,4 @@ -package dgu.newsee.domain.news.dto; +package dgu.newsee.domain.crawlednews.dto; import lombok.Getter; diff --git a/src/main/java/dgu/newsee/domain/crawlednews/dto/NewsCrawlResponseDTO.java b/src/main/java/dgu/newsee/domain/crawlednews/dto/NewsCrawlResponseDTO.java new file mode 100644 index 0000000..a6ae173 --- /dev/null +++ b/src/main/java/dgu/newsee/domain/crawlednews/dto/NewsCrawlResponseDTO.java @@ -0,0 +1,28 @@ +package dgu.newsee.domain.crawlednews.dto; + +import dgu.newsee.domain.crawlednews.entity.NewsOrigin; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class NewsCrawlResponseDTO { + private String title; + private String content; + private String imageUrl; + private String category; + private String source; + private LocalDateTime time; + private Long newsId; + + public NewsCrawlResponseDTO(NewsOrigin newsOrigin) { + this.title = newsOrigin.getTitle(); + this.content = newsOrigin.getContent(); + this.imageUrl = newsOrigin.getImageUrl(); + this.category = newsOrigin.getCategory(); + this.source = newsOrigin.getSource(); + this.time = newsOrigin.getTime(); + this.newsId = newsOrigin.getId(); + } +} + diff --git a/src/main/java/dgu/newsee/domain/crawlednews/entity/CrawledNews.java b/src/main/java/dgu/newsee/domain/crawlednews/entity/NewsOrigin.java similarity index 71% rename from src/main/java/dgu/newsee/domain/crawlednews/entity/CrawledNews.java rename to src/main/java/dgu/newsee/domain/crawlednews/entity/NewsOrigin.java index b24c840..eeb47aa 100644 --- a/src/main/java/dgu/newsee/domain/crawlednews/entity/CrawledNews.java +++ b/src/main/java/dgu/newsee/domain/crawlednews/entity/NewsOrigin.java @@ -1,5 +1,4 @@ package dgu.newsee.domain.crawlednews.entity; - import dgu.newsee.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -11,7 +10,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -public class CrawledNews extends BaseEntity { +public class NewsOrigin extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -19,6 +18,10 @@ public class CrawledNews extends BaseEntity { private String title; + @Column(length = 1024) + private String imageUrl; + + @Lob private String content; @@ -29,4 +32,7 @@ public class CrawledNews extends BaseEntity { private LocalDateTime time; private String originalUrl; + + @Enumerated(EnumType.STRING) // DB에는 USER_INPUT, AUTO_CRAWLED로 저장됨 + private NewsStatus status; } diff --git a/src/main/java/dgu/newsee/domain/crawlednews/entity/NewsStatus.java b/src/main/java/dgu/newsee/domain/crawlednews/entity/NewsStatus.java new file mode 100644 index 0000000..365d708 --- /dev/null +++ b/src/main/java/dgu/newsee/domain/crawlednews/entity/NewsStatus.java @@ -0,0 +1,6 @@ +package dgu.newsee.domain.crawlednews.entity; + +public enum NewsStatus { + USER_INPUT, // 0 + AUTO_CRAWLED // 1 +} diff --git a/src/main/java/dgu/newsee/domain/crawlednews/repository/CrawledNewsRepository.java b/src/main/java/dgu/newsee/domain/crawlednews/repository/NewsRepository.java similarity index 53% rename from src/main/java/dgu/newsee/domain/crawlednews/repository/CrawledNewsRepository.java rename to src/main/java/dgu/newsee/domain/crawlednews/repository/NewsRepository.java index 98d931a..04ed6ae 100644 --- a/src/main/java/dgu/newsee/domain/crawlednews/repository/CrawledNewsRepository.java +++ b/src/main/java/dgu/newsee/domain/crawlednews/repository/NewsRepository.java @@ -1,8 +1,8 @@ package dgu.newsee.domain.crawlednews.repository; -import dgu.newsee.domain.crawlednews.entity.CrawledNews; +import dgu.newsee.domain.crawlednews.entity.NewsOrigin; import org.springframework.data.jpa.repository.JpaRepository; -public interface CrawledNewsRepository extends JpaRepository { +public interface NewsRepository extends JpaRepository { boolean existsByOriginalUrl(String url); } diff --git a/src/main/java/dgu/newsee/domain/crawlednews/service/CrawledNewsService.java b/src/main/java/dgu/newsee/domain/crawlednews/service/CrawledNewsService.java index d579990..2bc8f83 100644 --- a/src/main/java/dgu/newsee/domain/crawlednews/service/CrawledNewsService.java +++ b/src/main/java/dgu/newsee/domain/crawlednews/service/CrawledNewsService.java @@ -1,41 +1,53 @@ package dgu.newsee.domain.crawlednews.service; -import dgu.newsee.domain.crawlednews.entity.CrawledNews; -import dgu.newsee.domain.crawlednews.repository.CrawledNewsRepository; +import dgu.newsee.domain.crawlednews.entity.NewsOrigin; +import dgu.newsee.domain.crawlednews.entity.NewsStatus; +import dgu.newsee.domain.crawlednews.repository.NewsRepository; import dgu.newsee.domain.crawlednews.util.CrawledNewsCrawler; -import dgu.newsee.domain.crawlednews.util.CrawledNewsResult; +import dgu.newsee.domain.crawlednews.util.ParsedNews; +import dgu.newsee.domain.transformednews.service.TransformedNewsService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor public class CrawledNewsService { private final CrawledNewsCrawler crawler; - private final CrawledNewsRepository repository; + private final NewsRepository newsRepository; + private final TransformedNewsService transformedNewsService; + @Transactional public void crawlAndSave(String url, String category) { String normalizedUrl = url.replace("/comment", "").split("\\?")[0]; - if (repository.existsByOriginalUrl(normalizedUrl)) { + if (newsRepository.existsByOriginalUrl(normalizedUrl)) { System.out.println("중복된 뉴스 URL → 저장하지 않음: " + normalizedUrl); return; } try { - CrawledNewsResult result = crawler.crawl(normalizedUrl, category); - CrawledNews news = CrawledNews.builder() + ParsedNews result = crawler.crawl(normalizedUrl, category); + + NewsOrigin news = NewsOrigin.builder() .title(result.getTitle()) .content(result.getContent()) + .imageUrl((result.getImageUrl())) .category(result.getCategory()) .source(result.getSource()) .time(result.getTime()) .originalUrl(normalizedUrl) + .status(NewsStatus.AUTO_CRAWLED) .build(); - repository.save(news); + newsRepository.save(news); System.out.println("크롤링 및 저장 완료: " + normalizedUrl); + + transformedNewsService.requestTransformAndSaveAllLevels(news.getId(), NewsStatus.AUTO_CRAWLED); + } catch (Exception e) { System.err.println("크롤링 실패: " + normalizedUrl + " → " + e.getMessage()); } diff --git a/src/main/java/dgu/newsee/domain/news/service/NewsService.java b/src/main/java/dgu/newsee/domain/crawlednews/service/NewsService.java similarity index 56% rename from src/main/java/dgu/newsee/domain/news/service/NewsService.java rename to src/main/java/dgu/newsee/domain/crawlednews/service/NewsService.java index 8d0e324..8d95452 100644 --- a/src/main/java/dgu/newsee/domain/news/service/NewsService.java +++ b/src/main/java/dgu/newsee/domain/crawlednews/service/NewsService.java @@ -1,14 +1,14 @@ -package dgu.newsee.domain.news.service; +package dgu.newsee.domain.crawlednews.service; -import dgu.newsee.domain.news.dto.NewsCrawlRequestDTO; -import dgu.newsee.domain.news.entity.News; -import dgu.newsee.domain.news.repository.NewsRepository; -import dgu.newsee.domain.news.util.NewsCrawlResult; -import dgu.newsee.domain.news.util.NewsCrawler; +import dgu.newsee.domain.crawlednews.dto.NewsCrawlRequestDTO; +import dgu.newsee.domain.crawlednews.entity.NewsOrigin; +import dgu.newsee.domain.crawlednews.entity.NewsStatus; +import dgu.newsee.domain.crawlednews.repository.NewsRepository; +import dgu.newsee.domain.crawlednews.util.NewsCrawler; +import dgu.newsee.domain.crawlednews.util.ParsedNews; +import dgu.newsee.domain.transformednews.service.TransformedNewsService; import dgu.newsee.domain.user.entity.User; import dgu.newsee.domain.user.repository.UserRepository; -import dgu.newsee.domain.news.entity.SavedNews; -import dgu.newsee.domain.news.repository.SavedNewsRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,10 +20,10 @@ public class NewsService { private final NewsCrawler crawler; private final NewsRepository newsRepository; private final UserRepository userRepository; - private final SavedNewsRepository savedNewsRepository; + private final TransformedNewsService transformedService; @Transactional - public News crawlAndSave(NewsCrawlRequestDTO request, Long userId) { + public NewsOrigin crawlAndSave(NewsCrawlRequestDTO request, Long userId) { String url = request.getUrl(); // 중복 저장 방지 @@ -33,31 +33,31 @@ public News crawlAndSave(NewsCrawlRequestDTO request, Long userId) { try { // 뉴스 크롤링 - NewsCrawlResult result = crawler.crawl(url); + ParsedNews result = crawler.crawl(url); // News 객체 저장 - News news = News.builder() + NewsOrigin newsOrigin = NewsOrigin.builder() .title(result.getTitle()) .content(result.getContent()) + .imageUrl(result.getImageUrl()) .category(result.getCategory()) .source(result.getSource()) .time(result.getTime()) .originalUrl(url) + .status(NewsStatus.USER_INPUT) .build(); - newsRepository.save(news); + newsRepository.save(newsOrigin); + + transformedService.requestTransformAndSaveAllLevels( + newsOrigin.getId(), + NewsStatus.USER_INPUT + ); // 사용자 조회 User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - // 사용자와 뉴스 연결 (SavedNews 테이블에 저장) - SavedNews savedNews = SavedNews.builder() - .user(user) - .news(news) - .build(); - savedNewsRepository.save(savedNews); - - return news; + return newsOrigin; } catch (Exception e) { throw new RuntimeException("크롤링 실패: " + e.getMessage()); diff --git a/src/main/java/dgu/newsee/domain/crawlednews/util/CrawledNewsCrawler.java b/src/main/java/dgu/newsee/domain/crawlednews/util/CrawledNewsCrawler.java index 7023936..e81f6c1 100644 --- a/src/main/java/dgu/newsee/domain/crawlednews/util/CrawledNewsCrawler.java +++ b/src/main/java/dgu/newsee/domain/crawlednews/util/CrawledNewsCrawler.java @@ -5,30 +5,12 @@ import org.springframework.stereotype.Component; import java.io.IOException; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; @Component public class CrawledNewsCrawler { - public CrawledNewsResult crawl(String url, String category) throws IOException { + public ParsedNews crawl(String url, String category) throws IOException { Document doc = Jsoup.connect(url).get(); - - String title = doc.select("meta[property=og:title]").attr("content"); - String content = doc.select("#dic_area").text(); - String source = doc.select("meta[property=og:article:author]").attr("content"); - if (source.isBlank()) { - source = doc.select("meta[property=og:site_name]").attr("content"); - } - - String rawTime = doc.select("meta[property=og:article:published_time]").attr("content"); - LocalDateTime time; - try { - time = LocalDateTime.parse(rawTime, DateTimeFormatter.ISO_OFFSET_DATE_TIME); - } catch (Exception e) { - time = LocalDateTime.now(); - } - - return new CrawledNewsResult(title, content, category, source, time, url); + return NewsParserUtil.parse(doc, category, url); // 카테고리는 호출하는 쪽에서 지정 } } diff --git a/src/main/java/dgu/newsee/domain/crawlednews/util/NewsCrawler.java b/src/main/java/dgu/newsee/domain/crawlednews/util/NewsCrawler.java new file mode 100644 index 0000000..2c429a4 --- /dev/null +++ b/src/main/java/dgu/newsee/domain/crawlednews/util/NewsCrawler.java @@ -0,0 +1,16 @@ +package dgu.newsee.domain.crawlednews.util; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class NewsCrawler { + + public ParsedNews crawl(String url) throws IOException { + Document doc = Jsoup.connect(url).get(); + return NewsParserUtil.parse(doc, null, url); + } +} diff --git a/src/main/java/dgu/newsee/domain/crawlednews/util/NewsParserUtil.java b/src/main/java/dgu/newsee/domain/crawlednews/util/NewsParserUtil.java new file mode 100644 index 0000000..1049a22 --- /dev/null +++ b/src/main/java/dgu/newsee/domain/crawlednews/util/NewsParserUtil.java @@ -0,0 +1,99 @@ +package dgu.newsee.domain.crawlednews.util; + +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class NewsParserUtil { + + public static ParsedNews parse(Document doc, String categoryFromCaller, String url) { + // 제목 + String title = doc.select("meta[property=og:title]").attr("content"); + + // 본문 + String content = doc.select("#dic_area").text(); + + // 출처 + String source = doc.select("meta[property=og:article:author]").attr("content"); + if (source.isBlank()) { + source = doc.select("meta[property=og:site_name]").attr("content"); + } + + // 시간 + String rawTime = doc.select("meta[property=og:article:published_time]").attr("content"); + LocalDateTime time; + try { + time = LocalDateTime.parse(rawTime, DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } catch (Exception e) { + time = LocalDateTime.now(); + } + + // 대표 이미지 + String imageUrl = doc.select("meta[property=og:image]").attr("content"); + + if (imageUrl == null || imageUrl.isBlank()) { + try { + imageUrl = doc.select("img[src]").stream() + .map(e -> e.attr("src")) + .filter(src -> src.contains("imgnews.pstatic.net")) + .findFirst() + .orElse(null); + } catch (Exception e) { + // 무시 + } + } + System.out.println("대표 이미지 URL 최종: " + imageUrl); + + + + // 카테고리 유추 + String category = null; + + try { + // 1. 네이버 뉴스일 경우 카테고리 직접 파싱 시도 + Element selected = doc.selectFirst("a.Nitem_link_menu[aria-selected=true]"); + if (selected != null) { + category = selected.text(); // 예: 생활/문화 + } + + // 2. 그래도 null이면 백업으로 URL에서 유추 시도 + if (category == null || category.isBlank()) { + category = extractCategoryFromUrl(url); // sid 기반 + } + + // 3. 여전히 못찾으면 fallback + if (category == null || category.isBlank()) { + category = "기타"; + } + + } catch (Exception e) { + category = "기타"; + } + + + return new ParsedNews(title, content, category, source, time, url, imageUrl); + } + + private static String extractCategoryFromUrl(String url) { + try { + int sidIndex = url.indexOf("sid="); + if (sidIndex != -1) { + String sid = url.substring(sidIndex + 4, sidIndex + 7); + return switch (sid) { + case "100" -> "정치"; + case "101" -> "경제"; + case "102" -> "사회"; + case "103" -> "생활/문화"; + case "104" -> "세계"; + case "105" -> "IT/과학"; + default -> "기타"; + }; + } + } catch (Exception e) { + // 무시하고 "기타"로 처리 + } + return "기타"; + } +} diff --git a/src/main/java/dgu/newsee/domain/crawlednews/util/CrawledNewsResult.java b/src/main/java/dgu/newsee/domain/crawlednews/util/ParsedNews.java similarity index 85% rename from src/main/java/dgu/newsee/domain/crawlednews/util/CrawledNewsResult.java rename to src/main/java/dgu/newsee/domain/crawlednews/util/ParsedNews.java index 51fee87..d3cbb7c 100644 --- a/src/main/java/dgu/newsee/domain/crawlednews/util/CrawledNewsResult.java +++ b/src/main/java/dgu/newsee/domain/crawlednews/util/ParsedNews.java @@ -7,11 +7,12 @@ @Getter @AllArgsConstructor -public class CrawledNewsResult { +public class ParsedNews { private String title; private String content; private String category; private String source; private LocalDateTime time; private String url; + private String imageUrl; } diff --git a/src/main/java/dgu/newsee/domain/news/dto/NewsCrawlResponseDTO.java b/src/main/java/dgu/newsee/domain/news/dto/NewsCrawlResponseDTO.java deleted file mode 100644 index de13496..0000000 --- a/src/main/java/dgu/newsee/domain/news/dto/NewsCrawlResponseDTO.java +++ /dev/null @@ -1,26 +0,0 @@ -package dgu.newsee.domain.news.dto; - -import dgu.newsee.domain.news.entity.News; -import lombok.Getter; - -import java.time.LocalDateTime; - -@Getter -public class NewsCrawlResponseDTO { - private String title; - private String content; - private String category; - private String source; - private LocalDateTime time; - private Long newsId; - - public NewsCrawlResponseDTO(News news) { - this.title = news.getTitle(); - this.content = news.getContent(); - this.category = news.getCategory(); - this.source = news.getSource(); - this.time = news.getTime(); - this.newsId = news.getId(); - } -} - diff --git a/src/main/java/dgu/newsee/domain/news/entity/News.java b/src/main/java/dgu/newsee/domain/news/entity/News.java deleted file mode 100644 index ec40550..0000000 --- a/src/main/java/dgu/newsee/domain/news/entity/News.java +++ /dev/null @@ -1,31 +0,0 @@ -package dgu.newsee.domain.news.entity; -import dgu.newsee.global.common.BaseEntity; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class News extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String title; - - @Lob - private String content; - - private String category; - - private String source; - - private LocalDateTime time; - - private String originalUrl; -} diff --git a/src/main/java/dgu/newsee/domain/news/entity/SavedNews.java b/src/main/java/dgu/newsee/domain/news/entity/SavedNews.java deleted file mode 100644 index f9ab0bf..0000000 --- a/src/main/java/dgu/newsee/domain/news/entity/SavedNews.java +++ /dev/null @@ -1,34 +0,0 @@ -package dgu.newsee.domain.news.entity; - -import dgu.newsee.domain.news.entity.News; -import dgu.newsee.domain.user.entity.User; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@IdClass(SavedNewsId.class) -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class SavedNews { - - @Id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; - - @Id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "news_id") - private News news; - - private LocalDateTime savedAt; - - @PrePersist - protected void onCreate() { - this.savedAt = LocalDateTime.now(); - } -} \ No newline at end of file diff --git a/src/main/java/dgu/newsee/domain/news/entity/SavedNewsId.java b/src/main/java/dgu/newsee/domain/news/entity/SavedNewsId.java deleted file mode 100644 index 1036a79..0000000 --- a/src/main/java/dgu/newsee/domain/news/entity/SavedNewsId.java +++ /dev/null @@ -1,29 +0,0 @@ -package dgu.newsee.domain.news.entity; - -import java.io.Serializable; -import java.util.Objects; - -public class SavedNewsId implements Serializable { - private Long user; - private Long news; - - public SavedNewsId() {} - - public SavedNewsId(Long user, Long news) { - this.user = user; - this.news = news; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - SavedNewsId that = (SavedNewsId) o; - return Objects.equals(user, that.user) && Objects.equals(news, that.news); - } - - @Override - public int hashCode() { - return Objects.hash(user, news); - } -} \ No newline at end of file diff --git a/src/main/java/dgu/newsee/domain/news/repository/NewsRepository.java b/src/main/java/dgu/newsee/domain/news/repository/NewsRepository.java deleted file mode 100644 index 4b99a91..0000000 --- a/src/main/java/dgu/newsee/domain/news/repository/NewsRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package dgu.newsee.domain.news.repository; - -import dgu.newsee.domain.news.entity.News; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface NewsRepository extends JpaRepository { - boolean existsByOriginalUrl(String url); -} diff --git a/src/main/java/dgu/newsee/domain/news/repository/SavedNewsRepository.java b/src/main/java/dgu/newsee/domain/news/repository/SavedNewsRepository.java deleted file mode 100644 index 9023c93..0000000 --- a/src/main/java/dgu/newsee/domain/news/repository/SavedNewsRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package dgu.newsee.domain.news.repository; - -import dgu.newsee.domain.news.entity.SavedNews; -import dgu.newsee.domain.news.entity.SavedNewsId; -import dgu.newsee.domain.user.entity.User; -import dgu.newsee.domain.news.entity.News; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface SavedNewsRepository extends JpaRepository { - boolean existsByUserAndNews(User user, News news); -} diff --git a/src/main/java/dgu/newsee/domain/news/util/NewsCrawlResult.java b/src/main/java/dgu/newsee/domain/news/util/NewsCrawlResult.java deleted file mode 100644 index 166c37f..0000000 --- a/src/main/java/dgu/newsee/domain/news/util/NewsCrawlResult.java +++ /dev/null @@ -1,17 +0,0 @@ -package dgu.newsee.domain.news.util; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.time.LocalDateTime; - -@Getter -@AllArgsConstructor -public class NewsCrawlResult { - private String title; - private String content; - private String category; - private String source; - private LocalDateTime time; -} - diff --git a/src/main/java/dgu/newsee/domain/news/util/NewsCrawler.java b/src/main/java/dgu/newsee/domain/news/util/NewsCrawler.java deleted file mode 100644 index 53d5513..0000000 --- a/src/main/java/dgu/newsee/domain/news/util/NewsCrawler.java +++ /dev/null @@ -1,44 +0,0 @@ -package dgu.newsee.domain.news.util; - -import dgu.newsee.domain.news.util.NewsCrawlResult; -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; - -@Component -public class NewsCrawler { - - public NewsCrawlResult crawl(String url) throws IOException { - Document doc = Jsoup.connect(url).get(); - - // 제목 - String title = doc.select("meta[property=og:title]").attr("content"); - - // 본문 - String content = doc.select("#dic_area").text(); - - // 카테고리 추출 (네이버는 명확하게 드러나진 않음 → default로 지정) - String category = "기타"; - - // 출처 (언론사 이름) - String source = doc.select("meta[property=og:article:author]").attr("content"); - if (source.isBlank()) { - source = doc.select("meta[property=og:site_name]").attr("content"); // fallback - } - - // 작성 시간 (문자열 → LocalDateTime 파싱) - String rawTime = doc.select("meta[property=og:article:published_time]").attr("content"); - LocalDateTime time; - try { - time = LocalDateTime.parse(rawTime, DateTimeFormatter.ISO_OFFSET_DATE_TIME); - } catch (Exception e) { - time = LocalDateTime.now(); // fallback - } - - return new NewsCrawlResult(title, content, category, source, time); - } -} diff --git a/src/main/java/dgu/newsee/domain/transformednews/dto/ApiResponse.java b/src/main/java/dgu/newsee/domain/transformednews/dto/ApiResponse.java new file mode 100644 index 0000000..64fa56d --- /dev/null +++ b/src/main/java/dgu/newsee/domain/transformednews/dto/ApiResponse.java @@ -0,0 +1,18 @@ +package dgu.newsee.domain.transformednews.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ApiResponse { + private String code; + private String message; + private T result; + private boolean success; +} + diff --git a/src/main/java/dgu/newsee/domain/transformednews/dto/TransformRequestDTO.java b/src/main/java/dgu/newsee/domain/transformednews/dto/TransformRequestDTO.java new file mode 100644 index 0000000..5954c55 --- /dev/null +++ b/src/main/java/dgu/newsee/domain/transformednews/dto/TransformRequestDTO.java @@ -0,0 +1,13 @@ +package dgu.newsee.domain.transformednews.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +public class TransformRequestDTO { + private String title; + private String originalContent; + private String level; +} diff --git a/src/main/java/dgu/newsee/domain/transformednews/dto/TransformedNewsResponseDTO.java b/src/main/java/dgu/newsee/domain/transformednews/dto/TransformedNewsResponseDTO.java new file mode 100644 index 0000000..fafaf63 --- /dev/null +++ b/src/main/java/dgu/newsee/domain/transformednews/dto/TransformedNewsResponseDTO.java @@ -0,0 +1,30 @@ +package dgu.newsee.domain.transformednews.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class TransformedNewsResponseDTO { + private Long newsId; + private String level; + private String title; + private String transformedContent; + private String summarized; + private List difficultWords; + + @NoArgsConstructor + @Getter + @Builder + @AllArgsConstructor + public static class DifficultWordDTO { + private String term; + private String description; + } +} diff --git a/src/main/java/dgu/newsee/domain/transformednews/entity/NewsTransformed.java b/src/main/java/dgu/newsee/domain/transformednews/entity/NewsTransformed.java new file mode 100644 index 0000000..79898d0 --- /dev/null +++ b/src/main/java/dgu/newsee/domain/transformednews/entity/NewsTransformed.java @@ -0,0 +1,35 @@ +package dgu.newsee.domain.transformednews.entity; + +import dgu.newsee.domain.crawlednews.entity.NewsOrigin; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class NewsTransformed { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + private dgu.newsee.domain.crawlednews.entity.NewsStatus status; // 사용자/시스템 구분 + + @Column(name = "level", length = 10) + @Enumerated(EnumType.STRING) + private TransformLevel level; + + + + @Lob + private String transformedContent; + + private String summarized; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "news_id") + private NewsOrigin news; // +} diff --git a/src/main/java/dgu/newsee/domain/transformednews/entity/TransformLevel.java b/src/main/java/dgu/newsee/domain/transformednews/entity/TransformLevel.java new file mode 100644 index 0000000..88737eb --- /dev/null +++ b/src/main/java/dgu/newsee/domain/transformednews/entity/TransformLevel.java @@ -0,0 +1,26 @@ +package dgu.newsee.domain.transformednews.entity; + +public enum TransformLevel { + EASY("하"), + MEDIUM("중"), + HARD("상"); + + private final String kor; + + TransformLevel(String kor) { + this.kor = kor; + } + + public static TransformLevel fromKorean(String kor) { + for (TransformLevel level : TransformLevel.values()) { + if (level.kor.equals(kor)) { + return level; + } + } + throw new IllegalArgumentException("Unknown level: " + kor); + } + + public String getKorean() { + return kor; + } +} diff --git a/src/main/java/dgu/newsee/domain/transformednews/repository/NewsTransformedRepository.java b/src/main/java/dgu/newsee/domain/transformednews/repository/NewsTransformedRepository.java new file mode 100644 index 0000000..b8c9f9a --- /dev/null +++ b/src/main/java/dgu/newsee/domain/transformednews/repository/NewsTransformedRepository.java @@ -0,0 +1,8 @@ +package dgu.newsee.domain.transformednews.repository; + +import dgu.newsee.domain.transformednews.entity.NewsTransformed; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NewsTransformedRepository extends JpaRepository { + boolean existsByNewsId(Long newsId); +} diff --git a/src/main/java/dgu/newsee/domain/transformednews/service/TransformedNewsService.java b/src/main/java/dgu/newsee/domain/transformednews/service/TransformedNewsService.java new file mode 100644 index 0000000..74b572a --- /dev/null +++ b/src/main/java/dgu/newsee/domain/transformednews/service/TransformedNewsService.java @@ -0,0 +1,139 @@ +package dgu.newsee.domain.transformednews.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dgu.newsee.domain.crawlednews.entity.NewsOrigin; +import dgu.newsee.domain.crawlednews.entity.NewsStatus; +import dgu.newsee.domain.crawlednews.repository.NewsRepository; +import dgu.newsee.domain.transformednews.dto.ApiResponse; +import dgu.newsee.domain.transformednews.dto.TransformRequestDTO; +import dgu.newsee.domain.transformednews.dto.TransformedNewsResponseDTO; +import dgu.newsee.domain.transformednews.entity.NewsTransformed; +import dgu.newsee.domain.transformednews.entity.TransformLevel; +import dgu.newsee.domain.transformednews.repository.NewsTransformedRepository; +import dgu.newsee.domain.words.entity.Word; +import dgu.newsee.domain.words.repository.WordRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TransformedNewsService { + + private final NewsRepository newsRepository; + private final NewsTransformedRepository transformedRepository; + private final WordRepository wordRepository; + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${external.ai.url}") + private String aiServerUrl; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Transactional + public void requestTransformAndSaveAllLevels(Long newsId, NewsStatus status) { + for (String level : List.of("상", "중", "하")) { + requestTransformAndSave(newsId, level, status); + } + } + + @Transactional + public void requestTransformAndSave(Long newsId, String level, NewsStatus status) { + NewsOrigin news = newsRepository.findById(newsId) + .orElseThrow(() -> new RuntimeException("뉴스 없음")); + + TransformRequestDTO request = new TransformRequestDTO( + news.getTitle(), + news.getContent(), + level + ); + + // 요청 로그 출력 + try { + System.out.println("\n==== [AI 서버 요청 전송] ===="); + System.out.println("요청 URL: " + aiServerUrl); + System.out.println("요청 JSON: " + objectMapper.writeValueAsString(request)); + } catch (Exception e) { + System.out.println("요청 JSON 직렬화 실패: " + e.getMessage()); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(request, headers); + + ResponseEntity> response = null; + + try { + response = restTemplate.exchange( + aiServerUrl, + HttpMethod.POST, + entity, + new ParameterizedTypeReference<>() {} + ); + } catch (Exception e) { + //System.out.println("AI 서버 호출 중 예외 발생: " + e.getMessage()); + e.printStackTrace(); + throw new RuntimeException("AI 서버 호출 실패"); + } + + // 응답 로그 출력 + try { + System.out.println("==== [AI 서버 응답 수신] ===="); + if (response == null) { + //System.out.println("응답 객체가 null입니다."); + throw new RuntimeException("응답이 null"); + } + + //System.out.println("응답 상태 코드: " + response.getStatusCode()); + + ApiResponse apiResponse = response.getBody(); + + if (apiResponse == null) { + //System.out.println("응답 바디가 null입니다."); + throw new RuntimeException("response.getBody()가 null"); + } + + //System.out.println("응답 바디: " + objectMapper.writeValueAsString(apiResponse)); + + if (apiResponse.getResult() == null) { + //System.out.println("result 필드가 null입니다."); + throw new RuntimeException("AI 응답의 result가 null"); + } + + // 정상 응답 처리 + TransformedNewsResponseDTO result = apiResponse.getResult(); + + NewsTransformed transformed = NewsTransformed.builder() + .news(news) + .level(TransformLevel.fromKorean(result.getLevel())) + .transformedContent(result.getTransformedContent()) + .summarized(result.getSummarized()) + .status(status) + .build(); + transformedRepository.save(transformed); + //System.out.println("변환된 뉴스 저장 완료"); + + for (var wordDTO : result.getDifficultWords()) { + if (!wordRepository.existsByTerm(wordDTO.getTerm())) { + Word word = Word.builder() + .term(wordDTO.getTerm()) + .description(wordDTO.getDescription()) + .category(news.getCategory()) + .build(); + wordRepository.save(word); + } + } + + } catch (Exception e) { + //System.out.println("응답 처리 중 예외 발생: " + e.getMessage()); + e.printStackTrace(); + throw new RuntimeException("응답 처리 실패"); + } + } +} diff --git a/src/main/java/dgu/newsee/domain/words/repository/WordRepository.java b/src/main/java/dgu/newsee/domain/words/repository/WordRepository.java index 07b60ac..0142783 100644 --- a/src/main/java/dgu/newsee/domain/words/repository/WordRepository.java +++ b/src/main/java/dgu/newsee/domain/words/repository/WordRepository.java @@ -10,4 +10,6 @@ public interface WordRepository extends JpaRepository { List findByTermContainingOrDescriptionContaining(String termKeyword, String descKeyword); + + boolean existsByTerm(String term); } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7f428e5..d003256 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,4 +23,8 @@ spring: pool: size: 3 main: - allow-bean-definition-overriding: true \ No newline at end of file + allow-bean-definition-overriding: true\ + +external: + ai: + url: https://4a1efdb53fcb.ngrok-free.app/api/news/transfer \ No newline at end of file