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
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.perfact.be.domain.alt.service;

import com.perfact.be.domain.alt.dto.ArticleExtractionResult;

import com.perfact.be.domain.news.dto.NewsArticleResponse;
import com.perfact.be.domain.news.service.NewsService;
import com.perfact.be.domain.news.extractor.factory.NewsExtractorFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand All @@ -13,17 +12,14 @@
@RequiredArgsConstructor
public class ArticleExtractionServiceImpl implements ArticleExtractionService {

private final NewsService newsService;
private final NewsExtractorFactory newsExtractorFactory;

@Override
public String extractArticleContent(String url) {
try {
if (newsService.isNaverNewsDomain(url)) {
NewsArticleResponse newsData = newsService.extractNaverNewsArticle(url);
return newsData.getContent();
} else {
return newsService.extractNewsArticleContent(url);
}
// 모든 뉴스 사이트에 대해 동일한 방식으로 처리
NewsArticleResponse newsData = newsExtractorFactory.extractNews(url);
return newsData.getContent();
} catch (Exception e) {
log.error("기사 본문 추출 실패 - URL: {}, 에러: {}", url, e.getMessage(), e);
throw new RuntimeException(e);
Expand All @@ -33,22 +29,13 @@ public String extractArticleContent(String url) {
@Override
public ArticleExtractionResult extractArticleWithMetadata(String url) {
try {
if (newsService.isNaverNewsDomain(url)) {
NewsArticleResponse newsData = newsService.extractNaverNewsArticle(url);
return ArticleExtractionResult.builder()
.title(newsData.getTitle())
.publicationDate(newsData.getDate())
.content(newsData.getContent())
.build();
} else {
String title = newsService.extractTitleFromOtherNewsSites(url);
String content = newsService.extractNewsArticleContent(url);
return ArticleExtractionResult.builder()
.title(title)
.publicationDate("날짜 정보 없음")
.content(content)
.build();
}
// 모든 뉴스 사이트에 대해 동일한 방식으로 처리
NewsArticleResponse newsData = newsExtractorFactory.extractNews(url);
return ArticleExtractionResult.builder()
.title(newsData.getTitle())
.publicationDate(newsData.getDate())
.content(newsData.getContent())
.build();
} catch (Exception e) {
log.error("기사 메타데이터 추출 실패 - URL: {}, 에러: {}", url, e.getMessage(), e);
throw new RuntimeException(e);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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.domain.news.extractor.factory.NewsExtractorFactory;
import com.perfact.be.global.apiPayload.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
Expand All @@ -17,21 +17,20 @@
@RequiredArgsConstructor
public class NewsController {

private final NewsService newsService;
private final NewsExtractorFactory newsExtractorFactory;

@Operation(summary = "뉴스 기사 내용 추출", description = "네이버 뉴스 URL을 입력받아 기사의 제목, 날짜, 내용을 추출합니다.")
@Operation(summary = "뉴스 기사 내용 추출", description = "뉴스 URL을 입력받아 기사의 제목, 날짜, 내용을 추출합니다. 지원 사이트: 네이버뉴스, 연합뉴스, 뉴시스, 노컷뉴스")
@GetMapping("/article-content")
public ApiResponse<NewsArticleResponse> getNewsArticleContent(
@Parameter(description = "네이버 뉴스 URL", required = true, example = "https://news.naver.com/main/read.naver?mode=LSD&mid=shm&sid1=100&oid=001&aid=0012345678") @RequestParam String url) {
NewsArticleResponse response = newsService.extractNaverNewsArticle(url);
@Parameter(description = "뉴스 URL", required = true, example = "https://news.naver.com/main/read.naver?mode=LSD&mid=shm&sid1=100&oid=001&aid=0012345678") @RequestParam String url) {
NewsArticleResponse response = newsExtractorFactory.extractNews(url);
return ApiResponse.onSuccess(response);
}

@Operation(summary = "네이버 뉴스 검색", description = "검색어를 입력받아 네이버 뉴스 검색 결과를 반환합니다.")
@GetMapping("/search")
public ApiResponse<String> searchNaverNews(
@Parameter(description = "검색할 키워드", required = true, example = "AI 기술") @RequestParam String query) {
String searchResult = newsService.searchNaverNews(query);
return ApiResponse.onSuccess(searchResult);
throw new UnsupportedOperationException("네이버 뉴스 검색 기능은 현재 지원되지 않습니다.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
@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 호출에 실패했습니다."),
UNSUPPORTED_NEWS_SITE(HttpStatus.BAD_REQUEST, "NEWS4002",
"지원하지 않는 뉴스 사이트입니다. 현재 지원되는 사이트: 네이버뉴스, 연합뉴스, 뉴시스, 노컷뉴스 (네이버 뉴스에 최적화되어 있습니다.)"),
NEWS_CONTENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "NEWS4003", "뉴스 내용을 찾을 수 없습니다."),
NEWS_TITLE_EXTRACTION_FAILED(HttpStatus.BAD_REQUEST, "NEWS4004", "뉴스 제목 추출에 실패했습니다."),
NEWS_DATE_EXTRACTION_FAILED(HttpStatus.BAD_REQUEST, "NEWS4005", "뉴스 날짜 추출에 실패했습니다."),
NEWS_ARTICLE_PARSING_FAILED(HttpStatus.BAD_REQUEST, "NEWS4006", "뉴스 기사 파싱에 실패했습니다."),
NEWS_NAVER_API_CALL_FAILED(HttpStatus.BAD_REQUEST, "NEWS4007", "네이버 API 호출에 실패했습니다."),
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.perfact.be.domain.news.extractor;

import com.perfact.be.domain.news.dto.NewsArticleResponse;
import com.perfact.be.domain.news.exception.NewsHandler;
import com.perfact.be.domain.news.exception.status.NewsErrorStatus;
import com.perfact.be.domain.news.service.DateExtractorService;
import com.perfact.be.domain.news.service.HtmlParserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

// 뉴스 추출기 추상 클래스
@Slf4j
@RequiredArgsConstructor
public abstract class AbstractNewsExtractor implements NewsExtractorStrategy {

protected final HtmlParserService htmlParserService;
protected final DateExtractorService dateExtractorService;

// HTML 문서에서 제목 추출
protected String extractTitle(Document doc, String[] titleSelectors) {
for (String selector : titleSelectors) {
Element titleElement = doc.selectFirst(selector);
if (titleElement != null) {
String title = titleElement.text().trim();
if (!title.isEmpty()) {
log.debug("제목 추출 성공: {} -> {}", selector, title);
return title;
}
}
}
log.warn("제목을 찾을 수 없습니다. 사용된 셀렉터: {}", String.join(", ", titleSelectors));
throw new NewsHandler(NewsErrorStatus.NEWS_TITLE_EXTRACTION_FAILED);
}

// HTML 문서에서 내용 추출
protected String extractContent(Document doc, String[] contentSelectors) {
for (String selector : contentSelectors) {
Element contentElement = doc.selectFirst(selector);
if (contentElement != null) {
String content = processContentElement(contentElement);
if (!content.trim().isEmpty()) {
log.debug("내용 추출 성공: {} -> 길이: {}", selector, content.length());
return content;
}
}
}
log.warn("내용을 찾을 수 없습니다. 사용된 셀렉터: {}", String.join(", ", contentSelectors));
throw new NewsHandler(NewsErrorStatus.NEWS_CONTENT_NOT_FOUND);
}

// 내용 요소 처리
protected String processContentElement(Element contentElement) {
StringBuilder content = new StringBuilder();

// p 태그들 처리
Elements paragraphs = contentElement.select("p");
for (Element p : paragraphs) {
String text = p.text().trim();
if (!text.isEmpty()) {
content.append(text).append("\n\n");
}
}

// li 태그들 처리
Elements listItems = contentElement.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 = contentElement.text().trim();
if (!fullText.isEmpty()) {
String processedText = fullText.replaceAll("\\s+", " ").trim();
content.append(processedText);
}
}

return content.toString();
}

// HTML 문서 가져오기
protected Document getDocument(String url) {
try {
return htmlParserService.getHtmlFromUrl(url);
} catch (Exception e) {
log.error("HTML 문서 가져오기 실패: {}", url, e);
throw new NewsHandler(NewsErrorStatus.NEWS_ARTICLE_PARSING_FAILED);
}
}

// 날짜 추출
protected String extractDate(String url) {
try {
String date = dateExtractorService.extractArticleDate(url);
if (date == null || date.equals("날짜 정보 없음")) {
throw new NewsHandler(NewsErrorStatus.NEWS_DATE_EXTRACTION_FAILED);
}
return date;
} catch (Exception e) {
log.warn("날짜 추출 실패: {}", url, e);
throw new NewsHandler(NewsErrorStatus.NEWS_DATE_EXTRACTION_FAILED);
}
}

// 도메인별 제목 셀렉터 반환
protected abstract String[] getTitleSelectors();

// 도메인별 내용 셀렉터 반환
protected abstract String[] getContentSelectors();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.perfact.be.domain.news.extractor;

import com.perfact.be.domain.news.dto.NewsArticleResponse;

// 뉴스 추출 전략 인터페이스 - 각 도메인별 뉴스 추출 로직 정의
public interface NewsExtractorStrategy {

// 해당 URL이 이 추출기로 처리 가능한지 확인
boolean canExtract(String url);

// 뉴스 기사를 추출
NewsArticleResponse extract(String url);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.perfact.be.domain.news.extractor.factory;

import com.perfact.be.domain.news.dto.NewsArticleResponse;
import com.perfact.be.domain.news.extractor.NewsExtractorStrategy;
import com.perfact.be.domain.news.exception.NewsHandler;
import com.perfact.be.domain.news.exception.status.NewsErrorStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.List;

// 뉴스 추출기 팩토리
@Slf4j
@Component
@RequiredArgsConstructor
public class NewsExtractorFactory {

private final List<NewsExtractorStrategy> extractors;

// URL에 맞는 추출기를 찾아 뉴스를 추출합니다.
public NewsArticleResponse extractNews(String url) {
log.info("뉴스 추출기 선택 시작: {}", url);

NewsExtractorStrategy extractor = getExtractor(url);
log.info("선택된 추출기: {}", extractor.getClass().getSimpleName());

return extractor.extract(url);
}

// URL에 맞는 추출기를 찾습니다.
public NewsExtractorStrategy getExtractor(String url) {
return extractors.stream()
.filter(extractor -> extractor.canExtract(url))
.findFirst()
.orElseThrow(() -> {
log.error("지원하지 않는 뉴스 사이트입니다: {}", url);
return new NewsHandler(NewsErrorStatus.UNSUPPORTED_NEWS_SITE);
});
}

// 사용 가능한 모든 추출기를 반환합니다.
public List<NewsExtractorStrategy> getAllExtractors() {
return extractors;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.perfact.be.domain.news.extractor.impl;

import com.perfact.be.domain.news.dto.NewsArticleResponse;
import com.perfact.be.domain.news.extractor.AbstractNewsExtractor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.nodes.Document;
import org.springframework.stereotype.Component;

// 뉴스 도메인 라우팅 추출기
@Slf4j
@Component
public class GenericNewsExtractor extends AbstractNewsExtractor {

public GenericNewsExtractor(com.perfact.be.domain.news.service.HtmlParserService htmlParserService,
com.perfact.be.domain.news.service.DateExtractorService dateExtractorService) {
super(htmlParserService, dateExtractorService);
}

@Override
public boolean canExtract(String url) {
// 지원하는 뉴스 사이트들만 처리하고, 나머지는 거부
return url.contains("news.naver.com") || url.contains("yna.co.kr") || url.contains("newsis.com")
|| url.contains("nocutnews.co.kr"); //|| url.contains("ohmynews.com");
}

@Override
public NewsArticleResponse extract(String url) {
log.info("지원하는 뉴스 사이트 처리: {}", url);

try {
Document doc = getDocument(url);

String title = extractTitle(doc, getTitleSelectors());
String content = extractContent(doc, getContentSelectors());
String date = extractDate(url);

return new NewsArticleResponse(title, date, content);

} catch (Exception e) {
log.error("뉴스 사이트 처리 실패: {}", url, e);
throw e;
}
}

@Override
protected String[] getTitleSelectors() {
return new String[] {
"h1",
".title",
".headline",
".article-title",
"title",
"[class*=\"title\"]",
"[class*=\"headline\"]"
};
}

@Override
protected String[] getContentSelectors() {
return new String[] {
"article",
".article-content",
".content",
".post-content",
".entry-content",
"[class*=\"article\"]",
"[class*=\"content\"]",
"main",
".main-content"
};
}
}
Loading