Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] 뉴스 기사 api 구현 (#28) #32

Merged
merged 19 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@ dependencies {
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

// jsoup
implementation 'org.jsoup:jsoup:1.18.3'

// amazon s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

}

clean { delete file('src/main/generated')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.ripple.BE.image.exception.ImageException;
import com.ripple.BE.learning.exception.LearningException;
import com.ripple.BE.learning.exception.QuizException;
import com.ripple.BE.news.exception.NewsException;
import com.ripple.BE.post.exception.PostException;
import com.ripple.BE.user.exception.UserException;
import io.micrometer.common.lang.NonNull;
Expand Down Expand Up @@ -87,6 +88,11 @@ public ResponseEntity<Object> handlePostException(final PostException e) {
return handleExceptionInternal(e.getErrorCode());
}

@ExceptionHandler(NewsException.class)
public ResponseEntity<Object> handleNewsException(final NewsException e) {
return handleExceptionInternal(e.getErrorCode());
}

@ExceptionHandler(ImageException.class)
public ResponseEntity<Object> handleImageException(final ImageException e) {
return handleExceptionInternal(e.getErrorCode());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
import com.ripple.BE.learning.exception.errorcode.LearningErrorCode;
import com.ripple.BE.learning.repository.LearningSetRepository;
import com.ripple.BE.learning.repository.QuizRepository;
import java.io.File;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -25,7 +27,7 @@
@RequiredArgsConstructor
public class LearningAdminService {

private static final String FILE_PATH = "src/main/resources/static/excel/example.xlsx";
private static final String FILE_PATH = "static/excel/example.xlsx";
private static final int LEARNING_SET_SHEET_INDEX = 0;
private static final int CONCEPT_SHEET_INDEX = 1;
private static final int QUIZ_SHEET_INDEX = 2;
Expand All @@ -37,8 +39,9 @@ public class LearningAdminService {
@Transactional
public void createLearningSetByExcel() {
try {
File file = new ClassPathResource(FILE_PATH).getFile();

List<LearningSet> learningSetList = parseLearningSetsFromExcel();
List<LearningSet> learningSetList = parseLearningSetsFromExcel(file.getPath());
List<LearningSet> existingLearningSets = learningSetRepository.findAll();

Map<String, LearningSet> learningSetMap =
Expand All @@ -57,11 +60,15 @@ public void createLearningSetByExcel() {

List<LearningSet> newLearningSets =
learningSetList.stream()
.filter(learningSet -> !existingLearningSets.contains(learningSet))
.filter(
learningSet ->
existingLearningSets.stream()
.noneMatch(
existingSet -> existingSet.getName().equals(learningSet.getName())))
.collect(Collectors.toList());

addConceptsToLearningSets(newLearningSetMap);
addQuizzesToLearningSets(newLearningSetMap);
addConceptsToLearningSets(file.getPath(), newLearningSetMap);
addQuizzesToLearningSets(file.getPath(), newLearningSetMap);

learningSetRepository.saveAll(newLearningSets);

Expand All @@ -71,29 +78,44 @@ public void createLearningSetByExcel() {
}
}

private List<LearningSet> parseLearningSetsFromExcel() throws Exception {
return ExcelUtils.parseExcelFile(FILE_PATH, LEARNING_SET_SHEET_INDEX).stream()
private List<LearningSet> parseLearningSetsFromExcel(String filePath) throws Exception {
return ExcelUtils.parseExcelFile(filePath, LEARNING_SET_SHEET_INDEX).stream()
.map(LearningSetDTO::toLearningSetDTO)
.map(LearningSet::toLearningSet)
.collect(Collectors.toList());
}

private void addConceptsToLearningSets(Map<String, LearningSet> learningSetMap) throws Exception {
ExcelUtils.parseExcelFile(FILE_PATH, CONCEPT_SHEET_INDEX).stream()
private void addConceptsToLearningSets(String filePath, Map<String, LearningSet> learningSetMap)
throws Exception {
ExcelUtils.parseExcelFile(filePath, CONCEPT_SHEET_INDEX).stream()
.map(ConceptDTO::toConceptDTO)
.forEach(
conceptDTO ->
Concept.toConcept(conceptDTO)
.setLearningSet(learningSetMap.get(conceptDTO.learningSetName())));
conceptDTO -> {
LearningSet learningSet = learningSetMap.get(conceptDTO.learningSetName());

if (learningSet == null) {
return;
}

Concept concept = Concept.toConcept(conceptDTO);
concept.setLearningSet(learningSet);
});
}

private void addQuizzesToLearningSets(Map<String, LearningSet> learningSetMap) throws Exception {
ExcelUtils.parseExcelFile(FILE_PATH, QUIZ_SHEET_INDEX).stream()
private void addQuizzesToLearningSets(String filePath, Map<String, LearningSet> learningSetMap)
throws Exception {
ExcelUtils.parseExcelFile(filePath, QUIZ_SHEET_INDEX).stream()
.map(QuizDTO::toQuizDTO)
.forEach(
quizDTO -> {
LearningSet learningSet = learningSetMap.get(quizDTO.learningSetName());

if (learningSet == null) {
return; // 해당 quizDTO를 건너뜀
}

Quiz quiz = Quiz.toQuiz(quizDTO);
quiz.setLearningSet(learningSetMap.get(quizDTO.learningSetName()));
quiz.setLearningSet(learningSet);

quizRepository.save(quiz);

Expand Down
77 changes: 77 additions & 0 deletions src/main/java/com/ripple/BE/news/controller/NewsController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.ripple.BE.news.controller;

import com.ripple.BE.global.dto.response.ApiResponse;
import com.ripple.BE.news.domain.type.NewsCategory;
import com.ripple.BE.news.domain.type.NewsSort;
import com.ripple.BE.news.dto.NewsDTO;
import com.ripple.BE.news.dto.NewsListDTO;
import com.ripple.BE.news.dto.response.NewsListResponse;
import com.ripple.BE.news.dto.response.NewsResponse;
import com.ripple.BE.news.service.NewsService;
import com.ripple.BE.user.domain.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/news")
@RequiredArgsConstructor
@Tag(name = "News", description = "뉴스 API")
public class NewsController {

private final NewsService newsService;

@Operation(summary = "뉴스 목록 조회", description = "뉴스 목록을 조회합니다.")
@GetMapping
public ResponseEntity<ApiResponse<Object>> getNewsList(
final @RequestParam(required = false, defaultValue = "0") @PositiveOrZero int page,
final @RequestParam(required = false, defaultValue = "RECENT") NewsSort sort,
final @RequestParam(required = false) NewsCategory category) {

NewsListDTO newsListDTO = newsService.getNewsList(page, sort, category);

return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponse.from(NewsListResponse.toNewsListResponse(newsListDTO)));
}

@Operation(summary = "뉴스 상세 조회", description = "뉴스의 상세 정보를 조회합니다.")
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Object>> getNews(final @PathVariable("id") long id) {

NewsDTO newsDTO = newsService.getNews(id);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponse.from(NewsResponse.toNewsResponse(newsDTO)));
}

@Operation(summary = "뉴스 스크랩", description = "뉴스를 스크랩합니다.")
@PostMapping("/{id}/scrap")
public ResponseEntity<ApiResponse<Object>> scrapNews(
final @AuthenticationPrincipal CustomUserDetails currentUser,
final @PathVariable("id") long id) {

newsService.addScrapToNews(id, currentUser.getId());
return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.from(ApiResponse.EMPTY_RESPONSE));
}

@Operation(summary = "뉴스 스크랩 취소", description = "뉴스 스크랩을 취소합니다.")
@DeleteMapping("/{id}/scrap")
public ResponseEntity<ApiResponse<Object>> unscrapPost(
final @AuthenticationPrincipal CustomUserDetails currentUser,
final @PathVariable("id") long id) {

newsService.removeScrapFromNews(id, currentUser.getId());

return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.from(ApiResponse.EMPTY_RESPONSE));
}
}
67 changes: 67 additions & 0 deletions src/main/java/com/ripple/BE/news/crawler/NewsCrawler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.ripple.BE.news.crawler;

import static com.ripple.BE.news.exception.errorcode.NewsErrorCode.*;

import com.ripple.BE.news.domain.type.NewsCategory;
import com.ripple.BE.news.dto.NewsDTO;
import com.ripple.BE.news.exception.NewsException;
import java.util.ArrayList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

@Slf4j
public abstract class NewsCrawler {

protected static final String LIST_SELECTOR = "li.sa_item"; // 리스트 선택자
protected static final String TITLE_SELECTOR = "strong.sa_text_strong"; // 제목 선택자
protected static final String CONTENT_SELECTOR = "div.sa_text_lede"; // 내용 선택자
protected static final String PUBLISH_SELECTOR = "div.sa_text_press"; // 언론사 선택자
protected static final String CONTENT_URL_SELECTOR = "div.sa_text a"; // 본문 URL 선택자

public List<NewsDTO> crawl() {

log.info("Crawling started");

String baseUrl = getPageUrl(); // 크롤링할 URL
List<NewsDTO> newsList = new ArrayList<>();

try {

Elements elementList = Jsoup.connect(baseUrl).get().select(LIST_SELECTOR);
if (elementList.isEmpty()) {
return newsList;
}

for (Element element : elementList) {
String title = element.select(TITLE_SELECTOR).text();
String content = element.select(CONTENT_SELECTOR).text();
String publisher = element.select(PUBLISH_SELECTOR).text();
String contentUrl = element.select(CONTENT_URL_SELECTOR).attr("href");

if (!title.isEmpty() && !content.isEmpty()) {
NewsDTO newsDTO =
NewsDTO.toNewsDTO(title, content, publisher, contentUrl, getNewsCategory());
newsList.add(newsDTO);
}
}

} catch (Exception e) {
throw new NewsException(NEWS_INTERNAL_SERVER_ERROR);
}

return newsList;
}

/**
* 크롤링할 페이지 URL 반환
*
* @return 페이지 URL
*/
protected abstract String getPageUrl();

/** 카테고리 반환 */
protected abstract NewsCategory getNewsCategory();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.ripple.BE.news.crawler.impl;

import com.ripple.BE.news.crawler.NewsCrawler;
import com.ripple.BE.news.domain.type.NewsCategory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class FinanceNewsCrawler extends NewsCrawler {

protected static final String URL = "https://news.naver.com/breakingnews/section/101/259";

/**
* 페이지 URL
*
* @return 금융 페이지 URL
*/
@Override
public String getPageUrl() {
return URL;
}

/** 카테고리 */
@Override
public NewsCategory getNewsCategory() {
return NewsCategory.FINANCE;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.ripple.BE.news.crawler.impl;

import com.ripple.BE.news.crawler.NewsCrawler;
import com.ripple.BE.news.domain.type.NewsCategory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class GlobalNewsCrawler extends NewsCrawler {

protected static final String URL = "https://news.naver.com/breakingnews/section/101/262";

/**
* 페이지 URL
*
* @return 국제 경제 페이지 URL
*/
@Override
public String getPageUrl() {
return URL;
}

@Override
public NewsCategory getNewsCategory() {
return NewsCategory.GLOBAL;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.ripple.BE.news.crawler.impl;

import com.ripple.BE.news.crawler.NewsCrawler;
import com.ripple.BE.news.domain.type.NewsCategory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class IndustryNewsCrawler extends NewsCrawler {

protected static final String URL = "https://news.naver.com/breakingnews/section/101/261";

/**
* 페이지 URL
*
* @return 산업 페이지 URL
*/
@Override
public String getPageUrl() {
return URL;
}

@Override
public NewsCategory getNewsCategory() {
return NewsCategory.INDUSTRY;
}
}
Loading