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
Binary file modified .gradle/buildOutputCleanup/buildOutputCleanup.lock
Binary file not shown.
Binary file modified .gradle/file-system.probe
Binary file not shown.
70 changes: 42 additions & 28 deletions src/main/java/com/team4/giftidea/controller/ProductController.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@
import com.team4.giftidea.service.KreamApiService;
import com.team4.giftidea.service.NaverApiService;
import com.team4.giftidea.service.ProductService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
* 상품 정보를 크롤링하고 저장하는 컨트롤러
*/
@Slf4j // 로깅 추가
@RestController
@RequestMapping("/api/products")
public class ProductController {
Expand All @@ -22,14 +21,6 @@ public class ProductController {
private final ProductService productService;
private final KreamApiService kreamApiService;

/**
* ProductController 생성자
*
* @param naverApiService 네이버 API 서비스
* @param coupangApiService 쿠팡 API 서비스
* @param kreamApiService Kream API 서비스
* @param productService 상품 저장 서비스
*/
public ProductController(
NaverApiService naverApiService,
CoupangApiService coupangApiService,
Expand All @@ -46,43 +37,66 @@ public ProductController(
*/
@GetMapping("/crawl")
public void crawlAndStoreData() {
log.info("🔍 크롤링 시작...");

// 네이버 키워드 목록
List<String> naverKeywords = List.of("현금 박스", "부모님 신발", "건강식품", "헬스가방", "핸드크림", "디퓨저", "오설록 티세트",
"휴지", "초콜릿", "수제 초콜릿 키트", "파자마세트" );
List<String> naverKeywords = List.of(
"현금 박스", "부모님 신발", "건강식품", "헬스가방", "핸드크림", "디퓨저",
"오설록 티세트", "휴지", "초콜릿", "수제 초콜릿 키트", "파자마세트"
);

// 쿠팡 키워드 목록
List<String> coupangKeywords = List.of("안마기기", "무선이어폰", "스마트워치");

// Kream 키워드 목록
List<String> kreamKeywords = List.of(
"남성 지갑", "남성 스니커즈", "백팩", "토트백", "크로스백", "벨트",
"선글라스", "향수", "여성 지갑", "여성 스니커즈", "숄더백", "목걸이", "텀블러", "립밤", "조명", "핸드워시", "식기"
"선글라스", "향수", "여성 지갑", "여성 스니커즈", "숄더백", "목걸이",
"텀블러", "립밤", "조명", "핸드워시", "식기"
);

// 네이버 크롤링
log.info("📢 네이버 크롤링 시작...");
naverKeywords.forEach(keyword -> {
log.debug("🔎 네이버 검색 키워드: {}", keyword);
List<Product> naverProducts = naverApiService.searchItems(List.of(keyword));
productService.saveItems(naverProducts, keyword); // DB에 저장
log.info("✅ 네이버 크롤링 완료 (키워드: {}, 검색 결과: {} 개)", keyword, naverProducts.size());

if (!naverProducts.isEmpty()) {
productService.saveItems(naverProducts, keyword);
log.info("✅ 네이버 상품 저장 완료 (키워드: {}, 저장된 개수: {})", keyword, naverProducts.size());
} else {
log.warn("⚠️ 네이버 크롤링 실패 또는 검색 결과 없음 (키워드: {})", keyword);
}
});

// 쿠팡 크롤링
log.info("📢 쿠팡 크롤링 시작...");
coupangKeywords.forEach(keyword -> {
log.debug("🔎 쿠팡 검색 키워드: {}", keyword);
List<Product> coupangProducts = coupangApiService.searchItems(keyword);
productService.saveItems(coupangProducts, keyword); // DB에 저장
log.info("✅ 쿠팡 크롤링 완료 (키워드: {}, 검색 결과: {} 개)", keyword, coupangProducts.size());

if (!coupangProducts.isEmpty()) {
productService.saveItems(coupangProducts, keyword);
log.info("✅ 쿠팡 상품 저장 완료 (키워드: {}, 저장된 개수: {})", keyword, coupangProducts.size());
} else {
log.warn("⚠️ 쿠팡 크롤링 실패 또는 검색 결과 없음 (키워드: {})", keyword);
}
});

// Kream 크롤링
log.info("📢 Kream 크롤링 시작...");
kreamKeywords.forEach(keyword -> {
log.debug("🔎 Kream 검색 키워드: {}", keyword);
List<Product> kreamProducts = kreamApiService.searchItems(keyword);
productService.saveItems(kreamProducts, keyword); // DB에 저장
log.info("✅ Kream 크롤링 완료 (키워드: {}, 검색 결과: {} 개)", keyword, kreamProducts.size());

if (!kreamProducts.isEmpty()) {
productService.saveItems(kreamProducts, keyword);
log.info("✅ Kream 상품 저장 완료 (키워드: {}, 저장된 개수: {})", keyword, kreamProducts.size());
} else {
log.warn("⚠️ Kream 크롤링 실패 또는 검색 결과 없음 (키워드: {})", keyword);
}
});
}

/**
* 매일 20시 12분에 상품 정보를 자동으로 크롤링하는 스케줄러
*/
@Scheduled(cron = "0 16 17 * * *")
public void scheduleCrawl() {
crawlAndStoreData();
log.info("🎯 크롤링 및 저장 작업 완료!");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.team4.giftidea.repository;

import java.util.List;
import java.util.Optional;

import com.team4.giftidea.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -12,19 +13,18 @@
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

/**
* 주어진 productId가 이미 존재하는지 확인합니다.
*
* @param productId 확인할 상품 ID
* @return 존재 여부 (true: 존재함, false: 존재하지 않음)
*/
boolean existsByProductId(String productId);

/**
* 주어진 키워드 목록에 해당하는 상품들을 검색합니다.
*
* @param keywords 검색할 키워드 목록
* @return 해당 키워드에 맞는 상품 리스트
*/
List<Product> findByKeywordIn(List<String> keywords);

/**
* ✅ 특정 productId로 상품 조회
* @param productId 상품 ID
* @return 상품 엔티티 (없으면 Optional.empty())
*/
Optional<Product> findByProductId(String productId);
}
141 changes: 47 additions & 94 deletions src/main/java/com/team4/giftidea/service/CoupangApiService.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
Expand All @@ -25,7 +24,8 @@
public class CoupangApiService {

private static final String COUPANG_SEARCH_URL = "https://www.coupang.com/np/search?q=%s&channel=user";
private static final String USER_AGENT = "user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.90 Safari/537.36";
private static final String USER_AGENT = "user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36";

@Value("${selenium.chromedriver-path}")
private String chromeDriverPath;
Expand All @@ -42,97 +42,50 @@ public CoupangApiService(ProductService productService) {
}

/**
* 주어진 키워드에 대해 쿠팡에서 상품을 검색하고 리스트로 반환합니다.
*
* @param query 검색 키워드
* @return 크롤링된 상품 리스트
*/
public List<Product> searchItems(String query) {
List<Product> productList = new ArrayList<>();
System.setProperty("webdriver.chrome.driver", chromeDriverPath);
ChromeOptions options = new ChromeOptions();
options.setBinary("/opt/google/chrome/chrome"); // 크롬 바이너리 직접 지정 (AWS 환경)
options.addArguments("--headless"); // 기본적으로 headless 모드 유지
options.addArguments("--disable-gpu");
options.addArguments("--no-sandbox");
options.addArguments("--disable-dev-shm-usage");
options.addArguments("--remote-debugging-port=9222");
options.addArguments("--window-size=1920,1080");
options.addArguments("--disable-software-rasterizer");
options.addArguments("--disable-extensions");
options.addArguments("--disable-popup-blocking");

// 최신 User-Agent 추가 (봇 탐지 우회)
options.addArguments("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.63 Safari/537.36");

WebDriver driver = new ChromeDriver(options);

try {
log.info("검색어: {}", query);

String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8);
String searchUrl = String.format("https://www.coupang.com/np/search?q=%s&channel=user", encodedQuery);

log.info("쿠팡 검색 URL: {}", searchUrl);
driver.get(searchUrl);

log.info("쿠팡 페이지 접속 완료. 5초 대기 중...");
Thread.sleep(5000); // 페이지 로딩 대기

// navigator.webdriver 감추기
JavascriptExecutor js = (JavascriptExecutor) driver;
js.executeScript("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})");

log.info("navigator.webdriver 속성 제거 완료.");

// 현재 페이지의 HTML 확인
String pageSource = driver.getPageSource();
log.debug("현재 페이지 HTML (앞부분): {}", pageSource.substring(0, Math.min(1000, pageSource.length())));

// 페이지가 제대로 로드되었는지 확인
if (pageSource.contains("captcha") || pageSource.contains("robot check")) {
log.error("쿠팡에서 봇 탐지를 수행함. 크롤링 차단됨.");
return Collections.emptyList();
}

log.info("상품 리스트 로딩 시작...");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(90));
wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(".search-product")));

List<WebElement> products = driver.findElements(By.cssSelector(".search-product"));

if (products.isEmpty()) {
log.warn("검색 결과가 없음. 크롤링할 제품 없음.");
return Collections.emptyList();
}

log.info("총 {}개의 상품 발견", products.size());

for (WebElement productElement : products) {
try {
Product productEntity = extractProductInfo(productElement, query);
if (productEntity != null) {
productList.add(productEntity);
productService.saveItems(List.of(productEntity), query);
log.info("상품 저장 완료: {}", productEntity.getTitle());
}
} catch (NoSuchElementException e) {
log.warn("요소를 찾을 수 없음: {}", e.getMessage());
}
}

} catch (TimeoutException e) {
log.error("페이지 로딩 시간 초과: {}", e.getMessage());
} catch (WebDriverException e) {
log.error("ChromeDriver 관련 오류 발생: {}", e.getMessage());
} catch (Exception e) {
log.error("크롤링 중 알 수 없는 오류 발생: {}", e.getMessage());
} finally {
log.info("ChromeDriver 종료...");
driver.quit();
}

return productList;
* 주어진 키워드에 대해 쿠팡에서 상품을 검색하고 리스트로 반환합니다.
*
* @param query 검색 키워드
* @return 크롤링된 상품 리스트
*/
public List<Product> searchItems(String query) {
List<Product> productList = new ArrayList<>();
System.setProperty("webdriver.chrome.driver", chromeDriverPath);

ChromeOptions options = new ChromeOptions();
options.addArguments(USER_AGENT);

WebDriver driver = new ChromeDriver(options);

try {
String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8);
String searchUrl = String.format(COUPANG_SEARCH_URL, encodedQuery);

driver.get(searchUrl);
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(90));
wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(".search-product")));

