diff --git a/BEconfig b/BEconfig index d2b98a3..d9afb7e 160000 --- a/BEconfig +++ b/BEconfig @@ -1 +1 @@ -Subproject commit d2b98a3431cf60541fb4be8fa52305f87bb499e6 +Subproject commit d9afb7e075350dad48889a3304d4f0b516aa9666 diff --git a/build.gradle b/build.gradle index 89dbb93..d1fab7c 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,8 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Jsoup for HTML parsing + implementation 'org.jsoup:jsoup:1.17.2' } diff --git a/src/main/java/com/perfact/be/domain/news/config/SelectorConfig.java b/src/main/java/com/perfact/be/domain/news/config/SelectorConfig.java new file mode 100644 index 0000000..5dc68bb --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/config/SelectorConfig.java @@ -0,0 +1,77 @@ +package com.perfact.be.domain.news.config; + +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class SelectorConfig { + + // 제목 추출 셀렉터 + public static final List TITLE_SELECTORS = List.of( + "#title_area span", // 네이버 뉴스의 일반적인 제목 셀렉터 + ".title_area .title", // 기존 셀렉터 + "h1", // 일반적인 제목 태그 + ".title", // 일반적인 제목 클래스 + "[class*=\"title\"]", // 제목 관련 클래스를 포함하는 요소 + "title" // HTML title 태그 + ); + + // 다른 뉴스 사이트 제목 셀렉터 + public static final List OTHER_NEWS_TITLE_SELECTORS = List.of( + "h1", + ".title", + ".headline", + ".article-title", + "title"); + + // 뉴스 내용 추출 셀렉터 + public static final List CONTENT_SELECTORS = List.of( + "#dic_area", // id 셀렉터 (가장 일반적) + ".dic_area", // 클래스 셀렉터 (기존) + "article", // article 태그 + "[id*=\"dic\"]", // dic을 포함하는 id + "[class*=\"article\"]" // article을 포함하는 클래스 + ); + + // 날짜 추출 셀렉터 + public static final List DATE_SELECTORS = List.of( + ".media_end_head_info_datestamp_time._ARTICLE_DATE_TIME", // 네이버 뉴스 기본 + "[class*='media_end_head_info_datestamp_time']", // 대체 셀렉터 + ".title_area .info .date", // 기존 셀렉터 + "time", // HTML5 time 태그 + ".date", // 일반적인 날짜 클래스 + ".article-date", // 기사 날짜 클래스 + "[class*=\"date\"]", // 날짜 관련 클래스를 포함하는 요소 + ".published-date", // 발행일 클래스 + ".article-time" // 기사 시간 클래스 + ); + + /** + * 제목 셀렉터 배열을 반환합니다. + */ + public String[] getTitleSelectors() { + return TITLE_SELECTORS.toArray(new String[0]); + } + + /** + * 다른 뉴스 사이트 제목 셀렉터 배열을 반환합니다. + */ + public String[] getOtherNewsTitleSelectors() { + return OTHER_NEWS_TITLE_SELECTORS.toArray(new String[0]); + } + + /** + * 내용 셀렉터 배열을 반환합니다. + */ + public String[] getContentSelectors() { + return CONTENT_SELECTORS.toArray(new String[0]); + } + + /** + * 날짜 셀렉터 배열을 반환합니다. + */ + public String[] getDateSelectors() { + return DATE_SELECTORS.toArray(new String[0]); + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/controller/NewsController.java b/src/main/java/com/perfact/be/domain/news/controller/NewsController.java new file mode 100644 index 0000000..cdc636f --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/controller/NewsController.java @@ -0,0 +1,29 @@ +package com.perfact.be.domain.news.controller; + +import com.perfact.be.domain.news.dto.NewsArticleResponse; +import com.perfact.be.domain.news.service.NewsService; +import com.perfact.be.global.apiPayload.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/news") +@RequiredArgsConstructor +public class NewsController { + + private final NewsService newsService; + + @GetMapping("/article-content") + public ApiResponse getNewsArticleContent(@RequestParam String url) { + NewsArticleResponse response = newsService.extractNaverNewsArticle(url); + return ApiResponse.onSuccess(response); + } + + @GetMapping("/search") + public ApiResponse searchNaverNews(@RequestParam String query) { + String searchResult = newsService.searchNaverNews(query); + return ApiResponse.onSuccess(searchResult); + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/converter/NewsConverter.java b/src/main/java/com/perfact/be/domain/news/converter/NewsConverter.java new file mode 100644 index 0000000..6f93494 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/converter/NewsConverter.java @@ -0,0 +1,53 @@ +package com.perfact.be.domain.news.converter; + +import com.perfact.be.domain.news.dto.NewsArticleResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * News 도메인의 데이터 변환을 담당하는 Converter + */ +@Slf4j +@Component +public class NewsConverter { + + /** + * 뉴스 데이터를 NewsArticleResponse로 변환 + */ + public NewsArticleResponse toNewsArticleResponse(String title, String date, String content) { + try { + return new NewsArticleResponse(title, date, content); + } catch (Exception e) { + throw new RuntimeException("NewsArticleResponse 변환 실패", e); + } + } + + /** + * 뉴스 데이터를 JSON 형태로 변환 + */ + public String toJsonString(NewsArticleResponse newsData) { + try { + return String.format( + "{\"title\": \"%s\", \"date\": \"%s\", \"content\": \"%s\"}", + escapeJsonString(newsData.getTitle()), + escapeJsonString(newsData.getDate()), + escapeJsonString(newsData.getContent())); + } catch (Exception e) { + throw new RuntimeException("JSON 변환 실패", e); + } + } + + /** + * JSON 문자열에서 특수문자 이스케이프 처리 + */ + private String escapeJsonString(String input) { + if (input == null) { + return ""; + } + return input.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/dto/NewsArticleResponse.java b/src/main/java/com/perfact/be/domain/news/dto/NewsArticleResponse.java new file mode 100644 index 0000000..edd4c60 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/dto/NewsArticleResponse.java @@ -0,0 +1,14 @@ +package com.perfact.be.domain.news.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class NewsArticleResponse { + private String title; + private String date; + private String content; +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/exception/NewsExceptionHandler.java b/src/main/java/com/perfact/be/domain/news/exception/NewsExceptionHandler.java new file mode 100644 index 0000000..ffecea9 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/exception/NewsExceptionHandler.java @@ -0,0 +1,62 @@ +package com.perfact.be.domain.news.exception; + +import com.perfact.be.domain.news.exception.status.NewsErrorStatus; +import com.perfact.be.global.exception.GeneralException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class NewsExceptionHandler { + + /** + * 뉴스 파싱 실패 시 예외를 처리합니다. + */ + public void handleParsingFailure(String url, String operation, Exception e) { + log.error("Failed to {} for URL: {}", operation, url, e); + throw new GeneralException(NewsErrorStatus.NEWS_ARTICLE_PARSING_FAILED); + } + + /** + * 뉴스 내용을 찾을 수 없을 때 예외를 처리합니다. + */ + public void handleContentNotFound(String url, String operation) { + log.warn("Content not found during {} for URL: {}", operation, url); + throw new GeneralException(NewsErrorStatus.NEWS_CONTENT_NOT_FOUND); + } + + /** + * 뉴스 제목 추출 실패 시 예외를 처리합니다. + */ + public void handleTitleExtractionFailure(String url, String operation, Exception e) { + log.error("Failed to extract title during {} for URL: {}", operation, url, e); + throw new GeneralException(NewsErrorStatus.NEWS_TITLE_EXTRACTION_FAILED); + } + + /** + * 네이버 API 호출 실패 시 예외를 처리합니다. + */ + public void handleNaverApiFailure(String query, Exception e) { + log.error("Failed to call Naver API for query: {}", query, e); + throw new GeneralException(NewsErrorStatus.NEWS_NAVER_API_CALL_FAILED); + } + + /** + * 안전한 텍스트 추출을 수행합니다. 실패 시 null을 반환합니다. + */ + public String safeExtractText(String url, String operation, TextExtractor extractor) { + try { + return extractor.extract(); + } catch (Exception e) { + return null; + } + } + + /** + * 텍스트 추출을 위한 함수형 인터페이스 + */ + @FunctionalInterface + public interface TextExtractor { + String extract() throws Exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/exception/status/NewsErrorStatus.java b/src/main/java/com/perfact/be/domain/news/exception/status/NewsErrorStatus.java new file mode 100644 index 0000000..746e342 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/exception/status/NewsErrorStatus.java @@ -0,0 +1,42 @@ +package com.perfact.be.domain.news.exception.status; + +import com.perfact.be.global.apiPayload.code.BaseErrorCode; +import com.perfact.be.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum NewsErrorStatus implements BaseErrorCode { + NOT_NAVER_NEWS(HttpStatus.BAD_REQUEST, "NEWS4001", "네이버 뉴스 도메인이 아닙니다. 네이버 뉴스를 통한 링크만 가능합니다."), + NEWS_CONTENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "NEWS4002", "뉴스 내용을 찾을 수 없습니다."), + NEWS_TITLE_EXTRACTION_FAILED(HttpStatus.BAD_REQUEST, "NEWS4003", "뉴스 제목 추출에 실패했습니다."), + NEWS_DATE_EXTRACTION_FAILED(HttpStatus.BAD_REQUEST, "NEWS4004", "뉴스 날짜 추출에 실패했습니다."), + NEWS_ARTICLE_PARSING_FAILED(HttpStatus.BAD_REQUEST, "NEWS4005", "뉴스 기사 파싱에 실패했습니다."), + NEWS_NAVER_API_CALL_FAILED(HttpStatus.BAD_REQUEST, "NEWS4006", "네이버 API 호출에 실패했습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .message(message) + .code(code) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/service/DateExtractorService.java b/src/main/java/com/perfact/be/domain/news/service/DateExtractorService.java new file mode 100644 index 0000000..8963ade --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/service/DateExtractorService.java @@ -0,0 +1,7 @@ +package com.perfact.be.domain.news.service; + +// 날짜 추출 +public interface DateExtractorService { + + String extractArticleDate(String url); +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/service/DateExtractorServiceImpl.java b/src/main/java/com/perfact/be/domain/news/service/DateExtractorServiceImpl.java new file mode 100644 index 0000000..b03fcc7 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/service/DateExtractorServiceImpl.java @@ -0,0 +1,77 @@ +package com.perfact.be.domain.news.service; + +import lombok.RequiredArgsConstructor; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.springframework.stereotype.Service; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +public class DateExtractorServiceImpl implements DateExtractorService { + + private final HtmlParserService htmlParserService; + private final com.perfact.be.domain.news.config.SelectorConfig selectorConfig; + + @Override + public String extractArticleDate(String url) { + try { + Document doc = htmlParserService.getHtmlFromUrl(url); + + // media_end_head_info_datestamp_time _ARTICLE_DATE_TIME 클래스를 가진 요소 찾기 + Element dateElement = doc.selectFirst(".media_end_head_info_datestamp_time._ARTICLE_DATE_TIME"); + + if (dateElement == null) { + // 대체 선택자 시도 + dateElement = doc.selectFirst("[class*='media_end_head_info_datestamp_time']"); + } + + if (dateElement == null) { + // 여러 CSS 셀렉터를 시도하여 날짜 추출 + String[] dateSelectors = selectorConfig.getDateSelectors(); + + for (String selector : dateSelectors) { + dateElement = doc.selectFirst(selector); + if (dateElement != null) { + String date = dateElement.text().trim(); + if (!date.isEmpty()) { + return date; + } + } + } + + return "날짜 정보 없음"; + } + + return extractDateFromElement(dateElement); + + } catch (Exception e) { + return "날짜 정보 없음"; + } + } + + // 날짜 파싱 + private String extractDateFromElement(Element dateElement) { + String dataDateTime = dateElement.attr("data-date-time"); + if (!dataDateTime.isEmpty()) { + String[] parts = dataDateTime.split(" "); + if (parts.length > 0) { + String datePart = parts[0]; + return datePart.replace("-", "."); + } + } + + String text = dateElement.text(); + if (!text.isEmpty()) { + Pattern pattern = Pattern.compile("(\\d{4}\\.\\d{2}\\.\\d{2})"); + Matcher matcher = pattern.matcher(text); + if (matcher.find()) { + return matcher.group(1); + } + } + + return "날짜 정보 없음"; + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/service/HtmlParserService.java b/src/main/java/com/perfact/be/domain/news/service/HtmlParserService.java new file mode 100644 index 0000000..f2425bb --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/service/HtmlParserService.java @@ -0,0 +1,20 @@ +package com.perfact.be.domain.news.service; + +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; + +// HTML 파싱 +public interface HtmlParserService { + + // URL에서 HTML 가져오기 + Document getHtmlFromUrl(String url); + + // CSS 셀렉터로 요소 특정 + Element extractElementBySelector(String url, String cssSelector); + + // CSS 셀렉터로 텍스트 추출 + String extractTextFromElement(String url, String cssSelector); + + // 여러 CSS 셀렉터를 시도하여 텍스트 추출 + String extractTextWithMultipleSelectors(String url, String[] selectors); +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/service/HtmlParserServiceImpl.java b/src/main/java/com/perfact/be/domain/news/service/HtmlParserServiceImpl.java new file mode 100644 index 0000000..268f3af --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/service/HtmlParserServiceImpl.java @@ -0,0 +1,65 @@ +package com.perfact.be.domain.news.service; + +import com.perfact.be.domain.news.exception.status.NewsErrorStatus; +import com.perfact.be.global.exception.GeneralException; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.springframework.stereotype.Service; + +import java.io.IOException; + +@Service +public class HtmlParserServiceImpl implements HtmlParserService { + + private static final String USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + + @Override + public Document getHtmlFromUrl(String url) { + try { + Document doc = Jsoup.connect(url) + .userAgent(USER_AGENT) + .timeout(10000) + .get(); + + return doc; + + } catch (IOException e) { + throw new GeneralException(NewsErrorStatus.NEWS_ARTICLE_PARSING_FAILED); + } + } + + @Override + public Element extractElementBySelector(String url, String cssSelector) { + try { + Document doc = getHtmlFromUrl(url); + Element element = doc.selectFirst(cssSelector); + + if (element == null) { + return null; + } + + return element; + + } catch (Exception e) { + throw new GeneralException(NewsErrorStatus.NEWS_ARTICLE_PARSING_FAILED); + } + } + + @Override + public String extractTextFromElement(String url, String cssSelector) { + Element element = extractElementBySelector(url, cssSelector); + return element != null ? element.text().trim() : null; + } + + @Override + public String extractTextWithMultipleSelectors(String url, String[] selectors) { + for (String selector : selectors) { + String text = extractTextFromElement(url, selector); + if (text != null && !text.trim().isEmpty()) { + return text; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/service/NaverApiService.java b/src/main/java/com/perfact/be/domain/news/service/NaverApiService.java new file mode 100644 index 0000000..00b9b87 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/service/NaverApiService.java @@ -0,0 +1,8 @@ +package com.perfact.be.domain.news.service; + +// 네이버 API 호출 +public interface NaverApiService { + + // 네이버 뉴스 검색 + String searchNaverNews(String query); +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/service/NaverApiServiceImpl.java b/src/main/java/com/perfact/be/domain/news/service/NaverApiServiceImpl.java new file mode 100644 index 0000000..36fba25 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/service/NaverApiServiceImpl.java @@ -0,0 +1,64 @@ +package com.perfact.be.domain.news.service; + +import com.perfact.be.domain.news.exception.status.NewsErrorStatus; +import com.perfact.be.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Service +@RequiredArgsConstructor +public class NaverApiServiceImpl implements NaverApiService { + + private final RestTemplate restTemplate; + + @Value("${api.naver.search-url}") + private String naverSearchUrl; + + @Value("${api.naver.client-id}") + private String naverClientId; + + @Value("${api.naver.client-secret}") + private String naverClientSecret; + + @Override + public String searchNaverNews(String query) { + try { + String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); + String searchUrl = String.format("%s?query=%s&display=10&start=1&sort=sim", + naverSearchUrl, encodedQuery); + + HttpHeaders headers = createNaverHeaders(); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + searchUrl, HttpMethod.GET, entity, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + return response.getBody(); + } else { + throw new GeneralException(NewsErrorStatus.NEWS_NAVER_API_CALL_FAILED); + } + + } catch (Exception e) { + throw new GeneralException(NewsErrorStatus.NEWS_NAVER_API_CALL_FAILED); + } + } + + // 네이버 API 헤더 생성 + private HttpHeaders createNaverHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Naver-Client-Id", naverClientId); + headers.set("X-Naver-Client-Secret", naverClientSecret); + return headers; + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/service/NewsExtractorService.java b/src/main/java/com/perfact/be/domain/news/service/NewsExtractorService.java new file mode 100644 index 0000000..f6d9c17 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/service/NewsExtractorService.java @@ -0,0 +1,11 @@ +package com.perfact.be.domain.news.service; + +// 뉴스 내용 추출 +public interface NewsExtractorService { + + // 뉴스 기사 내용 추출 + String extractNewsArticleContent(String url); + + // 다른 뉴스 사이트 제목 추출 + String extractTitleFromOtherNewsSites(String url); +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/service/NewsExtractorServiceImpl.java b/src/main/java/com/perfact/be/domain/news/service/NewsExtractorServiceImpl.java new file mode 100644 index 0000000..7c91523 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/service/NewsExtractorServiceImpl.java @@ -0,0 +1,107 @@ +package com.perfact.be.domain.news.service; + +import com.perfact.be.domain.news.exception.status.NewsErrorStatus; +import com.perfact.be.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class NewsExtractorServiceImpl implements NewsExtractorService { + + private final HtmlParserService htmlParserService; + private final com.perfact.be.domain.news.config.SelectorConfig selectorConfig; + + // 뉴스 기사 내용 추출 + @Override + public String extractNewsArticleContent(String url) { + try { + Document doc = htmlParserService.getHtmlFromUrl(url); + StringBuilder content = new StringBuilder(); + + Element titleArea = doc.selectFirst(".title_area .title"); + if (titleArea != null) { + content.append("제목: ").append(titleArea.text().trim()).append("\n\n"); + } + + String extractedContent = extractContentFromDocument(doc); + if (!extractedContent.trim().isEmpty()) { + content.append(extractedContent); + } + + return content.toString(); + + } catch (Exception e) { + throw new GeneralException(NewsErrorStatus.NEWS_CONTENT_NOT_FOUND); + } + } + + // 뉴스 기사 내용 추출 + private String extractContentFromDocument(Document doc) { + String[] contentSelectors = selectorConfig.getContentSelectors(); + + for (String selector : contentSelectors) { + Element dicArea = doc.selectFirst(selector); + if (dicArea != null) { + String extractedContent = processDicArea(dicArea); + if (!extractedContent.trim().isEmpty()) { + return extractedContent; + } + } + } + + return ""; + } + + // 뉴스 기사 내용 추출 + private String processDicArea(Element dicArea) { + StringBuilder content = new StringBuilder(); + + // 먼저 p 태그들을 처리 + Elements paragraphs = dicArea.select("p"); + for (Element p : paragraphs) { + String text = p.text().trim(); + if (!text.isEmpty()) { + content.append(text).append("\n\n"); + } + } + + // li 태그들을 처리 + Elements listItems = dicArea.select("li"); + for (Element li : listItems) { + String text = li.text().trim(); + if (!text.isEmpty()) { + content.append("• ").append(text).append("\n"); + } + } + + // p, li 태그가 없는 경우 전체 텍스트를 추출 + if (content.length() == 0) { + String fullText = dicArea.text().trim(); + if (!fullText.isEmpty()) { + //
태그를 줄바꿈으로 변환 + String processedText = fullText.replaceAll("\\s+", " ").trim(); + content.append(processedText); + } + } + + return content.toString(); + } + + // 다른 뉴스 사이트 제목 추출 + @Override + public String extractTitleFromOtherNewsSites(String url) { + try { + String[] titleSelectors = selectorConfig.getOtherNewsTitleSelectors(); + + String title = htmlParserService.extractTextWithMultipleSelectors(url, titleSelectors); + return title != null ? title : "제목을 찾을 수 없습니다"; + + } catch (Exception e) { + return "제목을 찾을 수 없습니다"; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/service/NewsService.java b/src/main/java/com/perfact/be/domain/news/service/NewsService.java new file mode 100644 index 0000000..25d88a5 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/service/NewsService.java @@ -0,0 +1,24 @@ +package com.perfact.be.domain.news.service; + +import com.perfact.be.domain.news.dto.NewsArticleResponse; + +public interface NewsService { + + // URL에서 HTML 가져오기 + org.jsoup.nodes.Document getHtmlFromUrl(String url); + + // 네이버 뉴스 도메인인지 확인 + boolean isNaverNewsDomain(String url); + + // 네이버 뉴스의 제목과 내용 추출 + NewsArticleResponse extractNaverNewsArticle(String url); + + // 뉴스 기사 내용 추출 + String extractNewsArticleContent(String url); + + // 다른 뉴스 사이트 제목 추출 + String extractTitleFromOtherNewsSites(String url); + + // 네이버 뉴스 검색 + String searchNaverNews(String query); +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/news/service/NewsServiceImpl.java b/src/main/java/com/perfact/be/domain/news/service/NewsServiceImpl.java new file mode 100644 index 0000000..7244c0b --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/service/NewsServiceImpl.java @@ -0,0 +1,77 @@ +package com.perfact.be.domain.news.service; + +import com.perfact.be.domain.news.dto.NewsArticleResponse; +import com.perfact.be.domain.news.exception.NewsExceptionHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class NewsServiceImpl implements NewsService { + + private final HtmlParserService htmlParserService; + private final NaverApiService naverApiService; + private final NewsExtractorService newsExtractorService; + private final DateExtractorService dateExtractorService; + private final NewsExceptionHandler exceptionHandler; + private final com.perfact.be.domain.news.config.SelectorConfig selectorConfig; + + @Override + public org.jsoup.nodes.Document getHtmlFromUrl(String url) { + return htmlParserService.getHtmlFromUrl(url); + } + + private String extractTitleAreaText(String url) { + return exceptionHandler.safeExtractText(url, "extract title", () -> { + String[] titleSelectors = selectorConfig.getTitleSelectors(); + + for (String selector : titleSelectors) { + String title = htmlParserService.extractTextFromElement(url, selector); + if (title != null && !title.trim().isEmpty()) { + return title; + } + } + + return null; + }); + } + + @Override + public String extractNewsArticleContent(String url) { + return newsExtractorService.extractNewsArticleContent(url); + } + + @Override + public boolean isNaverNewsDomain(String url) { + return url.contains("news.naver.com"); + } + + @Override + public NewsArticleResponse extractNaverNewsArticle(String url) { + try { + String title = extractTitleAreaText(url); + if (title == null) { + exceptionHandler.handleTitleExtractionFailure(url, "extract Naver news article", + new Exception("Title extraction failed")); + } + + String date = dateExtractorService.extractArticleDate(url); + String content = extractNewsArticleContent(url); + + return new NewsArticleResponse(title, date, content); + + } catch (Exception e) { + return null; + } + } + + @Override + public String extractTitleFromOtherNewsSites(String url) { + return newsExtractorService.extractTitleFromOtherNewsSites(url); + } + + @Override + public String searchNaverNews(String query) { + return naverApiService.searchNaverNews(query); + } +} diff --git a/src/main/java/com/perfact/be/domain/report/controller/ReportController.java b/src/main/java/com/perfact/be/domain/report/controller/ReportController.java new file mode 100644 index 0000000..1ef9324 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/report/controller/ReportController.java @@ -0,0 +1,41 @@ +package com.perfact.be.domain.report.controller; + +import com.perfact.be.domain.report.dto.AnalyzeNewsRequestDTO; +import com.perfact.be.domain.report.entity.Report; +import com.perfact.be.domain.report.service.ReportService; +import com.perfact.be.domain.user.entity.User; +import com.perfact.be.global.apiPayload.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/report") +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + + @PostMapping("") + public ApiResponse analyzeNewsWithClova(@RequestBody AnalyzeNewsRequestDTO request) { + Object analysisResult = reportService.analyzeNewsWithClova(request.getUrl()); + return ApiResponse.onSuccess(analysisResult); + } + + @PostMapping("/create") + public ApiResponse createReport(@RequestBody AnalyzeNewsRequestDTO request) { + // 실제 사용자 정보는 인증에서 가져와야 함 + User mockUser = User.builder() + .socialId("test_user") + .socialType("naver") + .name("테스트 사용자") + .email("test@naver.com") + .build(); + + Object analysisResult = reportService.analyzeNewsWithClova(request.getUrl()); + Report report = reportService.createReport(mockUser, request.getUrl(), analysisResult); + + return ApiResponse.onSuccess(report); + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/report/converter/ReportConverter.java b/src/main/java/com/perfact/be/domain/report/converter/ReportConverter.java new file mode 100644 index 0000000..2bb7a56 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/report/converter/ReportConverter.java @@ -0,0 +1,92 @@ +package com.perfact.be.domain.report.converter; + +import com.perfact.be.domain.news.dto.NewsArticleResponse; +import com.perfact.be.domain.report.dto.ClovaResponseDTO; +import com.perfact.be.domain.report.entity.Report; +import com.perfact.be.domain.user.entity.User; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Slf4j +@Component +public class ReportConverter { + + // Clova API 응답과 뉴스 데이터를 Report 엔티티로 변환 + public Report toReport(ClovaResponseDTO clovaResponse, NewsArticleResponse newsData, User user, String url) { + try { + String category = extractCategoryFromAnalysis(clovaResponse); + String summary = extractSummaryFromAnalysis(clovaResponse); + String publisher = extractPublisherFromUrl(url); + + return Report.builder() + .user(user) + .title(newsData.getTitle()) + .category(category) + .url(url) + .publisher(publisher) + .publicationDate(parsePublicationDate(newsData.getDate())) + .summary(summary) + .build(); + } catch (Exception e) { + throw new RuntimeException("리포트 변환 실패", e); + } + } + + // 분석 결과에서 카테고리 추출 + private String extractCategoryFromAnalysis(ClovaResponseDTO clovaResponse) { + try { + String content = clovaResponse.getResult().getMessage().getContent(); + if (content.contains("\"field\"")) { + int start = content.indexOf("\"field\"") + 8; + int end = content.indexOf("\"", start); + if (start > 7 && end > start) { + return content.substring(start, end); + } + } + return "기타"; + } catch (Exception e) { + return "기타"; + } + } + + // 분석 결과에서 요약 추출 + private String extractSummaryFromAnalysis(ClovaResponseDTO clovaResponse) { + try { + String content = clovaResponse.getResult().getMessage().getContent(); + if (content.contains("\"summary\"")) { + int start = content.indexOf("\"summary\"") + 10; + int end = content.indexOf("]", start); + if (start > 9 && end > start) { + return content.substring(start, end).replaceAll("\"", "").replaceAll(",", "\n"); + } + } + return ""; + } catch (Exception e) { + return ""; + } + } + + // URL에서 출판사 추출 + private String extractPublisherFromUrl(String url) { + try { + String domain = url.replaceAll("https?://", "").replaceAll("www\\.", ""); + return domain.split("/")[0]; + } catch (Exception e) { + return "unknown"; + } + } + + // 날짜 문자열을 LocalDate로 변환 + private LocalDate parsePublicationDate(String dateStr) { + try { + if (dateStr == null || dateStr.equals("날짜 정보 없음")) { + return LocalDate.now(); + } + return LocalDate.parse(dateStr.substring(0, 10)); + } catch (Exception e) { + return LocalDate.now(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/report/dto/AnalyzeNewsRequestDTO.java b/src/main/java/com/perfact/be/domain/report/dto/AnalyzeNewsRequestDTO.java new file mode 100644 index 0000000..cc2c260 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/report/dto/AnalyzeNewsRequestDTO.java @@ -0,0 +1,14 @@ +package com.perfact.be.domain.report.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class AnalyzeNewsRequestDTO { + private String url; +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/report/dto/ClovaRequestDTO.java b/src/main/java/com/perfact/be/domain/report/dto/ClovaRequestDTO.java new file mode 100644 index 0000000..1559ad7 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/report/dto/ClovaRequestDTO.java @@ -0,0 +1,33 @@ +package com.perfact.be.domain.report.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ClovaRequestDTO { + private List messages; + private double topP; + private int topK; + private int maxTokens; + private double temperature; + private double repetitionPenalty; + private List stop; + private int seed; + private boolean includeAiFilters; + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class Message { + private String role; + private String content; + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/report/dto/ClovaResponseDTO.java b/src/main/java/com/perfact/be/domain/report/dto/ClovaResponseDTO.java new file mode 100644 index 0000000..2c6525f --- /dev/null +++ b/src/main/java/com/perfact/be/domain/report/dto/ClovaResponseDTO.java @@ -0,0 +1,69 @@ +package com.perfact.be.domain.report.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ClovaResponseDTO { + private Status status; + private Result result; + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class Status { + private String code; + private String message; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class Result { + private Message message; + private String finishReason; + private long created; + private long seed; + private Usage usage; + private List aiFilter; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class Message { + private String role; + private String content; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class Usage { + private int promptTokens; + private int completionTokens; + private int totalTokens; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class AiFilter { + private String groupName; + private String name; + private String score; + private String result; + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/report/entity/Report.java b/src/main/java/com/perfact/be/domain/report/entity/Report.java new file mode 100644 index 0000000..a179160 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/report/entity/Report.java @@ -0,0 +1,57 @@ +package com.perfact.be.domain.report.entity; + +import com.perfact.be.domain.user.entity.User; +import com.perfact.be.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "reports") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Report extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "report_id") + private Long reportId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "title", length = 255) + private String title; + + @Column(name = "category", length = 255) + private String category; + + @Column(name = "url", length = 1000) + private String url; + + @Column(name = "publisher", length = 50) + private String publisher; + + @Column(name = "publication_date") + private LocalDate publicationDate; + + @Column(name = "summary", columnDefinition = "TEXT") + private String summary; + + @Builder + public Report(User user, String title, String category, String url, + String publisher, LocalDate publicationDate, String summary) { + this.user = user; + this.title = title; + this.category = category; + this.url = url; + this.publisher = publisher; + this.publicationDate = publicationDate; + this.summary = summary; + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/report/exception/ReportHandler.java b/src/main/java/com/perfact/be/domain/report/exception/ReportHandler.java new file mode 100644 index 0000000..fc42813 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/report/exception/ReportHandler.java @@ -0,0 +1,10 @@ +package com.perfact.be.domain.report.exception; + +import com.perfact.be.global.apiPayload.code.BaseErrorCode; +import com.perfact.be.global.exception.GeneralException; + +public class ReportHandler extends GeneralException { + public ReportHandler(BaseErrorCode code) { + super(code); + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/report/exception/status/ReportErrorStatus.java b/src/main/java/com/perfact/be/domain/report/exception/status/ReportErrorStatus.java new file mode 100644 index 0000000..15b6dad --- /dev/null +++ b/src/main/java/com/perfact/be/domain/report/exception/status/ReportErrorStatus.java @@ -0,0 +1,41 @@ +package com.perfact.be.domain.report.exception.status; + +import com.perfact.be.global.apiPayload.code.BaseErrorCode; +import com.perfact.be.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ReportErrorStatus implements BaseErrorCode { + REPORT_CREATION_FAILED(HttpStatus.BAD_REQUEST, "REPORT4001", "리포트 생성에 실패했습니다."), + CLOVA_API_CALL_FAILED(HttpStatus.BAD_REQUEST, "REPORT4002", "Clova API 호출에 실패했습니다."), + ANALYSIS_RESULT_PARSING_FAILED(HttpStatus.BAD_REQUEST, "REPORT4003", "분석 결과 파싱에 실패했습니다."), + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "REPORT4004", "리포트를 찾을 수 없습니다."), + REPORT_CONVERSION_FAILED(HttpStatus.BAD_REQUEST, "REPORT4005", "리포트 변환에 실패했습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .message(message) + .code(code) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/report/repository/ReportRepository.java b/src/main/java/com/perfact/be/domain/report/repository/ReportRepository.java new file mode 100644 index 0000000..26f6c6d --- /dev/null +++ b/src/main/java/com/perfact/be/domain/report/repository/ReportRepository.java @@ -0,0 +1,16 @@ +package com.perfact.be.domain.report.repository; + +import com.perfact.be.domain.report.entity.Report; +import com.perfact.be.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ReportRepository extends JpaRepository { + + List findByUserOrderByCreatedAtDesc(User user); + + List findByUserAndCategoryOrderByCreatedAtDesc(User user, String category); +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/report/service/PromptService.java b/src/main/java/com/perfact/be/domain/report/service/PromptService.java new file mode 100644 index 0000000..349ede5 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/report/service/PromptService.java @@ -0,0 +1,216 @@ +package com.perfact.be.domain.report.service; + +import com.perfact.be.domain.report.dto.ClovaRequestDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PromptService { + + private static final String SYSTEM_PROMPT = """ + # Role & Goal + You are an expert AI news analyst named "Perfact". Your primary goal is to analyze a given news article and return a highly-detailed, structured report in a strict JSON format. + + # Input Article Structure + The user will provide an article. It may or may not explicitly state the source. Infer the source if possible, otherwise state it's unknown. + + # Analysis Process & Output Instruction + Analyze the article following these steps and generate a JSON object as the final output. The output MUST be ONLY the JSON object. + + ### Step 1: Classify Field & Topic + - **field**: Classify the article into one of the following 6 categories: "정치", "경제", "사회", "생활/문화", "IT/과학", "세계". + - **topic**: Summarize the main topic of the article in a single, concise sentence. + + ### Step 2: Extract Source and Summarize + - **source**: Identify the news source from the article. If not available, state "출처 불명". + - **summary**: Provide a neutral summary of the article's core facts in 3-4 bullet points. + + ### Step 3: Perform Reliability Analysis + - **reliability_analysis**: Evaluate the article based on the 5 criteria below. For each criterion, provide an integer score (0-100) and a brief `reason` for that score. + 1. **출처 신뢰성**: Credibility of the source press and reporter. + 2. **사실 근거**: Use of objective evidence (statistics, expert quotes) vs. vague sources. + 3. **광고/과장 표현**: Presence of promotional or sensational language. + 4. **편향성**: Balanced viewpoints vs. one-sided arguments. + 5. **기사 형식**: Overall quality of writing, structure, and title accuracy. + - **total_score**: Calculate the average of the 5 scores above. + + ### Step 4: Grant AI Badges + - **ai_badges**: You MUST select a minimum of 1 and a maximum of 2 badges. From the list below, choose the badges that best represent the article's primary characteristics. If no badge is a perfect match, select the one(s) that are most closely related. This is a qualitative diagnosis separate from the scores. + - **공신력 있는 출처**: Major press (KBS, SBS, 연합뉴스 etc.), reporter's real name mentioned. + - **균형 잡힌 기사**: Includes diverse and opposing viewpoints (e.g., pros and cons). + - **주의 환기 우수**: Emphasizes and provides evidence for social risks (e.g., side effects). + - **부분적인 신뢰 가능**: The source is credible, but the perspective is biased or only presents partial facts. + - **전문가 인용 없음**: Cites only personal experiences without quotes from doctors, professors, researchers, etc. + - **광고성 기사**: Focuses on promoting a specific product/service, includes reviews, mentions brand commerce. + - **사실 검증 불가**: Based on unverifiable sources like "SNS reviews", "netizen reactions", anonymous interviews. + - **신뢰 불가**: Unregistered press, suspected false information, misleading headlines. + - **광고 목적**: Directly encourages purchase with phrases like "purchase link", "inquiry", "first-come, first-served". + - **과장 표현 다수**: Repeated use of extreme/definitive language like "miraculous", "100%", "guaranteed success for anyone". + + # Final JSON Output Structure + Your final output MUST follow this structure precisely: + { + "field": "string", + "topic": "string", + "source": "string", + "summary": [ + "string - bullet point 1", + "string - bullet point 2", + "string - bullet point 3" + ], + "reliability_analysis": [ + { + "category_name": "출처 신뢰성", + "score": integer, + "reason": "string" + }, + { + "category_name": "사실 근거", + "score": integer, + "reason": "string" + }, + { + "category_name": "광고/과장 표현", + "score": integer, + "reason": "string" + }, + { + "category_name": "편향성", + "score": integer, + "reason": "string" + }, + { + "category_name": "기사 형식", + "score": integer, + "reason": "string" + } + ], + "total_score": integer, + "ai_badges": [ "A mandatory array of 1-2 strings representing the most characteristic badges." ] + } + """; + + private static final String EXAMPLE1_USER = """ + {"title": "극한호우에 전남 무안·함평 침수…주민 대피령","date": "2025.08.03","content": "
\\n
\\n (무안=연합뉴스) 김혜인 기자 = 단시간에 많은 비가 내리면서 전남 무안과 함평 지역에 침수가 일어나 주민들이 대피하고 있다.\\n
\\n
\\n 3일 무안군은 이날 오후 8시 57분께 '무안군 신촌저수지 제방 월류 위험이 있으니 해당 저수지 수계 마을(상주교, 압창, 화촌) 주민분들께서는 대피해 주시길 바란다'고 긴급 재난문자를 발송했다.\\n
\\n
\\n 앞서 오후 8시 6분께 '무안읍소재지(무안군복합센터, 보건소) 침수 중이니 주민분들께서는 지금 즉시 차량을 신속하게 육상 안전지대로 이동시켜 주시기 바랍니다'라는 안전문자를 발송했다.\\n
\\n
\\n 함평군도 오후 8시 33분께 '함평읍내 및 5일 시장 주변이 폭우로 침수되고 있습니다. 차량은 우회하시고 주민들께서는 안전한 곳으로 즉시 대피하시기 바랍니다'라고 안내했다.\\n
\\n
\\n 이날 1시간 최대 강수량은 무안공항 142.1㎜, 무안 운남 115㎜, 신안 흑산도 87.9㎜, 장성 상무대 61.5㎜, 함평 월야 57.2㎜, 영광 50.9㎜, 광주 조선대 31.5㎜ 등으로 짧은 시간에 많은 비가 내리고 있다.\\n
\\n
\\n 전남도 관계자는 \\"현재 급작스럽게 많은 비로 인해 대피 현황을 파악하고 있다\\"며 \\"침수 피해를 최소화할 수 있도록 예의주시하겠다\\"고 말했다.\\n
\\n
\\n in@yna.co.kr\\n
\\n
"} + """; + + private static final String EXAMPLE1_ASSISTANT = """ + { + "field": "사회", + "topic": "전남 무안과 함평 지역에 내린 극한호우로 인한 침수 피해 및 주민 대피 상황", + "source": "연합뉴스", + "summary": [ + "2025년 8월 3일, 짧은 시간에 내린 폭우로 인해 전남 무안군과 함평군 일대에 침수가 발생하여 주민 대피령이 내려졌습니다.", + "무안군은 신촌저수지 월류 위험 및 무안읍소재지 침수를 알리며 주민과 차량의 신속한 대피를 요청했습니다.", + "함평군 역시 함평읍내 침수 상황을 알리며 안전한 곳으로의 즉시 대피를 안내했고, 무안공항에는 시간당 142.1mm의 많은 비가 기록되었습니다.", + "전라남도는 침수 피해 최소화를 위해 현황을 파악하며 상황을 예의주시하고 있다고 밝혔습니다." + ], + "reliability_analysis": [ + { + "category_name": "출처 신뢰성", + "score": 95, + "reason": "대한민국 주요 통신사인 '연합뉴스'의 보도이며, 기사 말미에 기자 이메일이 명시되어 있습니다." + }, + { + "category_name": "사실 근거", + "score": 100, + "reason": "재난 문자 발송 시각, 지역별 강수량(mm) 등 검증 가능한 수치를 제시하고 지자체 관계자를 인용했습니다." + }, + { + "category_name": "광고/과장 표현", + "score": 100, + "reason": "상업적 목적이나 감정을 자극하는 과장된 표현 없이 발생한 사건을 객관적으로 보도하고 있습니다." + }, + { + "category_name": "편향성", + "score": 90, + "reason": "특정 입장에 치우치지 않고, 재난 상황과 관련 당국의 대응을 사실적으로 전달하는 데 집중합니다." + }, + { + "category_name": "기사 형식", + "score": 95, + "reason": "제목과 본문이 명확하게 일치하며, 기사의 내용이 사실 중심으로 간결하게 구성되어 있습니다." + } + ], + "total_score": 96, + "ai_badges": [ + "공신력 있는 출처", + "주의 환기 우수" + ] + } + """; + + private static final String EXAMPLE2_USER = """ + {"title": "\\"짜장면 한 그릇 3900원에 드세요\\"…백종원, 또 승부수 던졌다","date": "2025.08.04","content": "
\\n
\\n백종원 대표가 운영하는 더본코리아의 중식 브랜드 '홍콩반점0410'이 짜장면 3900원 판매 프로모션을 들고 나왔다. '민생회복 국민응원 캠페인'으로 고물가에 더 가성비 있는 음식을 제공하겠다는 더본코리아의 마케팅 전략이다. 더본코리아는 올해 자사 브랜드의 릴레이 할인을 진행하고 있다. \\n
\\n
\\n4일 더본코리아에 따르면 이번 행사는 6~7일 이틀 동안 홍콩반점 매장을 방문하는 고객을 대상으로 한다. 포장 및 배달 주문 시에는 혜택이 적용되지 않지만, 홀 영업이 어려운 배달 전문 매장의 경우에는 포장 주문 시 짜장면 3900원 혜택을 받을 수 있다. 지역 화폐로 주문이 가능한 공공배달앱 '땡겨요'와 제휴 프로모션도 진행한다. 8월 4~31일까지 '땡겨요'를 통해 홍콩반점 메뉴 주문 시 4000원 할인 혜택을 받을 수 있다. \\n
\\n
\\n
\\n
\\n지난 5월 더본코리아는 본사가 전액 비용을 부담하는 대규모 할인 행사를 벌였다. 새마을식당과 홍콩반점, 한신포차, 연돈볼카츠 등 브랜드의 대표 메뉴를 최대 50% 할인하는 방식이었다. 빽다방 아메리카노커피를 500원에 판매하면서 전국적인 호응을 얻기도 했다. 앞서 백종원 대표는 각종 논란에 휩싸인 뒤, 300억원 규모의 가맹점 상생 지원책을 발표했다. \\n
\\n
\\n백 대표가 선택한 위기 돌파 전략은 가맹점 지원과 가격 할인에 집중됐다. 결국 가맹점주의 이익을 보호하고 가격할인을 통해 국민들에게 혜택을 주는 것이 프랜차이즈 대표로서 해야 할 일이라고 판단했기 때문이다. 특히 백종원 대표를 둘러싼 논란의 시작점이 '빽햄 가성비 문제'였던 만큼, 가성비 이미지를 회복해야 한다는 판단도 깔린 것으로 보인다. \\n
\\n
\\n고윤상 기자"} + """; + + private static final String EXAMPLE2_ASSISTANT = """ + { + "field": "경제", + "topic": "백종원의 더본코리아가 고물가 대응 및 기업 이미지 회복 전략의 일환으로 '홍콩반점0410' 짜장면 할인 행사를 진행", + "source": "출처 불명 (기자명 '고윤상' 명시)", + "summary": [ + "더본코리아의 '홍콩반점0410'이 '민생회복 국민응원 캠페인'의 일환으로 이틀간 짜장면을 3900원에 판매하는 행사를 진행합니다.", + "이번 프로모션은 매장 방문 고객을 대상으로 하며, 특정 배달앱과 연계한 할인 혜택도 별도로 제공됩니다.", + "이러한 연속적인 할인 행사는 최근 논란 이후 가맹점 상생 및 '가성비' 이미지를 회복하기 위한 백종원 대표의 전략으로 분석됩니다.", + "과거에도 빽다방 커피 할인 등 대규모 프로모션을 통해 전국적인 호응을 얻은 바 있습니다." + ], + "reliability_analysis": [ + { + "category_name": "출처 신뢰성", + "score": 65, + "reason": "기자 이름('고윤상')이 명시되어 있으나, 기사를 보도한 구체적인 언론사 이름이 확인되지 않습니다." + }, + { + "category_name": "사실 근거", + "score": 95, + "reason": "프로모션 기간, 가격, 대상 브랜드 등 구체적이고 확인 가능한 사실 정보를 중심으로 내용을 구성했습니다." + }, + { + "category_name": "광고/과장 표현", + "score": 90, + "reason": "기업의 할인 행사를 보도하고 있으나, 특정 상품의 구매를 직접 유도하는 광고성 표현은 없습니다." + }, + { + "category_name": "편향성", + "score": 80, + "reason": "단순한 행사 정보를 넘어, 기업의 전략적 배경과 의도를 함께 설명하며 다각적인 분석을 제공합니다." + }, + { + "category_name": "기사 형식", + "score": 95, + "reason": "제목이 기사의 핵심 내용을 잘 요약하고 있으며, 문단 구분이 명확하여 가독성이 높습니다." + } + ], + "total_score": 85, + "ai_badges": [ + "광고성 기사" + ] + } + """; + + /** + * 시스템 프롬프트를 반환합니다. + */ + public String getSystemPrompt() { + return SYSTEM_PROMPT; + } + + /** + * 예시 대화들을 반환합니다. + */ + public List getExampleConversations() { + List messages = new ArrayList<>(); + + messages.add(new ClovaRequestDTO.Message("user", EXAMPLE1_USER)); + messages.add(new ClovaRequestDTO.Message("assistant", EXAMPLE1_ASSISTANT)); + messages.add(new ClovaRequestDTO.Message("user", EXAMPLE2_USER)); + messages.add(new ClovaRequestDTO.Message("assistant", EXAMPLE2_ASSISTANT)); + + return messages; + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/report/service/ReportService.java b/src/main/java/com/perfact/be/domain/report/service/ReportService.java new file mode 100644 index 0000000..7bbddad --- /dev/null +++ b/src/main/java/com/perfact/be/domain/report/service/ReportService.java @@ -0,0 +1,13 @@ +package com.perfact.be.domain.report.service; + +import com.perfact.be.domain.report.entity.Report; +import com.perfact.be.domain.user.entity.User; + +public interface ReportService { + + // 뉴스를 Clova API로 분석 + Object analyzeNewsWithClova(String url); + + // 리포트 생성 + Report createReport(User user, String url, Object analysisResult); +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/report/service/ReportServiceImpl.java b/src/main/java/com/perfact/be/domain/report/service/ReportServiceImpl.java new file mode 100644 index 0000000..baf5bf5 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/report/service/ReportServiceImpl.java @@ -0,0 +1,182 @@ +package com.perfact.be.domain.report.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.perfact.be.domain.news.dto.NewsArticleResponse; +import com.perfact.be.domain.news.service.NewsService; +import com.perfact.be.domain.report.dto.ClovaRequestDTO; +import com.perfact.be.domain.report.dto.ClovaResponseDTO; +import com.perfact.be.domain.report.entity.Report; +import com.perfact.be.domain.report.repository.ReportRepository; +import com.perfact.be.domain.report.converter.ReportConverter; +import com.perfact.be.domain.report.exception.status.ReportErrorStatus; +import com.perfact.be.domain.user.entity.User; +import com.perfact.be.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ReportServiceImpl implements ReportService { + + private final NewsService newsService; + private final ReportRepository reportRepository; + private final ReportConverter reportConverter; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final PromptService promptService; + + @Value("${api.clova.api-url}") + private String CLOVA_API_URL; + + @Value("${api.clova.api-key}") + private String CLOVA_API_KEY; + + @Override + public Object analyzeNewsWithClova(String url) { + try { + if (newsService.isNaverNewsDomain(url)) { + return analyzeNaverNews(url); + } else { + return analyzeOtherNewsSite(url); + } + } catch (Exception e) { + throw new GeneralException(ReportErrorStatus.CLOVA_API_CALL_FAILED); + } + } + + private Object analyzeNaverNews(String url) { + try { + NewsArticleResponse newsData = newsService.extractNaverNewsArticle(url); + ClovaRequestDTO request = createClovaRequest(newsData); + ClovaResponseDTO response = callClovaAPI(request); + return parseJsonResponse(response.getResult().getMessage().getContent()); + } catch (Exception e) { + throw new GeneralException(ReportErrorStatus.CLOVA_API_CALL_FAILED); + } + } + + private Object analyzeOtherNewsSite(String url) { + try { + String title = newsService.extractTitleFromOtherNewsSites(url); + String content = newsService.extractNewsArticleContent(url); + + NewsArticleResponse newsData = new NewsArticleResponse(title, "날짜 정보 없음", content); + ClovaRequestDTO request = createClovaRequest(newsData); + ClovaResponseDTO response = callClovaAPI(request); + return parseJsonResponse(response.getResult().getMessage().getContent()); + } catch (Exception e) { + throw new GeneralException(ReportErrorStatus.CLOVA_API_CALL_FAILED); + } + } + + private Object parseJsonResponse(String analysisResult) { + try { + // ```json ... ``` 형태의 마크다운 코드 블록 제거 + String jsonContent = analysisResult; + if (jsonContent.startsWith("```json")) { + jsonContent = jsonContent.substring(7); + } + if (jsonContent.endsWith("```")) { + jsonContent = jsonContent.substring(0, jsonContent.length() - 3); + } + + // 앞뒤 공백 제거 + jsonContent = jsonContent.trim(); + + // JSON 파싱하여 객체로 변환 (UTF-8 인코딩 명시) + ObjectMapper mapper = new ObjectMapper(); + mapper.configure(com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true); + mapper.configure(com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_SINGLE_QUOTES, true); + Object jsonObject = mapper.readValue(jsonContent.getBytes("UTF-8"), Object.class); + + return jsonObject; + } catch (Exception e) { + // 파싱 실패 시 원본 문자열 반환 + return analysisResult; + } + } + + private ClovaRequestDTO createClovaRequest(NewsArticleResponse newsData) throws JsonProcessingException { + List messages = new ArrayList<>(); + + messages.add(new ClovaRequestDTO.Message("system", promptService.getSystemPrompt())); + + messages.addAll(promptService.getExampleConversations()); + + String newsContent = objectMapper.writeValueAsString(newsData); + messages.add(new ClovaRequestDTO.Message("user", newsContent)); + + return new ClovaRequestDTO( + messages, + 0.8, + 0, + 2048, + 0.5, + 1.1, + new ArrayList<>(), + 0, + true); + } + + private ClovaResponseDTO callClovaAPI(ClovaRequestDTO request) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(CLOVA_API_KEY); + + String requestBody = objectMapper.writeValueAsString(request); + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.exchange( + CLOVA_API_URL, + HttpMethod.POST, + entity, + String.class); + + ClovaResponseDTO responseBody = null; + try { + responseBody = objectMapper.readValue(response.getBody(), ClovaResponseDTO.class); + } catch (Exception e) { + throw new GeneralException(ReportErrorStatus.ANALYSIS_RESULT_PARSING_FAILED); + } + + return responseBody; + + } catch (Exception e) { + throw new GeneralException(ReportErrorStatus.CLOVA_API_CALL_FAILED); + } + } + + @Override + public Report createReport(User user, String url, Object analysisResult) { + try { + NewsArticleResponse newsData; + if (newsService.isNaverNewsDomain(url)) { + newsData = newsService.extractNaverNewsArticle(url); + } else { + String title = newsService.extractTitleFromOtherNewsSites(url); + String content = newsService.extractNewsArticleContent(url); + newsData = new NewsArticleResponse(title, "날짜 정보 없음", content); + } + + ClovaResponseDTO clovaResponse = objectMapper.convertValue(analysisResult, ClovaResponseDTO.class); + + Report report = reportConverter.toReport(clovaResponse, newsData, user, url); + + return reportRepository.save(report); + } catch (Exception e) { + throw new GeneralException(ReportErrorStatus.REPORT_CREATION_FAILED); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/user/entity/User.java b/src/main/java/com/perfact/be/domain/user/entity/User.java new file mode 100644 index 0000000..f1a202f --- /dev/null +++ b/src/main/java/com/perfact/be/domain/user/entity/User.java @@ -0,0 +1,57 @@ +package com.perfact.be.domain.user.entity; + +import com.perfact.be.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long userId; + + @Column(name = "social_id", length = 255) + private String socialId; + + @Column(name = "social_type", length = 50) + private String socialType; + + @Column(name = "name", length = 10) + private String name; + + @Column(name = "birth") + private Integer birth; + + @Column(name = "email", length = 255) + private String email; + + @Column(name = "is_subscribe", length = 255) + private String isSubscribe; + + @Column(name = "is_notification_agreed") + private Boolean isNotificationAgreed; + + @Column(name = "credit") + private Long credit; + + @Builder + public User(String socialId, String socialType, String name, Integer birth, + String email, String isSubscribe, Boolean isNotificationAgreed, Long credit) { + this.socialId = socialId; + this.socialType = socialType; + this.name = name; + this.birth = birth; + this.email = email; + this.isSubscribe = isSubscribe; + this.isNotificationAgreed = isNotificationAgreed; + this.credit = credit; + } +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/user/repository/UserRepository.java b/src/main/java/com/perfact/be/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..997989f --- /dev/null +++ b/src/main/java/com/perfact/be/domain/user/repository/UserRepository.java @@ -0,0 +1,15 @@ +package com.perfact.be.domain.user.repository; + +import com.perfact.be.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findBySocialIdAndSocialType(String socialId, String socialType); + + Optional findByEmail(String email); +} \ No newline at end of file