From 651ba02971a8dae8bbb8b4bd17472f403cac2d0c Mon Sep 17 00:00:00 2001 From: hardwoong Date: Wed, 20 Aug 2025 00:59:50 +0900 Subject: [PATCH 1/7] =?UTF-8?q?Refact=20:=20maxToken=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=ED=86=B5=ED=95=9C=20=ED=8C=8C=EC=8B=B1=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/ClovaAnalysisConverter.java | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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(); From 68689a95fcb85673c5d57ee8d49d8f16280d7fc5 Mon Sep 17 00:00:00 2001 From: hardwoong Date: Wed, 20 Aug 2025 01:01:19 +0900 Subject: [PATCH 2/7] =?UTF-8?q?Refact=20:=20=ED=81=B4=EB=A1=9C=EB=B0=94=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=EA=B0=92=20=EC=8C=8D=EB=94=B0=EC=98=B4?= =?UTF-8?q?=ED=91=9C=20=ED=8F=AC=ED=95=A8=ED=95=98=EB=8A=94=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=ED=95=B8=EB=93=A4=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/service/ReportServiceImpl.java | 101 +++++++++++++++--- 1 file changed, 86 insertions(+), 15 deletions(-) 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..a6dd741 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}") @@ -128,9 +129,15 @@ public Report analyzeNewsAndCreateReport(String url, User user) { if (newsService.isNaverNewsDomain(url)) { newsData = newsService.extractNaverNewsArticle(url); } else { - String title = newsService.extractTitleFromOtherNewsSites(url); - String content = newsService.extractNewsArticleContent(url); - newsData = new NewsArticleResponse(title, "날짜 정보 없음", content); + // 다른 뉴스 사이트의 경우에도 팩토리를 사용해서 완전한 정보 추출 + try { + newsData = newsExtractorFactory.extractNews(url); + } catch (Exception e) { + log.warn("뉴스 추출기로 추출 실패, 기존 방식으로 fallback: {}", url); + String title = newsService.extractTitleFromOtherNewsSites(url); + String content = newsService.extractNewsArticleContent(url); + newsData = new NewsArticleResponse(title, "날짜 정보 없음", content); + } } // 2. Clova API 분석 수행 @@ -211,10 +218,16 @@ private Object analyzeNaverNews(String url) { private Object analyzeOtherNewsSite(String url) { try { - String title = newsService.extractTitleFromOtherNewsSites(url); - String content = newsService.extractNewsArticleContent(url); - - NewsArticleResponse newsData = new NewsArticleResponse(title, "날짜 정보 없음", content); + // 다른 뉴스 사이트의 경우에도 팩토리를 사용해서 완전한 정보 추출 + NewsArticleResponse newsData; + try { + newsData = newsExtractorFactory.extractNews(url); + } catch (Exception e) { + log.warn("뉴스 추출기로 추출 실패, 기존 방식으로 fallback: {}", url); + String title = newsService.extractTitleFromOtherNewsSites(url); + String content = newsService.extractNewsArticleContent(url); + newsData = new NewsArticleResponse(title, "날짜 정보 없음", content); + } ClovaRequestDTO request = createClovaRequest(newsData); ClovaResponseDTO response = callClovaAPI(request); return parseJsonResponse(response.getResult().getMessage().getContent()); @@ -243,24 +256,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 +312,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 +361,7 @@ private ClovaRequestDTO createClovaRequest(NewsArticleResponse newsData) throws messages, 0.8, 0, - 2048, + 4096, 0.5, 1.1, new ArrayList<>(), From 67264a52ff86e5509adde6fc2297cb17f658ae3f Mon Sep 17 00:00:00 2001 From: hardwoong Date: Wed, 20 Aug 2025 01:03:38 +0900 Subject: [PATCH 3/7] =?UTF-8?q?Refact=20:=20=ED=83=80=20=EB=89=B4=EC=8A=A4?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8(5=EA=B0=9C=20=EC=96=B8=EB=A1=A0?= =?UTF-8?q?=EC=82=AC)=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/status/NewsErrorStatus.java | 12 +- .../news/extractor/AbstractNewsExtractor.java | 151 +++++++++++++++ .../news/extractor/NewsExtractorStrategy.java | 13 ++ .../factory/NewsExtractorFactory.java | 63 ++++++ .../extractor/impl/GenericNewsExtractor.java | 78 ++++++++ .../extractor/impl/NaverNewsExtractor.java | 70 +++++++ .../extractor/impl/NewsisNewsExtractor.java | 180 ++++++++++++++++++ .../extractor/impl/NocutNewsExtractor.java | 172 +++++++++++++++++ .../extractor/impl/OhMyNewsExtractor.java | 168 ++++++++++++++++ .../news/extractor/impl/YnaNewsExtractor.java | 174 +++++++++++++++++ .../service/NewsExtractorServiceImpl.java | 90 +++------ .../domain/news/service/NewsServiceImpl.java | 35 ++-- 12 files changed, 1110 insertions(+), 96 deletions(-) create mode 100644 src/main/java/com/perfact/be/domain/news/extractor/AbstractNewsExtractor.java create mode 100644 src/main/java/com/perfact/be/domain/news/extractor/NewsExtractorStrategy.java create mode 100644 src/main/java/com/perfact/be/domain/news/extractor/factory/NewsExtractorFactory.java create mode 100644 src/main/java/com/perfact/be/domain/news/extractor/impl/GenericNewsExtractor.java create mode 100644 src/main/java/com/perfact/be/domain/news/extractor/impl/NaverNewsExtractor.java create mode 100644 src/main/java/com/perfact/be/domain/news/extractor/impl/NewsisNewsExtractor.java create mode 100644 src/main/java/com/perfact/be/domain/news/extractor/impl/NocutNewsExtractor.java create mode 100644 src/main/java/com/perfact/be/domain/news/extractor/impl/OhMyNewsExtractor.java create mode 100644 src/main/java/com/perfact/be/domain/news/extractor/impl/YnaNewsExtractor.java 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..52c8a23 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", + "지원하지 않는 뉴스 사이트입니다. 현재 지원되는 사이트: 네이버뉴스, 연합뉴스, 뉴시스, 뉴스1, 노컷뉴스, 오마이뉴스 (네이버 뉴스에 최적화되어 있습니다.)"), + 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..d2832f3 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/AbstractNewsExtractor.java @@ -0,0 +1,151 @@ +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 문서에서 제목을 추출합니다. + * + * @param doc HTML 문서 + * @param titleSelectors 제목 셀렉터 배열 + * @return 추출된 제목 + */ + 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)); + return "제목을 찾을 수 없습니다"; + } + + /** + * HTML 문서에서 내용을 추출합니다. + * + * @param doc HTML 문서 + * @param contentSelectors 내용 셀렉터 배열 + * @return 추출된 내용 + */ + 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)); + return "내용을 찾을 수 없습니다"; + } + + /** + * 내용 요소를 처리합니다. + * + * @param contentElement 내용 요소 + * @return 처리된 내용 + */ + 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 문서를 가져옵니다. + * + * @param url URL + * @return 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); + } + } + + /** + * 날짜를 추출합니다. + * + * @param url URL + * @return 추출된 날짜 + */ + protected String extractDate(String url) { + try { + return dateExtractorService.extractArticleDate(url); + } catch (Exception e) { + log.warn("날짜 추출 실패: {}", url, e); + return "날짜 정보 없음"; + } + } + + /** + * 도메인별 제목 셀렉터를 반환합니다. + * + * @return 제목 셀렉터 배열 + */ + protected abstract String[] getTitleSelectors(); + + /** + * 도메인별 내용 셀렉터를 반환합니다. + * + * @return 내용 셀렉터 배열 + */ + 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..053feb1 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/factory/NewsExtractorFactory.java @@ -0,0 +1,63 @@ +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; + +/** + * 뉴스 추출기 팩토리 + * URL에 따라 적절한 추출기를 선택합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class NewsExtractorFactory { + + private final List extractors; + + /** + * URL에 맞는 추출기를 찾아 뉴스를 추출합니다. + * + * @param url 뉴스 URL + * @return 추출된 뉴스 데이터 + */ + public NewsArticleResponse extractNews(String url) { + log.info("뉴스 추출기 선택 시작: {}", url); + + NewsExtractorStrategy extractor = getExtractor(url); + log.info("선택된 추출기: {}", extractor.getClass().getSimpleName()); + + return extractor.extract(url); + } + + /** + * URL에 맞는 추출기를 찾습니다. + * + * @param url 뉴스 URL + * @return 적절한 추출기 + */ + 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); + }); + } + + /** + * 사용 가능한 모든 추출기를 반환합니다. + * + * @return 추출기 목록 + */ + 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..b3b1179 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/impl/GenericNewsExtractor.java @@ -0,0 +1,78 @@ +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) { + // 네이버 뉴스, 연합뉴스, 뉴시스, 노컷뉴스가 아닌 모든 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); + + 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[] { + "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..9a787ed --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/impl/NaverNewsExtractor.java @@ -0,0 +1,70 @@ +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..4c79321 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/impl/NewsisNewsExtractor.java @@ -0,0 +1,180 @@ +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..0a19cbf --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/impl/NocutNewsExtractor.java @@ -0,0 +1,172 @@ +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; + +/** + * 노컷뉴스 추출기 + * nocutnews.co.kr 도메인의 뉴스 기사를 처리합니다. + */ +@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); + } + } + + /** + * 노컷뉴스 특화 날짜 추출 + * ul.bl_b 안의 두 번째 li에서 날짜 추출 + */ + 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..44d8a26 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/impl/OhMyNewsExtractor.java @@ -0,0 +1,168 @@ +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; + +/** + * 오마이뉴스 추출기 + * ohmynews.com 도메인의 뉴스 기사를 처리합니다. + */ +@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" + }; + } + + /** + * 오마이뉴스 특화 날짜 추출 + * div.atc-sponsor 안의 첫 번째 span.date에서 날짜 추출 + */ + private String extractDate(Document doc) { + try { + // 오마이뉴스 날짜 선택자 + Elements dateElements = doc.select("div.atc-sponsor span.date"); + + if (!dateElements.isEmpty()) { + // 첫 번째 date span 사용 + Element firstDateElement = dateElements.first(); + String dateText = firstDateElement.text().trim(); + + log.debug("오마이뉴스 원본 날짜 텍스트: {}", dateText); + + // "25.08.19 15:25" 형식을 "2025-08-19 15:25" 형식으로 변환 + String convertedDate = convertOhMyNewsDate(dateText); + + if (convertedDate != null) { + log.info("오마이뉴스 날짜 변환 성공: {} → {}", dateText, convertedDate); + return convertedDate; + } + } + + // fallback: 다른 날짜 선택자들 시도 + Elements fallbackElements = doc.select("span.date, .date, [class*='date']"); + for (Element element : fallbackElements) { + String text = element.text().trim(); + String convertedDate = convertOhMyNewsDate(text); + if (convertedDate != null) { + log.info("fallback으로 오마이뉴스 날짜 추출 성공: {} → {}", text, convertedDate); + return convertedDate; + } + } + + log.warn("오마이뉴스 날짜를 찾을 수 없습니다"); + return "날짜 정보 없음"; + } catch (Exception e) { + log.error("오마이뉴스 날짜 추출 실패", e); + return "날짜 정보 없음"; + } + } + + /** + * 오마이뉴스 날짜 형식 변환 + * "25.08.19 15:25" → "2025-08-19 15:25" + */ + private String convertOhMyNewsDate(String dateText) { + try { + // "25.08.19 15:25" 형식 매칭 + Pattern pattern = Pattern.compile("(\\d{2})\\.(\\d{2})\\.(\\d{2})\\s+(\\d{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); + String time = matcher.group(4); + + // 20xx년으로 변환 (25 → 2025) + String fullYear = "20" + year; + + return String.format("%s-%s-%s %s", fullYear, month, day, time); + } + + 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("div[id*='google'], div[id*='Google']").remove(); + contentElement.select("div[class*='ad'], div[class*='Ad']").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..c8df5ed --- /dev/null +++ b/src/main/java/com/perfact/be/domain/news/extractor/impl/YnaNewsExtractor.java @@ -0,0 +1,174 @@ +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/NewsServiceImpl.java b/src/main/java/com/perfact/be/domain/news/service/NewsServiceImpl.java index b782211..5a089e6 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,21 +26,6 @@ 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); @@ -50,18 +39,18 @@ public boolean isNaverNewsDomain(String url) { @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")); - } + log.info("네이버 뉴스 기사 추출 시작: {}", url); + + // 새로운 팩토리 패턴 사용 + NewsArticleResponse newsData = newsExtractorFactory.extractNews(url); - String date = dateExtractorService.extractArticleDate(url); - String content = extractNewsArticleContent(url); + log.info("네이버 뉴스 기사 추출 완료 - 제목: {}, 날짜: {}, 내용 길이: {}", + newsData.getTitle(), newsData.getDate(), newsData.getContent().length()); - return new NewsArticleResponse(title, date, content); + return newsData; } catch (Exception e) { + log.error("네이버 뉴스 기사 추출 실패: {}", url, e); return null; } } From 932ed7bdff9c82282848ced8156d6ca3cce71092 Mon Sep 17 00:00:00 2001 From: hardwoong Date: Wed, 20 Aug 2025 01:27:43 +0900 Subject: [PATCH 4/7] =?UTF-8?q?Refact=20:=20maxToken=20=EA=B0=92=20?= =?UTF-8?q?=EC=B5=9C=EB=8C=80=20=EB=B0=98=ED=99=98=EA=B0=92=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=94=EC=96=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/perfact/be/domain/report/service/ReportServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a6dd741..81f10b1 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 @@ -361,7 +361,7 @@ private ClovaRequestDTO createClovaRequest(NewsArticleResponse newsData) throws messages, 0.8, 0, - 4096, + 1024, 0.5, 1.1, new ArrayList<>(), From 57adf19188f92ecc5ac9e09e1550b6be38403a8f Mon Sep 17 00:00:00 2001 From: hardwoong Date: Wed, 20 Aug 2025 01:32:02 +0900 Subject: [PATCH 5/7] =?UTF-8?q?Refact=20:=20=EB=89=B4=EC=8A=A41=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/extractor/AbstractNewsExtractor.java | 54 +++---------------- .../factory/NewsExtractorFactory.java | 25 ++------- .../extractor/impl/GenericNewsExtractor.java | 7 +-- .../extractor/impl/NaverNewsExtractor.java | 4 +- .../extractor/impl/NewsisNewsExtractor.java | 20 ++----- .../extractor/impl/NocutNewsExtractor.java | 18 ++----- .../extractor/impl/OhMyNewsExtractor.java | 19 ++----- .../news/extractor/impl/YnaNewsExtractor.java | 20 ++----- 8 files changed, 33 insertions(+), 134 deletions(-) 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 index d2832f3..e64c30a 100644 --- a/src/main/java/com/perfact/be/domain/news/extractor/AbstractNewsExtractor.java +++ b/src/main/java/com/perfact/be/domain/news/extractor/AbstractNewsExtractor.java @@ -11,10 +11,7 @@ import org.jsoup.nodes.Element; import org.jsoup.select.Elements; -/** - * 뉴스 추출기 추상 클래스 - * 공통 로직을 제공합니다. - */ +// 뉴스 추출기 추상 클래스 @Slf4j @RequiredArgsConstructor public abstract class AbstractNewsExtractor implements NewsExtractorStrategy { @@ -22,13 +19,7 @@ public abstract class AbstractNewsExtractor implements NewsExtractorStrategy { protected final HtmlParserService htmlParserService; protected final DateExtractorService dateExtractorService; - /** - * HTML 문서에서 제목을 추출합니다. - * - * @param doc HTML 문서 - * @param titleSelectors 제목 셀렉터 배열 - * @return 추출된 제목 - */ + // HTML 문서에서 제목 추출 protected String extractTitle(Document doc, String[] titleSelectors) { for (String selector : titleSelectors) { Element titleElement = doc.selectFirst(selector); @@ -44,13 +35,7 @@ protected String extractTitle(Document doc, String[] titleSelectors) { return "제목을 찾을 수 없습니다"; } - /** - * HTML 문서에서 내용을 추출합니다. - * - * @param doc HTML 문서 - * @param contentSelectors 내용 셀렉터 배열 - * @return 추출된 내용 - */ + // HTML 문서에서 내용 추출 protected String extractContent(Document doc, String[] contentSelectors) { for (String selector : contentSelectors) { Element contentElement = doc.selectFirst(selector); @@ -66,12 +51,7 @@ protected String extractContent(Document doc, String[] contentSelectors) { return "내용을 찾을 수 없습니다"; } - /** - * 내용 요소를 처리합니다. - * - * @param contentElement 내용 요소 - * @return 처리된 내용 - */ + // 내용 요소 처리 protected String processContentElement(Element contentElement) { StringBuilder content = new StringBuilder(); @@ -105,12 +85,7 @@ protected String processContentElement(Element contentElement) { return content.toString(); } - /** - * HTML 문서를 가져옵니다. - * - * @param url URL - * @return HTML 문서 - */ + // HTML 문서 가져오기 protected Document getDocument(String url) { try { return htmlParserService.getHtmlFromUrl(url); @@ -120,12 +95,7 @@ protected Document getDocument(String url) { } } - /** - * 날짜를 추출합니다. - * - * @param url URL - * @return 추출된 날짜 - */ + // 날짜 추출 protected String extractDate(String url) { try { return dateExtractorService.extractArticleDate(url); @@ -135,17 +105,9 @@ protected String extractDate(String url) { } } - /** - * 도메인별 제목 셀렉터를 반환합니다. - * - * @return 제목 셀렉터 배열 - */ + // 도메인별 제목 셀렉터 반환 protected abstract String[] getTitleSelectors(); - /** - * 도메인별 내용 셀렉터를 반환합니다. - * - * @return 내용 셀렉터 배열 - */ + // 도메인별 내용 셀렉터 반환 protected abstract String[] getContentSelectors(); } 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 index 053feb1..ef86811 100644 --- 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 @@ -10,10 +10,7 @@ import java.util.List; -/** - * 뉴스 추출기 팩토리 - * URL에 따라 적절한 추출기를 선택합니다. - */ +// 뉴스 추출기 팩토리 @Slf4j @Component @RequiredArgsConstructor @@ -21,12 +18,7 @@ public class NewsExtractorFactory { private final List extractors; - /** - * URL에 맞는 추출기를 찾아 뉴스를 추출합니다. - * - * @param url 뉴스 URL - * @return 추출된 뉴스 데이터 - */ + // URL에 맞는 추출기를 찾아 뉴스를 추출합니다. public NewsArticleResponse extractNews(String url) { log.info("뉴스 추출기 선택 시작: {}", url); @@ -36,12 +28,7 @@ public NewsArticleResponse extractNews(String url) { return extractor.extract(url); } - /** - * URL에 맞는 추출기를 찾습니다. - * - * @param url 뉴스 URL - * @return 적절한 추출기 - */ + // URL에 맞는 추출기를 찾습니다. public NewsExtractorStrategy getExtractor(String url) { return extractors.stream() .filter(extractor -> extractor.canExtract(url)) @@ -52,11 +39,7 @@ public NewsExtractorStrategy getExtractor(String url) { }); } - /** - * 사용 가능한 모든 추출기를 반환합니다. - * - * @return 추출기 목록 - */ + // 사용 가능한 모든 추출기를 반환합니다. 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 index b3b1179..da61788 100644 --- 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 @@ -6,10 +6,7 @@ import org.jsoup.nodes.Document; import org.springframework.stereotype.Component; -/** - * 일반 뉴스 사이트 추출기 - * 네이버 뉴스, 연합뉴스, 뉴시스, 노컷뉴스가 아닌 기타 뉴스 사이트를 처리합니다. - */ +// 뉴스 도메인 라우팅 추출기 @Slf4j @Component public class GenericNewsExtractor extends AbstractNewsExtractor { @@ -22,7 +19,7 @@ public GenericNewsExtractor(com.perfact.be.domain.news.service.HtmlParserService @Override public boolean canExtract(String url) { // 네이버 뉴스, 연합뉴스, 뉴시스, 노컷뉴스가 아닌 모든 URL을 처리 - return !url.contains("news.naver.com") && !url.contains("yna.co.kr") && !url.contains("newsis.com") + return !url.contains("news.naver.com") && !url.contains("yna.co.kr") && !url.contains("newsis.com") && !url.contains("news1.kr") && !url.contains("nocutnews.co.kr") && !url.contains("ohmynews.com"); } 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 index 9a787ed..b997952 100644 --- 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 @@ -6,9 +6,7 @@ import org.jsoup.nodes.Document; import org.springframework.stereotype.Component; -/** - * 네이버 뉴스 추출기 - */ +// 네이버 뉴스 추출기 @Slf4j @Component public class NaverNewsExtractor extends AbstractNewsExtractor { 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 index 4c79321..44f0647 100644 --- 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 @@ -8,9 +8,7 @@ import org.jsoup.select.Elements; import org.springframework.stereotype.Component; -/** - * 뉴시스 추출기 - */ +// 뉴시스 추출기 @Slf4j @Component public class NewsisNewsExtractor extends AbstractNewsExtractor { @@ -47,9 +45,7 @@ public NewsArticleResponse extract(String url) { } } - /** - * 뉴시스에서 날짜를 추출합니다. - */ + // 뉴시스에서 날짜 추출 private String extractDate(Document doc) { try { Element dateElement = doc.selectFirst(".txt"); @@ -71,9 +67,7 @@ private String extractDate(Document doc) { } } - /** - * 뉴시스 본문을 정제합니다. - */ + // 뉴시스 본문 정제 @Override protected String processContentElement(Element contentElement) { // 불필요한 요소들 제거 @@ -111,9 +105,7 @@ protected String processContentElement(Element contentElement) { return content.toString(); } - /** - * 불필요한 요소들을 제거합니다. - */ + // 불필요한 요소들 제거 private void removeUnnecessaryElements(Element contentElement) { // 광고 관련 요소 제거 contentElement.select("iframe").remove(); @@ -134,9 +126,7 @@ private void removeUnnecessaryElements(Element contentElement) { contentElement.select(".desc").remove(); } - /** - * 불필요한 텍스트인지 확인합니다. - */ + // 불필요한 텍스트 확인 private boolean isUnnecessaryText(String text) { // 기자 연락처 제거 if (text.contains("◎공감언론 뉴시스") || text.contains("@newsis.com")) { 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 index 0a19cbf..ed3a458 100644 --- 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 @@ -11,10 +11,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -/** - * 노컷뉴스 추출기 - * nocutnews.co.kr 도메인의 뉴스 기사를 처리합니다. - */ +// 노컷뉴스 추출기 @Slf4j @Component public class NocutNewsExtractor extends AbstractNewsExtractor { @@ -48,10 +45,7 @@ public NewsArticleResponse extract(String url) { } } - /** - * 노컷뉴스 특화 날짜 추출 - * ul.bl_b 안의 두 번째 li에서 날짜 추출 - */ + // 노컷뉴스 특화 날짜 추출 private String extractDate(Document doc) { try { Elements dateElements = doc.select("ul.bl_b li"); @@ -115,9 +109,7 @@ protected String processContentElement(Element contentElement) { return cleanedContent.toString().trim(); } - /** - * 불필요한 HTML 요소들 제거 - */ + // 불필요한 HTML 요소들 제거 private void removeUnnecessaryElements(Element contentElement) { // 광고 관련 요소 제거 contentElement.select("iframe").remove(); @@ -134,9 +126,7 @@ private void removeUnnecessaryElements(Element contentElement) { contentElement.select("style").remove(); } - /** - * 불필요한 텍스트인지 확인 - */ + // 불필요한 텍스트 확인 private boolean isUnnecessaryText(String text) { if (text == null || text.trim().isEmpty()) { return true; 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 index 44d8a26..82eb64d 100644 --- 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 @@ -11,10 +11,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -/** - * 오마이뉴스 추출기 - * ohmynews.com 도메인의 뉴스 기사를 처리합니다. - */ +// 오마이뉴스 추출기 @Slf4j @Component public class OhMyNewsExtractor extends AbstractNewsExtractor { @@ -67,10 +64,7 @@ protected String[] getContentSelectors() { }; } - /** - * 오마이뉴스 특화 날짜 추출 - * div.atc-sponsor 안의 첫 번째 span.date에서 날짜 추출 - */ + // 오마이뉴스 특화 날짜 추출 private String extractDate(Document doc) { try { // 오마이뉴스 날짜 선택자 @@ -111,10 +105,7 @@ private String extractDate(Document doc) { } } - /** - * 오마이뉴스 날짜 형식 변환 - * "25.08.19 15:25" → "2025-08-19 15:25" - */ + // 오마이뉴스 날짜 형식 변환 private String convertOhMyNewsDate(String dateText) { try { // "25.08.19 15:25" 형식 매칭 @@ -148,9 +139,7 @@ protected String processContentElement(Element contentElement) { return contentElement.text().trim(); } - /** - * 오마이뉴스 특화 불필요한 요소들 제거 - */ + // 오마이뉴스 특화 불필요한 요소들 제거 private void removeOhMyNewsSpecificElements(Element contentElement) { // 광고 관련 요소들 제거 contentElement.select("div[id*='ad'], div[id*='Ad'], .ad, .ads, .advertisement").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 index c8df5ed..86c9c4e 100644 --- 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 @@ -8,9 +8,7 @@ import org.jsoup.select.Elements; import org.springframework.stereotype.Component; -/** - * 연합뉴스 추출기 - */ +// 연합뉴스 추출기 @Slf4j @Component public class YnaNewsExtractor extends AbstractNewsExtractor { @@ -47,9 +45,7 @@ public NewsArticleResponse extract(String url) { } } - /** - * 연합뉴스에서 날짜를 추출합니다. - */ + // 연합뉴스에서 날짜 추출 private String extractDate(Document doc) { try { Element dateElement = doc.selectFirst(".txt-time01"); @@ -69,9 +65,7 @@ private String extractDate(Document doc) { } } - /** - * 연합뉴스 본문을 정제합니다. - */ + // 연합뉴스 본문 정제 @Override protected String processContentElement(Element contentElement) { // 불필요한 요소들 제거 @@ -109,9 +103,7 @@ protected String processContentElement(Element contentElement) { return content.toString(); } - /** - * 불필요한 요소들을 제거합니다. - */ + // 불필요한 요소들 제거 private void removeUnnecessaryElements(Element contentElement) { // 광고 관련 요소 제거 contentElement.select("aside").remove(); @@ -130,9 +122,7 @@ private void removeUnnecessaryElements(Element contentElement) { contentElement.select(".swiper-area").remove(); } - /** - * 불필요한 텍스트인지 확인합니다. - */ + // 불필요한 텍스트 확인 private boolean isUnnecessaryText(String text) { // 이메일 주소 제거 if (text.contains("@") && text.contains(".co.kr")) { From a2fff1176f23c8abeee67227646d5cfe1cf0136d Mon Sep 17 00:00:00 2001 From: hardwoong Date: Wed, 20 Aug 2025 01:45:44 +0900 Subject: [PATCH 6/7] =?UTF-8?q?Refact=20:=20=EB=89=B4=EC=8A=A41=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../be/domain/news/exception/status/NewsErrorStatus.java | 2 +- .../domain/news/extractor/impl/GenericNewsExtractor.java | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) 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 52c8a23..a397eb1 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 @@ -11,7 +11,7 @@ public enum NewsErrorStatus implements BaseErrorCode { NOT_NAVER_NEWS(HttpStatus.BAD_REQUEST, "NEWS4001", "네이버 뉴스 도메인이 아닙니다. 네이버 뉴스를 통한 링크만 가능합니다."), UNSUPPORTED_NEWS_SITE(HttpStatus.BAD_REQUEST, "NEWS4002", - "지원하지 않는 뉴스 사이트입니다. 현재 지원되는 사이트: 네이버뉴스, 연합뉴스, 뉴시스, 뉴스1, 노컷뉴스, 오마이뉴스 (네이버 뉴스에 최적화되어 있습니다.)"), + "지원하지 않는 뉴스 사이트입니다. 현재 지원되는 사이트: 네이버뉴스, 연합뉴스, 뉴시스, 노컷뉴스, 오마이뉴스 (네이버 뉴스에 최적화되어 있습니다.)"), NEWS_CONTENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "NEWS4003", "뉴스 내용을 찾을 수 없습니다."), NEWS_TITLE_EXTRACTION_FAILED(HttpStatus.BAD_REQUEST, "NEWS4004", "뉴스 제목 추출에 실패했습니다."), NEWS_DATE_EXTRACTION_FAILED(HttpStatus.BAD_REQUEST, "NEWS4005", "뉴스 날짜 추출에 실패했습니다."), 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 index da61788..8e0391b 100644 --- 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 @@ -19,13 +19,13 @@ public GenericNewsExtractor(com.perfact.be.domain.news.service.HtmlParserService @Override public boolean canExtract(String url) { // 네이버 뉴스, 연합뉴스, 뉴시스, 노컷뉴스가 아닌 모든 URL을 처리 - return !url.contains("news.naver.com") && !url.contains("yna.co.kr") && !url.contains("newsis.com") && !url.contains("news1.kr") + 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); + log.info("일반 뉴스 사이트 판별: {}", url); try { Document doc = getDocument(url); @@ -34,13 +34,10 @@ public NewsArticleResponse extract(String url) { 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); + log.error("일반 뉴스 사이트 판별 실패: {}", url, e); throw e; } } From 1126e9bd2aab15faf263c241ca30ab44216065e0 Mon Sep 17 00:00:00 2001 From: hardwoong Date: Wed, 20 Aug 2025 10:46:33 +0900 Subject: [PATCH 7/7] =?UTF-8?q?Refact=20:=20=EC=A0=84=EB=9E=B5=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=EC=97=90=20=EB=A7=9E=EC=B6=94=EC=96=B4=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ArticleExtractionServiceImpl.java | 37 +++------ .../news/controller/NewsController.java | 13 ++- .../exception/status/NewsErrorStatus.java | 2 +- .../news/extractor/AbstractNewsExtractor.java | 12 ++- .../extractor/impl/GenericNewsExtractor.java | 10 +-- .../extractor/impl/OhMyNewsExtractor.java | 74 ++++++++++------- .../be/domain/news/service/NewsService.java | 6 -- .../domain/news/service/NewsServiceImpl.java | 24 ------ .../report/service/ReportServiceImpl.java | 83 +++++-------------- 9 files changed, 96 insertions(+), 165 deletions(-) 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 a397eb1..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 @@ -11,7 +11,7 @@ public enum NewsErrorStatus implements BaseErrorCode { NOT_NAVER_NEWS(HttpStatus.BAD_REQUEST, "NEWS4001", "네이버 뉴스 도메인이 아닙니다. 네이버 뉴스를 통한 링크만 가능합니다."), 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", "뉴스 날짜 추출에 실패했습니다."), 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 index e64c30a..2663fea 100644 --- a/src/main/java/com/perfact/be/domain/news/extractor/AbstractNewsExtractor.java +++ b/src/main/java/com/perfact/be/domain/news/extractor/AbstractNewsExtractor.java @@ -32,7 +32,7 @@ protected String extractTitle(Document doc, String[] titleSelectors) { } } log.warn("제목을 찾을 수 없습니다. 사용된 셀렉터: {}", String.join(", ", titleSelectors)); - return "제목을 찾을 수 없습니다"; + throw new NewsHandler(NewsErrorStatus.NEWS_TITLE_EXTRACTION_FAILED); } // HTML 문서에서 내용 추출 @@ -48,7 +48,7 @@ protected String extractContent(Document doc, String[] contentSelectors) { } } log.warn("내용을 찾을 수 없습니다. 사용된 셀렉터: {}", String.join(", ", contentSelectors)); - return "내용을 찾을 수 없습니다"; + throw new NewsHandler(NewsErrorStatus.NEWS_CONTENT_NOT_FOUND); } // 내용 요소 처리 @@ -98,10 +98,14 @@ protected Document getDocument(String url) { // 날짜 추출 protected String extractDate(String url) { try { - return dateExtractorService.extractArticleDate(url); + 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); - return "날짜 정보 없음"; + throw new NewsHandler(NewsErrorStatus.NEWS_DATE_EXTRACTION_FAILED); } } 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 index 8e0391b..33a29f3 100644 --- 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 @@ -18,14 +18,14 @@ public GenericNewsExtractor(com.perfact.be.domain.news.service.HtmlParserService @Override public boolean canExtract(String url) { - // 네이버 뉴스, 연합뉴스, 뉴시스, 노컷뉴스가 아닌 모든 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"); + // 지원하는 뉴스 사이트들만 처리하고, 나머지는 거부 + 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); + log.info("지원하는 뉴스 사이트 처리: {}", url); try { Document doc = getDocument(url); @@ -37,7 +37,7 @@ public NewsArticleResponse extract(String url) { return new NewsArticleResponse(title, date, content); } catch (Exception e) { - log.error("일반 뉴스 사이트 판별 실패: {}", url, e); + log.error("뉴스 사이트 처리 실패: {}", url, e); throw e; } } 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 index 82eb64d..0df7729 100644 --- 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 @@ -67,33 +67,30 @@ protected String[] getContentSelectors() { // 오마이뉴스 특화 날짜 추출 private String extractDate(Document doc) { try { - // 오마이뉴스 날짜 선택자 - Elements dateElements = doc.select("div.atc-sponsor span.date"); - - if (!dateElements.isEmpty()) { - // 첫 번째 date span 사용 - Element firstDateElement = dateElements.first(); - String dateText = firstDateElement.text().trim(); - - log.debug("오마이뉴스 원본 날짜 텍스트: {}", dateText); - - // "25.08.19 15:25" 형식을 "2025-08-19 15:25" 형식으로 변환 - String convertedDate = convertOhMyNewsDate(dateText); - - if (convertedDate != null) { - log.info("오마이뉴스 날짜 변환 성공: {} → {}", dateText, convertedDate); - return convertedDate; - } - } - - // fallback: 다른 날짜 선택자들 시도 - Elements fallbackElements = doc.select("span.date, .date, [class*='date']"); - for (Element element : fallbackElements) { - String text = element.text().trim(); - String convertedDate = convertOhMyNewsDate(text); - if (convertedDate != null) { - log.info("fallback으로 오마이뉴스 날짜 추출 성공: {} → {}", text, convertedDate); - return convertedDate; + // 오마이뉴스 날짜 선택자들 (우선순위 순) + 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; + } } } @@ -108,20 +105,24 @@ private String extractDate(Document doc) { // 오마이뉴스 날짜 형식 변환 private String convertOhMyNewsDate(String dateText) { try { - // "25.08.19 15:25" 형식 매칭 - Pattern pattern = Pattern.compile("(\\d{2})\\.(\\d{2})\\.(\\d{2})\\s+(\\d{2}:\\d{2})"); + // "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); - String time = matcher.group(4); + int hour = Integer.parseInt(matcher.group(4)); + String minute = matcher.group(5); // 20xx년으로 변환 (25 → 2025) String fullYear = "20" + year; - return String.format("%s-%s-%s %s", fullYear, month, day, time); + // 시간을 2자리로 포맷팅 + String formattedHour = String.format("%02d", hour); + + return String.format("%s-%s-%s %s:%s", fullYear, month, day, formattedHour, minute); } return null; @@ -150,8 +151,19 @@ private void removeOhMyNewsSpecificElements(Element contentElement) { 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/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 5a089e6..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 @@ -31,30 +31,6 @@ 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 { - log.info("네이버 뉴스 기사 추출 시작: {}", url); - - // 새로운 팩토리 패턴 사용 - NewsArticleResponse newsData = newsExtractorFactory.extractNews(url); - - log.info("네이버 뉴스 기사 추출 완료 - 제목: {}, 날짜: {}, 내용 길이: {}", - newsData.getTitle(), newsData.getDate(), newsData.getContent().length()); - - return newsData; - - } catch (Exception e) { - log.error("네이버 뉴스 기사 추출 실패: {}", url, e); - return null; - } - } - @Override public String extractTitleFromOtherNewsSites(String url) { return newsExtractorService.extractTitleFromOtherNewsSites(url); 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 81f10b1..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 @@ -61,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); @@ -76,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()); @@ -114,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); @@ -124,21 +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 { - // 다른 뉴스 사이트의 경우에도 팩토리를 사용해서 완전한 정보 추출 - try { - newsData = newsExtractorFactory.extractNews(url); - } catch (Exception e) { - log.warn("뉴스 추출기로 추출 실패, 기존 방식으로 fallback: {}", url); - 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); @@ -169,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); @@ -204,39 +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 { - // 다른 뉴스 사이트의 경우에도 팩토리를 사용해서 완전한 정보 추출 - NewsArticleResponse newsData; - try { - newsData = newsExtractorFactory.extractNews(url); - } catch (Exception e) { - log.warn("뉴스 추출기로 추출 실패, 기존 방식으로 fallback: {}", url); - String title = newsService.extractTitleFromOtherNewsSites(url); - String content = newsService.extractNewsArticleContent(url); - 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 { // 원본 응답 로깅