List<WebElement> products = driver.findElements(By.cssSelector(".search-product"));

for (WebElement productElement : products) {
try {
Product productEntity = extractProductInfo(productElement, query);
if (productEntity != null) {
productList.add(productEntity);
productService.saveItems(List.of(productEntity), query);
}
} catch (NoSuchElementException e) {
log.warn("요소를 찾을 수 없음: {}", e.getMessage());
}
}
} catch (TimeoutException e) {
log.error("페이지 로딩 시간 초과: {}", e.getMessage());
} catch (Exception e) {
log.error("크롤링 중 오류 발생: {}", e.getMessage());
} finally {
driver.quit(); // 크롤링 후 브라우저 종료
}

return productList;
}

/**
Expand Down Expand Up @@ -184,4 +137,4 @@ private String extractProductId(String link) {
return "unknown";
}
}
}
}
37 changes: 27 additions & 10 deletions src/main/java/com/team4/giftidea/service/KreamApiService.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

/**
* Kream 웹사이트에서 상품 정보를 크롤링하는 서비스 클래스
Expand All @@ -25,7 +26,8 @@
public class KreamApiService {

private static final String KREAM_SEARCH_URL = "https://kream.co.kr/search?keyword=%s&tab=products";
private static final String USER_AGENT = "user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.90 Safari/537.36";
private static final String USER_AGENT = "user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36";

@Value("${selenium.chromedriver-path}")
private String chromeDriverPath;
Expand All @@ -48,14 +50,8 @@ public List<Product> searchItems(String query) {
System.setProperty("webdriver.chrome.driver", chromeDriverPath);

ChromeOptions options = new ChromeOptions();
options.setBinary("/opt/google/chrome/chrome");
options.addArguments("--headless"); // Headless 모드 유지
options.addArguments("--disable-gpu");
options.addArguments("--no-sandbox");
options.addArguments("--disable-dev-shm-usage");
options.addArguments("--remote-debugging-port=9222");
options.addArguments("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.90 Safari/537.36"); // 브라우저 속이기

options.addArguments(USER_AGENT);

WebDriver driver = new ChromeDriver(options);

try {
Expand Down Expand Up @@ -105,7 +101,11 @@ private Product extractProductInfo(WebElement productElement, String query) {
String imageUrl = productElement.findElement(By.tagName("img")).getAttribute("src");
String link = productElement.findElement(By.tagName("a")).getAttribute("href");

// ✅ 상품 코드 (product_id) 추출
String productId = extractProductIdFromLink(link);

Product product = new Product();
product.setProductId(productId);
product.setTitle(title);
product.setPrice(price);
product.setImage(imageUrl);
Expand All @@ -119,4 +119,21 @@ private Product extractProductInfo(WebElement productElement, String query) {
return null;
}
}
}

/**
* ✅ Kream 상품 링크에서 productId (상품 코드) 추출
* @param link 상품 페이지 URL
* @return 상품 코드 (숫자)
*/
private String extractProductIdFromLink(String link) {
try {
String[] parts = link.split("/products/");
if (parts.length > 1) {
return parts[1].split("\\?")[0]; // "430299?size=" → "430299" 추출
}
} catch (Exception e) {
log.error("🔴 상품 코드 추출 실패: {}", link);
}
return UUID.randomUUID().toString(); // 실패 시 랜덤값 사용 (예외 방지)
}
}
Loading