Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ssupetition/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package com4table.ssupetition.domain.news;

import java.time.Duration;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.openqa.selenium.By;
import org.openqa.selenium.TimeoutException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.springframework.stereotype.Service;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;

@Service
@RequiredArgsConstructor
@Slf4j
public class NewsService {
private static final String BASE_URL = "https://scatch.ssu.ac.kr/뉴스센터/주요뉴스/";
private static final long DELAY_MS = 500L; // 서버 예절상 딜레이
private static final Pattern DATE_PAT = Pattern.compile("(\\d{4})년\\s*(\\d{1,2})월\\s*(\\d{1,2})일");

/** 주요뉴스 크롤링: 1페이지부터 maxPages까지 */
public CrawlResult crawlMajorNews(int maxPages) {
// WebDriver 설정 (USaintCrawler와 동일한 형태 유지)
ChromeOptions options = new ChromeOptions();
options.setBinary("/usr/bin/google-chrome");
options.addArguments("--headless=new");
options.addArguments("--no-sandbox");
options.addArguments("--disable-dev-shm-usage");
WebDriver driver = new ChromeDriver(options);

List<NewsItem> results = new ArrayList<>();
Set<String> seen = new HashSet<>();

try {
int page = 1;
while (true) {
if (maxPages > 0 && page > maxPages) break;

List<String> links = extractLinksOnList(driver, page);
if (links.isEmpty()) {
log.info("No more links at page={}", page);
break;
}
for (String link : links) {
if (!seen.add(link)) continue;
try {
NewsItem item = parseArticle(driver, link);
if ((item.getTitle() != null && !item.getTitle().isEmpty())
|| (item.getDate() != null && !item.getDate().isEmpty())) {
results.add(item);
log.info("[OK] {} {}", item.getDate(), item.getTitle());
} else {
log.info("[SKIP] {} (empty)", link);
}
Thread.sleep(DELAY_MS);
} catch (Exception e) {
log.warn("[ERR] {} :: {}", link, e.toString());
}
}
page++;
}
return new CrawlResult(true, results, "count=" + results.size());
} catch (Exception e) {
log.error("crawlMajorNews error: ", e);
return new CrawlResult(false, results, "error=" + e.getMessage());
} finally {
driver.quit();
}
}

/** 목록 페이지에서 상세 링크 뽑기 */
private List<String> extractLinksOnList(WebDriver driver, int page) {
String url = (page == 1) ? BASE_URL : BASE_URL + "?paged=" + page;
log.info("GET {}", url);
driver.get(url);

// 앵커 로드 대기
new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.presenceOfElementLocated(
By.xpath("//a[contains(@href,'slug=')]")
));

// 상세로 가는 링크가 보통 slug= 파라미터를 포함
List<WebElement> anchors = driver.findElements(By.xpath("//a[contains(@href, 'slug=')]"));
List<String> links = anchors.stream()
.map(a -> a.getAttribute("href"))
.filter(Objects::nonNull)
.map(h -> h.split("#")[0])
.filter(h -> h.contains("slug="))
.distinct()
.collect(Collectors.toList());

log.info("page={} links={}", page, links.size());
return links;
}

/** 상세 페이지 파싱: 제목/날짜/본문/URL */
private NewsItem parseArticle(WebDriver driver, String url) {
log.debug("Parse {}", url);
driver.get(url);

String title = "";
try {
WebElement h = new WebDriverWait(driver, Duration.ofSeconds(8))
.until(ExpectedConditions.presenceOfElementLocated(By.xpath("(//h1|//h2)[1]")));
title = Optional.ofNullable(h.getText()).orElse("").trim();
} catch (TimeoutException ignored) {}

// 본문: article p 우선, 없으면 모든 p
List<WebElement> paras = driver.findElements(By.cssSelector("article p"));
if (paras.isEmpty()) paras = driver.findElements(By.tagName("p"));
String content = paras.stream()
.map(WebElement::getText)
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.joining("\n"));

// 날짜: 페이지 전체 텍스트에서 yyyy년 m월 d일
String bodyText = driver.findElement(By.tagName("body")).getText();
Matcher m = DATE_PAT.matcher(bodyText);
String dateStr = "";
if (m.find()) {
int y = Integer.parseInt(m.group(1));
int mo = Integer.parseInt(m.group(2));
int d = Integer.parseInt(m.group(3));
dateStr = String.format("%04d-%02d-%02d", y, mo, d);
}

return new NewsItem(title, dateStr, url, content);
}

// ====== 결과/아이템 DTO (USaintCrawler의 내부 static 클래스 스타일로 구성) ======

@Getter
public static class NewsItem {
private final String title;
private final String date; // yyyy-MM-dd
private final String url;
private final String content;

public NewsItem(String title, String date, String url, String content) {
this.title = title;
this.date = date;
this.url = url;
this.content = content;
}
}

@Getter
@Setter
public static class CrawlResult {
private final boolean success;
private final List<NewsItem> items;
private final String message;

public CrawlResult(boolean success, List<NewsItem> items, String message) {
this.success = success;
this.items = items;
this.message = message;
}
}

}
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
package com4table.ssupetition.domain.post.controller;

