Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion BEconfig
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

}

Expand Down
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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 컨트롤러 메서드에 Swagger 명세가 누락되어 있습니다.
추후 @operation, @parameter 등의 어노테이션을 활용해 각 API의 목적, 요청 파라미터, 예시 값 등을 문서화해 주세요!

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 {
Copy link
Member

Choose a reason for hiding this comment

The 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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewsController에서 프론트엔드에게 응답으로 전달되는 NewsArticleResponse는 DTO로 사용되고 있지만, 각 필드가 어떤 의미를 가지는지에 대한 명세나 설명이 작성되어 있지 않아 파악하기 어렵습니다.
프론트엔드에서 쉽게 이해하고 활용할 수 있도록
각 필드에 Swagger 명세(annotation)를 통해 어떤 정보를 담고 있는지 어떤 형식/예시의 값이 전달되는지 명확히 기재해주시면 좋을 것 같습니다.
나중에 소셜 로그인 쪽 컨트롤러 보시고 작성해주시면 좋을 것 같습니다.

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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 코드에서는 GeneralException을 직접 던지는 방식으로 예외를 처리하고 있는데, 각 도메인 별로 Handler 클래스를 만들어서 GeneralException을 상속 받는 구조로 예외를 처리하는 방식을 추천합니다.
예를 들어서
throw new GeneralException(NewsErrorStatus.NEWS_NAVER_API_CALL_FAILED);

이런식으로 직접 예외를 던지기 보다는


public class NewsHandler extends GeneralException {
  public NewsHandler(NewsErrorStatus code) {
    super(code);
  }
}

이런식으로 각 도메인마다 핸들러를 정의하고
throw new NewsHandler(NewsErrorStatus.NEWS_NAVER_API_CALL_FAILED);
예외를 던져야 하는 곳에서 에러 상태 코드와, 핸들러를 재사용하는 방식이 좋을 것 같습니다.

이런식으로 도메인 전용 핸들러 클래스를 만들어서 사용해주시면 좋을 것 같습니다.

}

/**
* 뉴스 제목 추출 실패 시 예외를 처리합니다.
*/
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;
Copy link
Member

Choose a reason for hiding this comment

The 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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

각 도메인마다 상태 코드를 정의해주신 점은 좋습니다!
앞서 남긴 코멘트와도 연결되는 부분인데,
예외를 발생시킬 때는 각 도메인별로 정의한 Handler 클래스에서 GeneralException을 상속받고
예외 상황이 발생한 곳에서는 해당 Handler와 도메인별 ErrorStatus를 사용해 예외를 던지는 방식을 추천드립니다.

이렇게 하면 예외 상황에서도 클라이언트에게 일관된 형태의 응답을 전달할 수 있고,
도메인별로 예외 처리를 구조적으로 분리할 수 있어 유지보수와 확장성 측면에서도 도움이 됩니다.

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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SelectorConfig를 private final com.perfact...SelectorConfig처럼 패키지 전체 경로로 선언하신 부분이 보이는데요!
특별한 이유가 없다면 import를 추가하고 필드 선언은 private final 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 "날짜 정보 없음";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

전체적으로 catch 블록에서 "날짜 정보 없음"과 같은 단순 문자열만 반환하고 있는 부분이 여러 군데 보입니다.
이런 방식은 실제 예외 발생 원인을 숨기고,
클라이언트 측에서도 정확한 실패 이유를 알 수 없게 됩니다..
그리고 무엇보다 콘솔 로그 외에는 문제를 추적할 수 없기 때문에 운영 환경에서는 리스크가 큽니다...

혹시 특별한 이유가 없다면, 프로젝트 내 공통 예외 처리 설계(GeneralException + 도메인별 Handler + ErrorStatus)에 따라
catch 블록에서도 도메인 전용 핸들러를 활용해 예외를 명확하게 던져주는 방식으로 개선하는 것을 권장드립니다!

}

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 "날짜 정보 없음";
}
}
Loading