diff --git a/src/main/java/com/perfact/be/domain/alt/service/ArticleExtractionServiceImpl.java b/src/main/java/com/perfact/be/domain/alt/service/ArticleExtractionServiceImpl.java index a3812c7..a8d7124 100644 --- a/src/main/java/com/perfact/be/domain/alt/service/ArticleExtractionServiceImpl.java +++ b/src/main/java/com/perfact/be/domain/alt/service/ArticleExtractionServiceImpl.java @@ -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; @@ -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); @@ -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); 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 index 8abfd9f..7802694 100644 --- a/src/main/java/com/perfact/be/domain/news/controller/NewsController.java +++ b/src/main/java/com/perfact/be/domain/news/controller/NewsController.java @@ -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; @@ -17,13 +17,13 @@ @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 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); } @@ -31,7 +31,6 @@ public ApiResponse getNewsArticleContent( @GetMapping("/search") public ApiResponse searchNaverNews( @Parameter(description = "검색할 키워드", required = true, example = "AI 기술") @RequestParam String query) { - String searchResult = newsService.searchNaverNews(query); - return ApiResponse.onSuccess(searchResult); + throw new UnsupportedOperationException("네이버 뉴스 검색 기능은 현재 지원되지 않습니다."); } } \ 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 index 746e342..3678d40 100644 --- 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 @@ -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; diff --git a/src/main/java/com/perfact/be/domain/news/extractor/AbstractNewsExtractor.java b/src/main/java/com/perfact/be/domain/news/extractor/AbstractNewsExtractor.java new file mode 100644 index 0000000..2663fea --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/AbstractNewsExtractor.java @@ -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(); +} diff --git a/src/main/java/com/perfact/be/domain/news/extractor/NewsExtractorStrategy.java b/src/main/java/com/perfact/be/domain/news/extractor/NewsExtractorStrategy.java new file mode 100644 index 0000000..58a9ad8 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/NewsExtractorStrategy.java @@ -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); +} diff --git a/src/main/java/com/perfact/be/domain/news/extractor/factory/NewsExtractorFactory.java b/src/main/java/com/perfact/be/domain/news/extractor/factory/NewsExtractorFactory.java new file mode 100644 index 0000000..ef86811 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/factory/NewsExtractorFactory.java @@ -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 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 getAllExtractors() { + return extractors; + } +} diff --git a/src/main/java/com/perfact/be/domain/news/extractor/impl/GenericNewsExtractor.java b/src/main/java/com/perfact/be/domain/news/extractor/impl/GenericNewsExtractor.java new file mode 100644 index 0000000..33a29f3 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/impl/GenericNewsExtractor.java @@ -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" + }; + } +} diff --git a/src/main/java/com/perfact/be/domain/news/extractor/impl/NaverNewsExtractor.java b/src/main/java/com/perfact/be/domain/news/extractor/impl/NaverNewsExtractor.java new file mode 100644 index 0000000..b997952 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/impl/NaverNewsExtractor.java @@ -0,0 +1,68 @@ +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 NaverNewsExtractor extends AbstractNewsExtractor { + + public NaverNewsExtractor(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"); + } + + @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); + + log.info("네이버 뉴스 추출 완료 - 제목: {}, 날짜: {}, 내용 길이: {}", + title, date, content.length()); + + return new NewsArticleResponse(title, date, content); + + } catch (Exception e) { + log.error("네이버 뉴스 추출 실패: {}", url, e); + throw e; + } + } + + @Override + protected String[] getTitleSelectors() { + return new String[] { + "#title_area span", + ".title_area .title", + "h1", + ".title", + "[class*=\"title\"]", + "title" + }; + } + + @Override + protected String[] getContentSelectors() { + return new String[] { + "#dic_area", + ".dic_area", + "article", + "[id*=\"dic\"]", + "[class*=\"article\"]" + }; + } +} diff --git a/src/main/java/com/perfact/be/domain/news/extractor/impl/NewsisNewsExtractor.java b/src/main/java/com/perfact/be/domain/news/extractor/impl/NewsisNewsExtractor.java new file mode 100644 index 0000000..44f0647 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/impl/NewsisNewsExtractor.java @@ -0,0 +1,170 @@ +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.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.springframework.stereotype.Component; + +// 뉴시스 추출기 +@Slf4j +@Component +public class NewsisNewsExtractor extends AbstractNewsExtractor { + + public NewsisNewsExtractor(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("newsis.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(doc); + + log.info("뉴시스 추출 완료 - 제목: {}, 날짜: {}, 내용 길이: {}", + title, date, content.length()); + + return new NewsArticleResponse(title, date, content); + + } catch (Exception e) { + log.error("뉴시스 추출 실패: {}", url, e); + throw e; + } + } + + // 뉴시스에서 날짜 추출 + private String extractDate(Document doc) { + try { + Element dateElement = doc.selectFirst(".txt"); + if (dateElement != null) { + Elements spans = dateElement.select("span"); + for (Element span : spans) { + String dateText = span.text().trim(); + // "등록 2025.08.19 17:09:47" 형태에서 등록 부분만 추출 + if (dateText.startsWith("등록")) { + String date = dateText.replace("등록", "").trim(); + return date; + } + } + } + return "날짜 정보 없음"; + } catch (Exception e) { + log.warn("뉴시스 날짜 추출 실패", e); + return "날짜 정보 없음"; + } + } + + // 뉴시스 본문 정제 + @Override + protected String processContentElement(Element contentElement) { + // 불필요한 요소들 제거 + removeUnnecessaryElements(contentElement); + + StringBuilder content = new StringBuilder(); + + // p 태그들 처리 + Elements paragraphs = contentElement.select("p"); + for (Element p : paragraphs) { + String text = p.text().trim(); + if (!text.isEmpty() && !isUnnecessaryText(text)) { + content.append(text).append("\n\n"); + } + } + + // li 태그들 처리 + Elements listItems = contentElement.select("li"); + for (Element li : listItems) { + String text = li.text().trim(); + if (!text.isEmpty() && !isUnnecessaryText(text)) { + 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(); + } + + // 불필요한 요소들 제거 + private void removeUnnecessaryElements(Element contentElement) { + // 광고 관련 요소 제거 + contentElement.select("iframe").remove(); + contentElement.select("#view_ad").remove(); + + // 이미지 관련 요소 제거 + contentElement.select(".thumCont").remove(); + contentElement.select(".article_photo").remove(); + contentElement.select(".photojournal").remove(); + + // 요약 부분 제거 + contentElement.select(".summury").remove(); + + // 스크립트 태그 제거 + contentElement.select("script").remove(); + + // 기타 불필요한 요소들 제거 + contentElement.select(".desc").remove(); + } + + // 불필요한 텍스트 확인 + private boolean isUnnecessaryText(String text) { + // 기자 연락처 제거 + if (text.contains("◎공감언론 뉴시스") || text.contains("@newsis.com")) { + return true; + } + + // 이미지 캡션 관련 텍스트 제거 + if (text.contains("[서울=뉴시스]") && text.contains("기자 =")) { + return true; + } + + // 날짜 관련 텍스트 제거 (본문에서) + if (text.contains("등록") || text.contains("수정")) { + return true; + } + + return false; + } + + @Override + protected String[] getTitleSelectors() { + return new String[] { + "h1.tit.title_area", + "h1.title_area", + "h1.tit", + "h1", + ".title", + "title" + }; + } + + @Override + protected String[] getContentSelectors() { + return new String[] { + "article", + ".content", + ".article", + "#textBody" + }; + } +} diff --git a/src/main/java/com/perfact/be/domain/news/extractor/impl/NocutNewsExtractor.java b/src/main/java/com/perfact/be/domain/news/extractor/impl/NocutNewsExtractor.java new file mode 100644 index 0000000..ed3a458 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/impl/NocutNewsExtractor.java @@ -0,0 +1,162 @@ +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.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.springframework.stereotype.Component; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +// 노컷뉴스 추출기 +@Slf4j +@Component +public class NocutNewsExtractor extends AbstractNewsExtractor { + + public NocutNewsExtractor(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("nocutnews.co.kr"); + } + + @Override + public NewsArticleResponse extract(String url) { + try { + log.info("노컷뉴스 추출 시작: {}", url); + Document doc = getDocument(url); + + String title = extractTitle(doc, getTitleSelectors()); + String content = extractContent(doc, getContentSelectors()); + String date = extractDate(doc); + + log.info("노컷뉴스 추출 완료 - 제목: {}, 날짜: {}, 내용 길이: {}", title, date, content.length()); + + return new NewsArticleResponse(title, date, content); + } catch (Exception e) { + log.error("노컷뉴스 추출 실패: {}", url, e); + throw new RuntimeException("노컷뉴스 기사 추출에 실패했습니다: " + url, e); + } + } + + // 노컷뉴스 특화 날짜 추출 + private String extractDate(Document doc) { + try { + Elements dateElements = doc.select("ul.bl_b li"); + + // 두 번째 li가 있는지 확인 + if (dateElements.size() >= 2) { + Element secondLi = dateElements.get(1); // 두 번째 li (인덱스 1) + String text = secondLi.text().trim(); + + // 날짜 패턴 확인 + Pattern datePattern = Pattern.compile("\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}"); + Matcher matcher = datePattern.matcher(text); + + if (matcher.find()) { + return matcher.group(); + } else { + log.warn("노컷뉴스 두 번째 li에서 날짜 패턴을 찾을 수 없습니다: {}", text); + } + } else { + log.warn("노컷뉴스 ul.bl_b에 li가 2개 미만입니다. 실제 개수: {}", dateElements.size()); + } + + // 기존 방식으로도 시도 (fallback) + Pattern datePattern = Pattern.compile("\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}"); + for (Element element : dateElements) { + String text = element.text().trim(); + Matcher matcher = datePattern.matcher(text); + if (matcher.find()) { + log.info("fallback 방식으로 날짜 추출 성공: {}", matcher.group()); + return matcher.group(); + } + } + + log.warn("노컷뉴스 날짜를 찾을 수 없습니다"); + return "날짜 정보 없음"; + } catch (Exception e) { + log.error("노컷뉴스 날짜 추출 실패", e); + return "날짜 정보 없음"; + } + } + + @Override + protected String processContentElement(Element contentElement) { + // 불필요한 요소들 제거 + removeUnnecessaryElements(contentElement); + + // 텍스트 추출 및 정제 + String content = contentElement.text(); + + // 불필요한 텍스트 필터링 + String[] lines = content.split("\n"); + StringBuilder cleanedContent = new StringBuilder(); + + for (String line : lines) { + line = line.trim(); + if (!line.isEmpty() && !isUnnecessaryText(line)) { + cleanedContent.append(line).append("\n"); + } + } + + return cleanedContent.toString().trim(); + } + + // 불필요한 HTML 요소들 제거 + private void removeUnnecessaryElements(Element contentElement) { + // 광고 관련 요소 제거 + contentElement.select("iframe").remove(); + contentElement.select("div[style*='text-align: right'][style*='float: right']").remove(); + + // 관련기사 제거 + contentElement.select(".news-related_n").remove(); + + // 이미지 관련 요소 제거 (캡션은 유지) + contentElement.select(".fr-img-space-wrap").remove(); + + // 기타 불필요한 요소들 제거 + contentElement.select("script").remove(); + contentElement.select("style").remove(); + } + + // 불필요한 텍스트 확인 + private boolean isUnnecessaryText(String text) { + if (text == null || text.trim().isEmpty()) { + return true; + } + + // 광고 관련 텍스트 제거 + if (text.contains("광고") || text.contains("sponsored")) { + return true; + } + + // 관련기사 관련 텍스트 제거 + if (text.contains("관련 기사") || text.contains("추천 기사")) { + return true; + } + + // 날짜 패턴이지만 기사 내용이 아닌 경우 제거 + if (text.matches("\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}")) { + return true; + } + + return false; + } + + @Override + protected String[] getTitleSelectors() { + return new String[] { "div.h_info h2", "h2", ".title", "title" }; + } + + @Override + protected String[] getContentSelectors() { + return new String[] { "div#pnlContent", "#pnlContent", ".content", "article" }; + } +} diff --git a/src/main/java/com/perfact/be/domain/news/extractor/impl/OhMyNewsExtractor.java b/src/main/java/com/perfact/be/domain/news/extractor/impl/OhMyNewsExtractor.java new file mode 100644 index 0000000..0df7729 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/impl/OhMyNewsExtractor.java @@ -0,0 +1,169 @@ +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.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.springframework.stereotype.Component; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +// 오마이뉴스 추출기 +@Slf4j +@Component +public class OhMyNewsExtractor extends AbstractNewsExtractor { + + public OhMyNewsExtractor(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("ohmynews.com"); + } + + @Override + public NewsArticleResponse extract(String url) { + try { + log.info("오마이뉴스 추출 시작: {}", url); + Document doc = getDocument(url); + + String title = extractTitle(doc, getTitleSelectors()); + String content = extractContent(doc, getContentSelectors()); + String date = extractDate(doc); + + log.info("오마이뉴스 추출 완료 - 제목: {}, 날짜: {}, 내용 길이: {}", title, date, content.length()); + + return new NewsArticleResponse(title, date, content); + } catch (Exception e) { + log.error("오마이뉴스 추출 실패: {}", url, e); + throw new RuntimeException("오마이뉴스 기사 추출에 실패했습니다: " + url, e); + } + } + + @Override + protected String[] getTitleSelectors() { + return new String[] { + "h2.article_tit a", + "h2.article_tit", + ".article_tit a", + ".article_tit" + }; + } + + @Override + protected String[] getContentSelectors() { + return new String[] { + "div.at_contents[itemprop='articleBody']", + "div.at_contents", + ".at_contents" + }; + } + + // 오마이뉴스 특화 날짜 추출 + private String extractDate(Document doc) { + try { + // 오마이뉴스 날짜 선택자들 (우선순위 순) + String[] dateSelectors = { + "div.atc-sponsor span.date", // 기존 셀렉터 + "span.date", // 직접 span.date + ".date", // 클래스로만 + "[class*='date']" // 클래스에 date 포함 + }; + + for (String selector : dateSelectors) { + Elements dateElements = doc.select(selector); + + if (!dateElements.isEmpty()) { + Element firstDateElement = dateElements.first(); + String dateText = firstDateElement.text().trim(); + + log.debug("오마이뉴스 원본 날짜 텍스트: {}", dateText); + + // "25.08.19 15:25" 또는 "25.08.19 19:00" 형식을 "2025-08-19 15:25" 형식으로 변환 + String convertedDate = convertOhMyNewsDate(dateText); + + if (convertedDate != null) { + log.info("오마이뉴스 날짜 변환 성공: {} → {}", dateText, convertedDate); + return convertedDate; + } + } + } + + log.warn("오마이뉴스 날짜를 찾을 수 없습니다"); + return "날짜 정보 없음"; + } catch (Exception e) { + log.error("오마이뉴스 날짜 추출 실패", e); + return "날짜 정보 없음"; + } + } + + // 오마이뉴스 날짜 형식 변환 + private String convertOhMyNewsDate(String dateText) { + try { + // "25.08.19 15:25" 또는 "25.08.19 19:00" 형식 매칭 (시간이 1자리 또는 2자리) + Pattern pattern = Pattern.compile("(\\d{2})\\.(\\d{2})\\.(\\d{2})\\s+(\\d{1,2}):(\\d{2})"); + Matcher matcher = pattern.matcher(dateText); + + if (matcher.find()) { + String year = matcher.group(1); + String month = matcher.group(2); + String day = matcher.group(3); + int hour = Integer.parseInt(matcher.group(4)); + String minute = matcher.group(5); + + // 20xx년으로 변환 (25 → 2025) + String fullYear = "20" + year; + + // 시간을 2자리로 포맷팅 + String formattedHour = String.format("%02d", hour); + + return String.format("%s-%s-%s %s:%s", fullYear, month, day, formattedHour, minute); + } + + return null; + } catch (Exception e) { + log.error("오마이뉴스 날짜 변환 실패: {}", dateText, e); + return null; + } + } + + @Override + protected String processContentElement(Element contentElement) { + // 오마이뉴스 특화 요소 제거 + removeOhMyNewsSpecificElements(contentElement); + + return contentElement.text().trim(); + } + + // 오마이뉴스 특화 불필요한 요소들 제거 + private void removeOhMyNewsSpecificElements(Element contentElement) { + // 광고 관련 요소들 제거 + contentElement.select("div[id*='ad'], div[id*='Ad'], .ad, .ads, .advertisement").remove(); + contentElement.select("script, style, iframe").remove(); + + // 오마이뉴스 특화 요소들 제거 + contentElement.select("div.dvCenterAd, .V0999, .text").remove(); + contentElement.select("button.zoom-btn, button.rhksfus").remove(); + contentElement.select("figure.omn-photo").remove(); + + // 이미지 관련 요소들 제거 + contentElement.select("figure, .pho-center, .pho-caption").remove(); + contentElement.select("img[src*='ohmynews.com']").remove(); + + // 기타 불필요한 요소들 + contentElement.select("div[id*='google'], div[id*='Google']").remove(); + contentElement.select("div[class*='ad'], div[class*='Ad']").remove(); + + // HTML 주석 제거 + contentElement.select("*").forEach(element -> { + if (element.nodeName().equals("#comment")) { + element.remove(); + } + }); + } +} diff --git a/src/main/java/com/perfact/be/domain/news/extractor/impl/YnaNewsExtractor.java b/src/main/java/com/perfact/be/domain/news/extractor/impl/YnaNewsExtractor.java new file mode 100644 index 0000000..86c9c4e --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/impl/YnaNewsExtractor.java @@ -0,0 +1,164 @@ +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.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.springframework.stereotype.Component; + +// 연합뉴스 추출기 +@Slf4j +@Component +public class YnaNewsExtractor extends AbstractNewsExtractor { + + public YnaNewsExtractor(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("yna.co.kr"); + } + + @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(doc); + + log.info("연합뉴스 추출 완료 - 제목: {}, 날짜: {}, 내용 길이: {}", + title, date, content.length()); + + return new NewsArticleResponse(title, date, content); + + } catch (Exception e) { + log.error("연합뉴스 추출 실패: {}", url, e); + throw e; + } + } + + // 연합뉴스에서 날짜 추출 + private String extractDate(Document doc) { + try { + Element dateElement = doc.selectFirst(".txt-time01"); + if (dateElement != null) { + String dateText = dateElement.text().trim(); + // "송고2025-08-19 19:29" 형태에서 날짜 부분만 추출 + if (dateText.contains("송고")) { + String date = dateText.replace("송고", "").trim(); + return date; + } + return dateText; + } + return "날짜 정보 없음"; + } catch (Exception e) { + log.warn("연합뉴스 날짜 추출 실패", e); + return "날짜 정보 없음"; + } + } + + // 연합뉴스 본문 정제 + @Override + protected String processContentElement(Element contentElement) { + // 불필요한 요소들 제거 + removeUnnecessaryElements(contentElement); + + StringBuilder content = new StringBuilder(); + + // p 태그들 처리 + Elements paragraphs = contentElement.select("p"); + for (Element p : paragraphs) { + String text = p.text().trim(); + if (!text.isEmpty() && !isUnnecessaryText(text)) { + content.append(text).append("\n\n"); + } + } + + // li 태그들 처리 + Elements listItems = contentElement.select("li"); + for (Element li : listItems) { + String text = li.text().trim(); + if (!text.isEmpty() && !isUnnecessaryText(text)) { + 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(); + } + + // 불필요한 요소들 제거 + private void removeUnnecessaryElements(Element contentElement) { + // 광고 관련 요소 제거 + contentElement.select("aside").remove(); + + // 기자 정보 제거 + contentElement.select(".writer-zone01").remove(); + + // 이미지 그룹 제거 + contentElement.select(".comp-box.photo-group").remove(); + + // 저작권 정보 제거 + contentElement.select(".txt-copyright").remove(); + + // 기타 불필요한 요소들 제거 + contentElement.select(".tit-sub").remove(); + contentElement.select(".swiper-area").remove(); + } + + // 불필요한 텍스트 확인 + private boolean isUnnecessaryText(String text) { + // 이메일 주소 제거 + if (text.contains("@") && text.contains(".co.kr")) { + return true; + } + + // 저작권 관련 텍스트 제거 + if (text.contains("저작권자") || text.contains("무단 전재") || text.contains("AI 학습")) { + return true; + } + + // 제보 관련 텍스트 제거 + if (text.contains("제보는 카카오톡")) { + return true; + } + + return false; + } + + @Override + protected String[] getTitleSelectors() { + return new String[] { + "h1.tit01", + "h1", + ".title", + "title" + }; + } + + @Override + protected String[] getContentSelectors() { + return new String[] { + ".story-news.article", + ".article", + ".content", + "article" + }; + } +} 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 index 4723523..3c3496c 100644 --- a/src/main/java/com/perfact/be/domain/news/service/NewsExtractorServiceImpl.java +++ b/src/main/java/com/perfact/be/domain/news/service/NewsExtractorServiceImpl.java @@ -1,107 +1,61 @@ package com.perfact.be.domain.news.service; import com.perfact.be.domain.news.config.SelectorConfig; +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.extractor.factory.NewsExtractorFactory; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class NewsExtractorServiceImpl implements NewsExtractorService { + private final NewsExtractorFactory newsExtractorFactory; private final HtmlParserService htmlParserService; private final SelectorConfig selectorConfig; - // 뉴스 기사 내용 추출 + // 뉴스 기사 내용 추출 (기존 메서드 유지 - 호환성) @Override public String extractNewsArticleContent(String url) { try { - Document doc = htmlParserService.getHtmlFromUrl(url); - StringBuilder content = new StringBuilder(); + log.info("뉴스 기사 내용 추출 시작: {}", url); - Element titleArea = doc.selectFirst(".title_area .title"); - if (titleArea != null) { - content.append("제목: ").append(titleArea.text().trim()).append("\n\n"); - } + // 새로운 팩토리 패턴 사용 + NewsArticleResponse newsData = newsExtractorFactory.extractNews(url); - String extractedContent = extractContentFromDocument(doc); - if (!extractedContent.trim().isEmpty()) { - content.append(extractedContent); - } + log.info("뉴스 기사 내용 추출 완료 - 제목: {}, 내용 길이: {}", + newsData.getTitle(), newsData.getContent().length()); - return content.toString(); + return newsData.getContent(); } catch (Exception e) { + log.error("뉴스 기사 내용 추출 실패: {}", url, e); throw new NewsHandler(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(); + log.info("다른 뉴스 사이트 제목 추출 시작: {}", url); + + // 새로운 팩토리 패턴 사용 + NewsArticleResponse newsData = newsExtractorFactory.extractNews(url); + + log.info("다른 뉴스 사이트 제목 추출 완료: {}", newsData.getTitle()); - String title = htmlParserService.extractTextWithMultipleSelectors(url, titleSelectors); - return title != null ? title : "제목을 찾을 수 없습니다"; + return newsData.getTitle(); } catch (Exception e) { + log.error("다른 뉴스 사이트 제목 추출 실패: {}", url, e); return "제목을 찾을 수 없습니다"; } } 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 index 25d88a5..18f6db4 100644 --- a/src/main/java/com/perfact/be/domain/news/service/NewsService.java +++ b/src/main/java/com/perfact/be/domain/news/service/NewsService.java @@ -7,12 +7,6 @@ public interface NewsService { // URL에서 HTML 가져오기 org.jsoup.nodes.Document getHtmlFromUrl(String url); - // 네이버 뉴스 도메인인지 확인 - boolean isNaverNewsDomain(String url); - - // 네이버 뉴스의 제목과 내용 추출 - NewsArticleResponse extractNaverNewsArticle(String url); - // 뉴스 기사 내용 추출 String extractNewsArticleContent(String url); 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 index b782211..d6279ba 100644 --- a/src/main/java/com/perfact/be/domain/news/service/NewsServiceImpl.java +++ b/src/main/java/com/perfact/be/domain/news/service/NewsServiceImpl.java @@ -3,13 +3,17 @@ import com.perfact.be.domain.news.config.SelectorConfig; import com.perfact.be.domain.news.dto.NewsArticleResponse; import com.perfact.be.domain.news.exception.NewsExceptionHandler; +import com.perfact.be.domain.news.extractor.factory.NewsExtractorFactory; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class NewsServiceImpl implements NewsService { + private final NewsExtractorFactory newsExtractorFactory; private final HtmlParserService htmlParserService; private final NaverApiService naverApiService; private final NewsExtractorService newsExtractorService; @@ -22,50 +26,11 @@ 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); diff --git a/src/main/java/com/perfact/be/domain/report/converter/ClovaAnalysisConverter.java b/src/main/java/com/perfact/be/domain/report/converter/ClovaAnalysisConverter.java index d3066a7..61b27b8 100644 --- a/src/main/java/com/perfact/be/domain/report/converter/ClovaAnalysisConverter.java +++ b/src/main/java/com/perfact/be/domain/report/converter/ClovaAnalysisConverter.java @@ -232,12 +232,30 @@ private LocalDate parsePublicationDate(String dateStr) { int year = Integer.parseInt(parts[0]); int month = Integer.parseInt(parts[1]); int day = Integer.parseInt(parts[2]); - return LocalDate.of(year, month, day); + LocalDate result = LocalDate.of(year, month, day); + log.debug("결과: {}", result); + return result; + } + + // "2025-07-25 15:50" 형식 처리 (노컷뉴스, 오마이뉴스 등) + log.debug("2025-07-25 15:50 형식 매칭 시도: {}", cleanDate.matches("\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}")); + if (cleanDate.matches("\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}")) { + log.debug("2025-07-25 15:50 형식 매칭됨"); + String[] parts = cleanDate.split("\\s+")[0].split("-"); + int year = Integer.parseInt(parts[0]); + int month = Integer.parseInt(parts[1]); + int day = Integer.parseInt(parts[2]); + LocalDate result = LocalDate.of(year, month, day); + log.debug("결과: {}", result); + return result; } // 기존 로직 (ISO 형식) if (cleanDate.length() >= 10) { - return LocalDate.parse(cleanDate.substring(0, 10)); + log.debug("ISO 형식 처리"); + LocalDate result = LocalDate.parse(cleanDate.substring(0, 10)); + log.debug("결과: {}", result); + return result; } return LocalDate.now(); 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 index 85f1ce3..68cb081 100644 --- a/src/main/java/com/perfact/be/domain/report/service/ReportServiceImpl.java +++ b/src/main/java/com/perfact/be/domain/report/service/ReportServiceImpl.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.ObjectMapper; import com.perfact.be.domain.news.dto.NewsArticleResponse; +import com.perfact.be.domain.news.extractor.factory.NewsExtractorFactory; import com.perfact.be.domain.news.service.NewsService; import com.perfact.be.domain.report.converter.ClovaAnalysisConverter; import com.perfact.be.domain.report.converter.ReportConverter; @@ -48,7 +49,7 @@ public class ReportServiceImpl implements ReportService { private final RestTemplate restTemplate; private final ObjectMapper objectMapper; private final PromptService promptService; - + private final NewsExtractorFactory newsExtractorFactory; private final ReportConverter reportConverter; @Value("${api.clova.api-url}") @@ -60,11 +61,15 @@ public class ReportServiceImpl implements ReportService { @Override public Object analyzeNewsWithClova(String url) { try { - if (newsService.isNaverNewsDomain(url)) { - return analyzeNaverNews(url); - } else { - return analyzeOtherNewsSite(url); - } + // 모든 뉴스 사이트에 대해 동일한 방식으로 처리 + NewsArticleResponse newsData = newsExtractorFactory.extractNews(url); + ClovaRequestDTO request = createClovaRequest(newsData); + ClovaResponseDTO response = callClovaAPI(request); + return parseJsonResponse(response.getResult().getMessage().getContent()); + } catch (com.perfact.be.domain.news.exception.NewsHandler e) { + // NewsHandler는 그대로 전달 (지원하지 않는 뉴스 사이트 등) + log.error("뉴스 추출 실패 - URL: {}, 에러: {}", url, e.getMessage(), e); + throw e; } catch (Exception e) { log.error("Clova API 분석 실패 - URL: {}, 에러: {}", url, e.getMessage(), e); throw new ReportHandler(ReportErrorStatus.CLOVA_API_CALL_FAILED); @@ -75,15 +80,8 @@ public Object analyzeNewsWithClova(String url) { @Transactional public Report createReportFromAnalysis(Object analysisResult, String url, User user) { try { - // 1. 뉴스 데이터 추출 - 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); - } + // 1. 뉴스 데이터 추출 - 모든 뉴스 사이트에 대해 동일한 방식으로 처리 + NewsArticleResponse newsData = newsExtractorFactory.extractNews(url); // 2. 분석 결과를 JSON 문자열로 변환 log.debug("분석 결과 객체 타입: {}", analysisResult.getClass().getSimpleName()); @@ -113,6 +111,10 @@ public Report createReportFromAnalysis(Object analysisResult, String url, User u } return savedReport; + } catch (com.perfact.be.domain.news.exception.NewsHandler e) { + // NewsHandler는 그대로 전달 (지원하지 않는 뉴스 사이트 등) + log.error("뉴스 추출 실패 - URL: {}, 사용자: {}, 에러: {}", url, user.getId(), e.getMessage(), e); + throw e; } catch (Exception e) { log.error("분석 결과로부터 리포트 생성 실패 - URL: {}, 사용자: {}, 에러: {}", url, user.getId(), e.getMessage(), e); throw new ReportHandler(ReportErrorStatus.REPORT_CREATION_FAILED); @@ -123,15 +125,8 @@ public Report createReportFromAnalysis(Object analysisResult, String url, User u @Transactional public Report analyzeNewsAndCreateReport(String url, User user) { try { - // 1. 뉴스 데이터 추출 - 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); - } + // 1. 뉴스 데이터 추출 - 모든 뉴스 사이트에 대해 동일한 방식으로 처리 + NewsArticleResponse newsData = newsExtractorFactory.extractNews(url); // 2. Clova API 분석 수행 Object analysisResult = analyzeNewsWithClova(url); @@ -162,6 +157,10 @@ public Report analyzeNewsAndCreateReport(String url, User user) { } return savedReport; + } catch (com.perfact.be.domain.news.exception.NewsHandler e) { + // NewsHandler는 그대로 전달 (지원하지 않는 뉴스 사이트 등) + log.error("뉴스 추출 실패 - URL: {}, 사용자: {}, 에러: {}", url, user.getId(), e.getMessage(), e); + throw e; } catch (Exception e) { log.error("리포트 생성 실패 - URL: {}, 사용자: {}, 에러: {}", url, user.getId(), e.getMessage(), e); throw new ReportHandler(ReportErrorStatus.REPORT_CREATION_FAILED); @@ -197,33 +196,6 @@ public ReportResponseDto getReport(User loginUser, Long reportId) { return ReportResponseDto.from(report, trueScore, reportBadges); } - 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) { - log.error("네이버 뉴스 분석 실패 - URL: {}, 에러: {}", url, e.getMessage(), e); - throw new ReportHandler(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) { - log.error("기타 뉴스 사이트 분석 실패 - URL: {}, 에러: {}", url, e.getMessage(), e); - throw new ReportHandler(ReportErrorStatus.CLOVA_API_CALL_FAILED); - } - } - private Object parseJsonResponse(String analysisResult) { try { // 원본 응답 로깅 @@ -243,24 +215,52 @@ private Object parseJsonResponse(String analysisResult) { // 앞뒤 공백 제거 jsonContent = jsonContent.trim(); + // 쌍따옴표로 묶인 JSON 문자열 처리 + if (jsonContent.startsWith("\"") && jsonContent.endsWith("\"")) { + // 완전히 쌍따옴표로 감싸진 경우 + jsonContent = jsonContent.substring(1, jsonContent.length() - 1); + jsonContent = jsonContent.replace("\\\"", "\"").replace("\\\\", "\\"); + log.debug("완전히 쌍따옴표로 감싸진 JSON 처리 후: {}", jsonContent); + } else if (jsonContent.endsWith("\"")) { + // 끝에만 쌍따옴표가 있는 경우 + jsonContent = jsonContent.substring(0, jsonContent.length() - 1); + log.debug("끝 쌍따옴표 제거 후 JSON: {}", jsonContent); + } + // JSON 구조 검증 log.debug("=== JSON 구조 검증 ==="); log.debug("처리된 JSON 내용 길이: {}", jsonContent.length()); log.debug("처리된 JSON 내용: {}", jsonContent); // JSON 구조가 올바른지 미리 검증 - if (!jsonContent.startsWith("{") || !jsonContent.endsWith("}")) { - log.error("JSON 구조가 올바르지 않습니다. 시작: {}, 끝: {}", - jsonContent.length() > 0 ? jsonContent.charAt(0) : "empty", - jsonContent.length() > 0 ? jsonContent.charAt(jsonContent.length() - 1) : "empty"); + if (!jsonContent.startsWith("{")) { + log.error("JSON 구조가 올바르지 않습니다. 시작: {}", + jsonContent.length() > 0 ? jsonContent.charAt(0) : "empty"); throw new ReportHandler(ReportErrorStatus.ANALYSIS_RESULT_PARSING_FAILED); } + // 끝 부분에서 } 찾기 (공백이나 개행 문자 제거 후) + String trimmedContent = jsonContent.trim(); + if (!trimmedContent.endsWith("}")) { + log.error("JSON 구조가 올바르지 않습니다. 끝: {}", + trimmedContent.length() > 0 ? trimmedContent.charAt(trimmedContent.length() - 1) : "empty"); + // 마지막 } 찾기 시도 + int lastBraceIndex = trimmedContent.lastIndexOf("}"); + if (lastBraceIndex > 0) { + jsonContent = trimmedContent.substring(0, lastBraceIndex + 1); + log.debug("마지막 } 위치에서 잘라서 처리: {}", jsonContent); + } else { + throw new ReportHandler(ReportErrorStatus.ANALYSIS_RESULT_PARSING_FAILED); + } + } + // JSON 파싱하여 객체로 변환 (더 강력한 인코딩 처리) ObjectMapper mapper = new ObjectMapper(); mapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true); mapper.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true); mapper.configure(JsonParser.Feature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER, true); + mapper.configure(JsonParser.Feature.IGNORE_UNDEFINED, true); + mapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true); // UTF-8로 명시적 인코딩하여 파싱 Object jsonObject = mapper.readValue(jsonContent.getBytes(StandardCharsets.UTF_8), Object.class); @@ -271,8 +271,38 @@ private Object parseJsonResponse(String analysisResult) { log.error("JSON 파싱 실패 - 원본 내용: {}", analysisResult); log.error("JSON 파싱 실패 - 에러: {}", e.getMessage(), e); - // 파싱 실패 시 예외를 던져서 상위에서 처리하도록 함 - throw new ReportHandler(ReportErrorStatus.ANALYSIS_RESULT_PARSING_FAILED); + // 파싱 실패 시 더 유연한 파싱 시도 + try { + log.warn("기본 파싱 실패, 더 유연한 파싱 시도"); + ObjectMapper mapper = new ObjectMapper(); + mapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true); + mapper.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true); + mapper.configure(JsonParser.Feature.IGNORE_UNDEFINED, true); + mapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true); + + // JSON 문자열에서 불필요한 문자 제거 + String cleanedJson = analysisResult + .replaceAll("\\s+", " ") + .replaceAll(",\\s*}", "}") + .replaceAll(",\\s*]", "]"); + + // 쌍따옴표로 묶인 JSON 문자열 처리 + if (cleanedJson.startsWith("\"") && cleanedJson.endsWith("\"")) { + // 완전히 쌍따옴표로 감싸진 경우 + cleanedJson = cleanedJson.substring(1, cleanedJson.length() - 1); + cleanedJson = cleanedJson.replace("\\\"", "\"").replace("\\\\", "\\"); + } else if (cleanedJson.endsWith("\"")) { + // 끝에만 쌍따옴표가 있는 경우 + cleanedJson = cleanedJson.substring(0, cleanedJson.length() - 1); + } + + Object jsonObject = mapper.readValue(cleanedJson, Object.class); + log.debug("유연한 파싱 성공"); + return jsonObject; + } catch (Exception e2) { + log.error("유연한 파싱도 실패: {}", e2.getMessage()); + throw new ReportHandler(ReportErrorStatus.ANALYSIS_RESULT_PARSING_FAILED); + } } } @@ -290,7 +320,7 @@ private ClovaRequestDTO createClovaRequest(NewsArticleResponse newsData) throws messages, 0.8, 0, - 2048, + 1024, 0.5, 1.1, new ArrayList<>(),