Skip to content

Commit 1f261e2

Browse files
authored
Merge pull request #37 from 508PERFACT/refact/35-different-news-domain
2 parents 0053231 + 1126e9b commit 1f261e2

17 files changed

+1140
-210
lines changed

src/main/java/com/perfact/be/domain/alt/service/ArticleExtractionServiceImpl.java

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
package com.perfact.be.domain.alt.service;
22

33
import com.perfact.be.domain.alt.dto.ArticleExtractionResult;
4-
54
import com.perfact.be.domain.news.dto.NewsArticleResponse;
6-
import com.perfact.be.domain.news.service.NewsService;
5+
import com.perfact.be.domain.news.extractor.factory.NewsExtractorFactory;
76
import lombok.RequiredArgsConstructor;
87
import lombok.extern.slf4j.Slf4j;
98
import org.springframework.stereotype.Service;
@@ -13,17 +12,14 @@
1312
@RequiredArgsConstructor
1413
public class ArticleExtractionServiceImpl implements ArticleExtractionService {
1514

16-
private final NewsService newsService;
15+
private final NewsExtractorFactory newsExtractorFactory;
1716

1817
@Override
1918
public String extractArticleContent(String url) {
2019
try {
21-
if (newsService.isNaverNewsDomain(url)) {
22-
NewsArticleResponse newsData = newsService.extractNaverNewsArticle(url);
23-
return newsData.getContent();
24-
} else {
25-
return newsService.extractNewsArticleContent(url);
26-
}
20+
// 모든 뉴스 사이트에 대해 동일한 방식으로 처리
21+
NewsArticleResponse newsData = newsExtractorFactory.extractNews(url);
22+
return newsData.getContent();
2723
} catch (Exception e) {
2824
log.error("기사 본문 추출 실패 - URL: {}, 에러: {}", url, e.getMessage(), e);
2925
throw new RuntimeException(e);
@@ -33,22 +29,13 @@ public String extractArticleContent(String url) {
3329
@Override
3430
public ArticleExtractionResult extractArticleWithMetadata(String url) {
3531
try {
36-
if (newsService.isNaverNewsDomain(url)) {
37-
NewsArticleResponse newsData = newsService.extractNaverNewsArticle(url);
38-
return ArticleExtractionResult.builder()
39-
.title(newsData.getTitle())
40-
.publicationDate(newsData.getDate())
41-
.content(newsData.getContent())
42-
.build();
43-
} else {
44-
String title = newsService.extractTitleFromOtherNewsSites(url);
45-
String content = newsService.extractNewsArticleContent(url);
46-
return ArticleExtractionResult.builder()
47-
.title(title)
48-
.publicationDate("날짜 정보 없음")
49-
.content(content)
50-
.build();
51-
}
32+
// 모든 뉴스 사이트에 대해 동일한 방식으로 처리
33+
NewsArticleResponse newsData = newsExtractorFactory.extractNews(url);
34+
return ArticleExtractionResult.builder()
35+
.title(newsData.getTitle())
36+
.publicationDate(newsData.getDate())
37+
.content(newsData.getContent())
38+
.build();
5239
} catch (Exception e) {
5340
log.error("기사 메타데이터 추출 실패 - URL: {}, 에러: {}", url, e.getMessage(), e);
5441
throw new RuntimeException(e);

src/main/java/com/perfact/be/domain/news/controller/NewsController.java

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package com.perfact.be.domain.news.controller;
22

33
import com.perfact.be.domain.news.dto.NewsArticleResponse;
4-
import com.perfact.be.domain.news.service.NewsService;
4+
import com.perfact.be.domain.news.extractor.factory.NewsExtractorFactory;
55
import com.perfact.be.global.apiPayload.ApiResponse;
66
import io.swagger.v3.oas.annotations.Operation;
77
import io.swagger.v3.oas.annotations.Parameter;
@@ -17,21 +17,20 @@
1717
@RequiredArgsConstructor
1818
public class NewsController {
1919

20-
private final NewsService newsService;
20+
private final NewsExtractorFactory newsExtractorFactory;
2121

22-
@Operation(summary = "뉴스 기사 내용 추출", description = "네이버 뉴스 URL을 입력받아 기사의 제목, 날짜, 내용을 추출합니다.")
22+
@Operation(summary = "뉴스 기사 내용 추출", description = "뉴스 URL을 입력받아 기사의 제목, 날짜, 내용을 추출합니다. 지원 사이트: 네이버뉴스, 연합뉴스, 뉴시스, 노컷뉴스")
2323
@GetMapping("/article-content")
2424
public ApiResponse<NewsArticleResponse> getNewsArticleContent(
25-
@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) {
26-
NewsArticleResponse response = newsService.extractNaverNewsArticle(url);
25+
@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) {
26+
NewsArticleResponse response = newsExtractorFactory.extractNews(url);
2727
return ApiResponse.onSuccess(response);
2828
}
2929

3030
@Operation(summary = "네이버 뉴스 검색", description = "검색어를 입력받아 네이버 뉴스 검색 결과를 반환합니다.")
3131
@GetMapping("/search")
3232
public ApiResponse<String> searchNaverNews(
3333
@Parameter(description = "검색할 키워드", required = true, example = "AI 기술") @RequestParam String query) {
34-
String searchResult = newsService.searchNaverNews(query);
35-
return ApiResponse.onSuccess(searchResult);
34+
throw new UnsupportedOperationException("네이버 뉴스 검색 기능은 현재 지원되지 않습니다.");
3635
}
3736
}

src/main/java/com/perfact/be/domain/news/exception/status/NewsErrorStatus.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
@AllArgsConstructor
1111
public enum NewsErrorStatus implements BaseErrorCode {
1212
NOT_NAVER_NEWS(HttpStatus.BAD_REQUEST, "NEWS4001", "네이버 뉴스 도메인이 아닙니다. 네이버 뉴스를 통한 링크만 가능합니다."),
13-
NEWS_CONTENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "NEWS4002", "뉴스 내용을 찾을 수 없습니다."),
14-
NEWS_TITLE_EXTRACTION_FAILED(HttpStatus.BAD_REQUEST, "NEWS4003", "뉴스 제목 추출에 실패했습니다."),
15-
NEWS_DATE_EXTRACTION_FAILED(HttpStatus.BAD_REQUEST, "NEWS4004", "뉴스 날짜 추출에 실패했습니다."),
16-
NEWS_ARTICLE_PARSING_FAILED(HttpStatus.BAD_REQUEST, "NEWS4005", "뉴스 기사 파싱에 실패했습니다."),
17-
NEWS_NAVER_API_CALL_FAILED(HttpStatus.BAD_REQUEST, "NEWS4006", "네이버 API 호출에 실패했습니다."),
13+
UNSUPPORTED_NEWS_SITE(HttpStatus.BAD_REQUEST, "NEWS4002",
14+
"지원하지 않는 뉴스 사이트입니다. 현재 지원되는 사이트: 네이버뉴스, 연합뉴스, 뉴시스, 노컷뉴스 (네이버 뉴스에 최적화되어 있습니다.)"),
15+
NEWS_CONTENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "NEWS4003", "뉴스 내용을 찾을 수 없습니다."),
16+
NEWS_TITLE_EXTRACTION_FAILED(HttpStatus.BAD_REQUEST, "NEWS4004", "뉴스 제목 추출에 실패했습니다."),
17+
NEWS_DATE_EXTRACTION_FAILED(HttpStatus.BAD_REQUEST, "NEWS4005", "뉴스 날짜 추출에 실패했습니다."),
18+
NEWS_ARTICLE_PARSING_FAILED(HttpStatus.BAD_REQUEST, "NEWS4006", "뉴스 기사 파싱에 실패했습니다."),
19+
NEWS_NAVER_API_CALL_FAILED(HttpStatus.BAD_REQUEST, "NEWS4007", "네이버 API 호출에 실패했습니다."),
1820
;
1921

2022
private final HttpStatus httpStatus;
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package com.perfact.be.domain.news.extractor;
2+
3+
import com.perfact.be.domain.news.dto.NewsArticleResponse;
4+
import com.perfact.be.domain.news.exception.NewsHandler;
5+
import com.perfact.be.domain.news.exception.status.NewsErrorStatus;
6+
import com.perfact.be.domain.news.service.DateExtractorService;
7+
import com.perfact.be.domain.news.service.HtmlParserService;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.jsoup.nodes.Document;
11+
import org.jsoup.nodes.Element;
12+
import org.jsoup.select.Elements;
13+
14+
// 뉴스 추출기 추상 클래스
15+
@Slf4j
16+
@RequiredArgsConstructor
17+
public abstract class AbstractNewsExtractor implements NewsExtractorStrategy {
18+
19+
protected final HtmlParserService htmlParserService;
20+
protected final DateExtractorService dateExtractorService;
21+
22+
// HTML 문서에서 제목 추출
23+
protected String extractTitle(Document doc, String[] titleSelectors) {
24+
for (String selector : titleSelectors) {
25+
Element titleElement = doc.selectFirst(selector);
26+
if (titleElement != null) {
27+
String title = titleElement.text().trim();
28+
if (!title.isEmpty()) {
29+
log.debug("제목 추출 성공: {} -> {}", selector, title);
30+
return title;
31+
}
32+
}
33+
}
34+
log.warn("제목을 찾을 수 없습니다. 사용된 셀렉터: {}", String.join(", ", titleSelectors));
35+
throw new NewsHandler(NewsErrorStatus.NEWS_TITLE_EXTRACTION_FAILED);
36+
}
37+
38+
// HTML 문서에서 내용 추출
39+
protected String extractContent(Document doc, String[] contentSelectors) {
40+
for (String selector : contentSelectors) {
41+
Element contentElement = doc.selectFirst(selector);
42+
if (contentElement != null) {
43+
String content = processContentElement(contentElement);
44+
if (!content.trim().isEmpty()) {
45+
log.debug("내용 추출 성공: {} -> 길이: {}", selector, content.length());
46+
return content;
47+
}
48+
}
49+
}
50+
log.warn("내용을 찾을 수 없습니다. 사용된 셀렉터: {}", String.join(", ", contentSelectors));
51+
throw new NewsHandler(NewsErrorStatus.NEWS_CONTENT_NOT_FOUND);
52+
}
53+
54+
// 내용 요소 처리
55+
protected String processContentElement(Element contentElement) {
56+
StringBuilder content = new StringBuilder();
57+
58+
// p 태그들 처리
59+
Elements paragraphs = contentElement.select("p");
60+
for (Element p : paragraphs) {
61+
String text = p.text().trim();
62+
if (!text.isEmpty()) {
63+
content.append(text).append("\n\n");
64+
}
65+
}
66+
67+
// li 태그들 처리
68+
Elements listItems = contentElement.select("li");
69+
for (Element li : listItems) {
70+
String text = li.text().trim();
71+
if (!text.isEmpty()) {
72+
content.append("• ").append(text).append("\n");
73+
}
74+
}
75+
76+
// p, li 태그가 없는 경우 전체 텍스트 추출
77+
if (content.length() == 0) {
78+
String fullText = contentElement.text().trim();
79+
if (!fullText.isEmpty()) {
80+
String processedText = fullText.replaceAll("\\s+", " ").trim();
81+
content.append(processedText);
82+
}
83+
}
84+
85+
return content.toString();
86+
}
87+
88+
// HTML 문서 가져오기
89+
protected Document getDocument(String url) {
90+
try {
91+
return htmlParserService.getHtmlFromUrl(url);
92+
} catch (Exception e) {
93+
log.error("HTML 문서 가져오기 실패: {}", url, e);
94+
throw new NewsHandler(NewsErrorStatus.NEWS_ARTICLE_PARSING_FAILED);
95+
}
96+
}
97+
98+
// 날짜 추출
99+
protected String extractDate(String url) {
100+
try {
101+
String date = dateExtractorService.extractArticleDate(url);
102+
if (date == null || date.equals("날짜 정보 없음")) {
103+
throw new NewsHandler(NewsErrorStatus.NEWS_DATE_EXTRACTION_FAILED);
104+
}
105+
return date;
106+
} catch (Exception e) {
107+
log.warn("날짜 추출 실패: {}", url, e);
108+
throw new NewsHandler(NewsErrorStatus.NEWS_DATE_EXTRACTION_FAILED);
109+
}
110+
}
111+
112+
// 도메인별 제목 셀렉터 반환
113+
protected abstract String[] getTitleSelectors();
114+
115+
// 도메인별 내용 셀렉터 반환
116+
protected abstract String[] getContentSelectors();
117+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.perfact.be.domain.news.extractor;
2+
3+
import com.perfact.be.domain.news.dto.NewsArticleResponse;
4+
5+
// 뉴스 추출 전략 인터페이스 - 각 도메인별 뉴스 추출 로직 정의
6+
public interface NewsExtractorStrategy {
7+
8+
// 해당 URL이 이 추출기로 처리 가능한지 확인
9+
boolean canExtract(String url);
10+
11+
// 뉴스 기사를 추출
12+
NewsArticleResponse extract(String url);
13+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.perfact.be.domain.news.extractor.factory;
2+
3+
import com.perfact.be.domain.news.dto.NewsArticleResponse;
4+
import com.perfact.be.domain.news.extractor.NewsExtractorStrategy;
5+
import com.perfact.be.domain.news.exception.NewsHandler;
6+
import com.perfact.be.domain.news.exception.status.NewsErrorStatus;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.stereotype.Component;
10+
11+
import java.util.List;
12+
13+
// 뉴스 추출기 팩토리
14+
@Slf4j
15+
@Component
16+
@RequiredArgsConstructor
17+
public class NewsExtractorFactory {
18+
19+
private final List<NewsExtractorStrategy> extractors;
20+
21+
// URL에 맞는 추출기를 찾아 뉴스를 추출합니다.
22+
public NewsArticleResponse extractNews(String url) {
23+
log.info("뉴스 추출기 선택 시작: {}", url);
24+
25+
NewsExtractorStrategy extractor = getExtractor(url);
26+
log.info("선택된 추출기: {}", extractor.getClass().getSimpleName());
27+
28+
return extractor.extract(url);
29+
}
30+
31+
// URL에 맞는 추출기를 찾습니다.
32+
public NewsExtractorStrategy getExtractor(String url) {
33+
return extractors.stream()
34+
.filter(extractor -> extractor.canExtract(url))
35+
.findFirst()
36+
.orElseThrow(() -> {
37+
log.error("지원하지 않는 뉴스 사이트입니다: {}", url);
38+
return new NewsHandler(NewsErrorStatus.UNSUPPORTED_NEWS_SITE);
39+
});
40+
}
41+
42+
// 사용 가능한 모든 추출기를 반환합니다.
43+
public List<NewsExtractorStrategy> getAllExtractors() {
44+
return extractors;
45+
}
46+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.perfact.be.domain.news.extractor.impl;
2+
3+
import com.perfact.be.domain.news.dto.NewsArticleResponse;
4+
import com.perfact.be.domain.news.extractor.AbstractNewsExtractor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.jsoup.nodes.Document;
7+
import org.springframework.stereotype.Component;
8+
9+
// 뉴스 도메인 라우팅 추출기
10+
@Slf4j
11+
@Component
12+
public class GenericNewsExtractor extends AbstractNewsExtractor {
13+
14+
public GenericNewsExtractor(com.perfact.be.domain.news.service.HtmlParserService htmlParserService,
15+
com.perfact.be.domain.news.service.DateExtractorService dateExtractorService) {
16+
super(htmlParserService, dateExtractorService);
17+
}
18+
19+
@Override
20+
public boolean canExtract(String url) {
21+
// 지원하는 뉴스 사이트들만 처리하고, 나머지는 거부
22+
return url.contains("news.naver.com") || url.contains("yna.co.kr") || url.contains("newsis.com")
23+
|| url.contains("nocutnews.co.kr"); //|| url.contains("ohmynews.com");
24+
}
25+
26+
@Override
27+
public NewsArticleResponse extract(String url) {
28+
log.info("지원하는 뉴스 사이트 처리: {}", url);
29+
30+
try {
31+
Document doc = getDocument(url);
32+
33+
String title = extractTitle(doc, getTitleSelectors());
34+
String content = extractContent(doc, getContentSelectors());
35+
String date = extractDate(url);
36+
37+
return new NewsArticleResponse(title, date, content);
38+
39+
} catch (Exception e) {
40+
log.error("뉴스 사이트 처리 실패: {}", url, e);
41+
throw e;
42+
}
43+
}
44+
45+
@Override
46+
protected String[] getTitleSelectors() {
47+
return new String[] {
48+
"h1",
49+
".title",
50+
".headline",
51+
".article-title",
52+
"title",
53+
"[class*=\"title\"]",
54+
"[class*=\"headline\"]"
55+
};
56+
}
57+
58+
@Override
59+
protected String[] getContentSelectors() {
60+
return new String[] {
61+
"article",
62+
".article-content",
63+
".content",
64+
".post-content",
65+
".entry-content",
66+
"[class*=\"article\"]",
67+
"[class*=\"content\"]",
68+
"main",
69+
".main-content"
70+
};
71+
}
72+
}

0 commit comments

Comments
 (0)