-
Notifications
You must be signed in to change notification settings - Fork 0
Feat : url을 통한 뉴스 크롤링 및 HCX를 통한 뉴스 리포트 생성 #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String> TITLE_SELECTORS = List.of( | ||
| "#title_area span", // 네이버 뉴스의 일반적인 제목 셀렉터 | ||
| ".title_area .title", // 기존 셀렉터 | ||
| "h1", // 일반적인 제목 태그 | ||
| ".title", // 일반적인 제목 클래스 | ||
| "[class*=\"title\"]", // 제목 관련 클래스를 포함하는 요소 | ||
| "title" // HTML title 태그 | ||
| ); | ||
|
|
||
| // 다른 뉴스 사이트 제목 셀렉터 | ||
| public static final List<String> OTHER_NEWS_TITLE_SELECTORS = List.of( | ||
| "h1", | ||
| ".title", | ||
| ".headline", | ||
| ".article-title", | ||
| "title"); | ||
|
|
||
| // 뉴스 내용 추출 셀렉터 | ||
| public static final List<String> CONTENT_SELECTORS = List.of( | ||
| "#dic_area", // id 셀렉터 (가장 일반적) | ||
| ".dic_area", // 클래스 셀렉터 (기존) | ||
| "article", // article 태그 | ||
| "[id*=\"dic\"]", // dic을 포함하는 id | ||
| "[class*=\"article\"]" // article을 포함하는 클래스 | ||
| ); | ||
|
|
||
| // 날짜 추출 셀렉터 | ||
| public static final List<String> 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]); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<NewsArticleResponse> getNewsArticleContent(@RequestParam String url) { | ||
| NewsArticleResponse response = newsService.extractNaverNewsArticle(url); | ||
| return ApiResponse.onSuccess(response); | ||
| } | ||
|
|
||
| @GetMapping("/search") | ||
| public ApiResponse<String> searchNaverNews(@RequestParam String query) { | ||
| String searchResult = newsService.searchNaverNews(query); | ||
| return ApiResponse.onSuccess(searchResult); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 NewsConverter는 단순히 DTO 변환을 넘어서 JSON 직렬화 및 문자열 이스케이프 처리까지 수행하고 있습니다. Converter는 객체 간 변환 ( Entity → DTO) 역할에 집중하고 크롤링 특성상 이렇게 처리한 점은 이해되지만, 추후 리팩토링 시 Converter는 변환 책임만 갖도록 구조를 정리하는 것을 추천드립니다.! |
||
| 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"); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NewsController에서 프론트엔드에게 응답으로 전달되는 NewsArticleResponse는 DTO로 사용되고 있지만, 각 필드가 어떤 의미를 가지는지에 대한 명세나 설명이 작성되어 있지 않아 파악하기 어렵습니다. |
||
| private String title; | ||
| private String date; | ||
| private String content; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 지금 코드에서는 GeneralException을 직접 던지는 방식으로 예외를 처리하고 있는데, 각 도메인 별로 Handler 클래스를 만들어서 GeneralException을 상속 받는 구조로 예외를 처리하는 방식을 추천합니다. 이런식으로 직접 예외를 던지기 보다는 이런식으로 각 도메인마다 핸들러를 정의하고 이런식으로 도메인 전용 핸들러 클래스를 만들어서 사용해주시면 좋을 것 같습니다. |
||
| } | ||
|
|
||
| /** | ||
| * 뉴스 제목 추출 실패 시 예외를 처리합니다. | ||
| */ | ||
| 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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기서도 마찬가지로 단순히 null을 반환하는 것보다 핸들러 + 에러 상태 코드로 예외 처리를 수행하는 것이 나중에 디버깅할떄 편할 것 같습니다. |
||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 텍스트 추출을 위한 함수형 인터페이스 | ||
| */ | ||
| @FunctionalInterface | ||
| public interface TextExtractor { | ||
| String extract() throws Exception; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 각 도메인마다 상태 코드를 정의해주신 점은 좋습니다! 이렇게 하면 예외 상황에서도 클라이언트에게 일관된 형태의 응답을 전달할 수 있고, |
||
| 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(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.perfact.be.domain.news.service; | ||
|
|
||
| // 날짜 추출 | ||
| public interface DateExtractorService { | ||
|
|
||
| String extractArticleDate(String url); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SelectorConfig를 private final com.perfact...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 "날짜 정보 없음"; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 전체적으로 catch 블록에서 "날짜 정보 없음"과 같은 단순 문자열만 반환하고 있는 부분이 여러 군데 보입니다. 혹시 특별한 이유가 없다면, 프로젝트 내 공통 예외 처리 설계(GeneralException + 도메인별 Handler + ErrorStatus)에 따라 |
||
| } | ||
|
|
||
| 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 "날짜 정보 없음"; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
현재 컨트롤러 메서드에 Swagger 명세가 누락되어 있습니다.
추후 @operation, @parameter 등의 어노테이션을 활용해 각 API의 목적, 요청 파라미터, 예시 값 등을 문서화해 주세요!