import static com4table.ssupetition.domain.post.dto.gpt.PetitionDtos.*;

import com4table.ssupetition.domain.post.domain.Post;
import com4table.ssupetition.domain.post.dto.PostRequest;
import com4table.ssupetition.domain.post.dto.PostResponse;
import com4table.ssupetition.domain.post.dto.ResponseDto;
import com4table.ssupetition.domain.post.dto.gpt.PetitionDtos;
import com4table.ssupetition.domain.post.service.PostAnswerService;
import com4table.ssupetition.domain.post.service.PostService;
import com4table.ssupetition.global.exception.BaseResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
Expand Down Expand Up @@ -37,14 +46,54 @@ public ResponseEntity<Post> addPost(@RequestBody PostRequest.AddDTO addDTO, @Pat
return ResponseEntity.ok(createdPost);
}

//AI 글 수정
@Operation(summary = "최신판", description = "(최신판) 게시글 작성할 때 AI한테 다듬어달라고 요청하는 API")
@PostMapping("/ai/body")
public BaseResponse<GenerateResponse> summaryAI(@RequestBody GenerateRequest request){
return BaseResponse.<GenerateResponse>builder()
.code(200)
.message("AI가 글을 다듬었습니다.")
.isSuccess(true)
.data(postService.makeBestSingleVersion(request))
.build();
}

//전체 검색
@Operation(description = "전체 게시글들 가져오는 API")
@Operation(description = "전체 게시글들 가져오는 API-> 모아보기에서 이걸 쓰면 될 듯->키워드 별로 가져오는 거 같음")
@PostMapping("/search")
public List<PostResponse.AllListDTO> postSearch(@RequestBody Map<String, String> body) {
public Page<PostResponse.AllListDTO> postSearch(@RequestBody Map<String, String> body , @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable){
String keyword = body.get("keyword");
return postService.searchPosts(keyword);
return postService.searchPosts(keyword,pageable);
}

//필터링된 검색 결과 가져오기
@Operation(summary = "최신판", description = "(최신판) 필터링된 상태로 게시글들을 가져오는 API // FILTER : all(모아보기), event(행사), partnership(제휴), facility(시설), study(교과), report(신고) 뒤에 한국어 괄호 제외하고 넣어주면 됨 ")
@GetMapping("/search/{category}")
public BaseResponse<Page<PostResponse.AllListDTO>> getListsWithFilter(@PathVariable(name = "category") String category, @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable){
return BaseResponse.<Page<PostResponse.AllListDTO>>builder()
.isSuccess(true)
.message("필터링된 게시글들을 가져왔습니다.")
.code(200)
.data(postService.getFilterList(category, pageable))
.build();
}

//최신 검색
@Operation(summary = "최신판", description = "(최신판) 최신 결과 가져오기")
@GetMapping("/search/current")
public BaseResponse<Page<PostResponse.AllListDTO>> getListsRecent(@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable){
return BaseResponse.<Page<PostResponse.AllListDTO>>builder()
.isSuccess(true)
.message("최신 게시글들을 가져왔습니다.")
.code(200)
.data(postService.getCurrentList(pageable))
.build();
}

//학교 뉴스 크롤링




@Operation(description = "위의 설명을 보면 존재하는 카테고리에 속하는 게시글들을 제공하는 API")
@PostMapping("/search/sorted-by-agree/{category}")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com4table.ssupetition.domain.post.dto.gpt;

import java.util.List;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChatCompletionRequest {
private String model;
private List<Message> messages;
private Double temperature;
private Integer max_tokens;

@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public static class Message {
private String role; // "system" | "user"
private String content;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com4table.ssupetition.domain.post.dto.gpt;

import java.util.List;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ChatCompletionResponse {


private List<Choice> choices;

@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public static class Choice {
private int index;
private Message message;

@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public static class Message {
private String role;
private String content;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com4table.ssupetition.domain.post.dto.gpt;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

public class PetitionDtos
{

@Getter
@Setter
public static class GenerateRequest {
private String category; // 예: "시설", "수업", "행정"
private String titleDraft; // 사용자 초안 제목
private String bodyDraft; // 사용자 초안 본문
}

@Getter @AllArgsConstructor
public static class GenerateResponse {
private final String title;
private final String body;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import com4table.ssupetition.domain.post.domain.Post;
import com4table.ssupetition.domain.post.enums.Category;
import com4table.ssupetition.domain.post.enums.Type;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
Expand All @@ -15,8 +18,9 @@ public interface PostRepository extends JpaRepository<Post, Long> {
// List<Post> findByPostType(Long postCategoryId);
List<Post> findByUser_UserId(Long user);

Page<Post> findAllByPostCategory(Category postCategory,Pageable pageable);


Page<Post> findByTitleContainingOrContentContaining(String titleKeyword, String contentKeyword, Pageable pageable);

// 최다 동의 순으로 정렬
@Query("SELECT p FROM Post p ORDER BY p.agree DESC")
Expand Down
Loading