쇼핑몰에서 새로운 상품이 들어오거나 새로운 유통사를 지정 해야 할 때 사용자가 일일이 입력하지 않고 간편하게 데이터를 저장했다가 한 번에 데이터를 저장할 수 있는 페이지를 만들고자 한다.
완전히 테이블과 데이터가 일치하지는 않지만 저장된 데이터를 excel이나 csv 파일로 변환하고 추후 데이터 입력에서 쉽고 빠르게 데이터를 INSERT 할 수 있는 기능을 만드는 것을 목표로 한다.
웹 페이지에서 특정 요소의 ID가 변경되면, 크롤링 코드에서 그 ID를 참조하여 데이터를 추출하는 경우 문제가 발생할 수 있다. 따라서 크롤링 하는 api를 구현해도 무용지물이 되어버리는 경우가 생길 수 있기 때문에 Katalon Recorder 를 사용하여 먼저 페이지의 데이터를 크롤링하는 것을 테스트 해본다. 간편하게 화면에 들어오는 입력을 녹화하고 크롤링하기 때문에 사전에 크롤링 테스트를 하기에 적합하다.
chrome 웹 스토어에서 katalon recorder 검색 후 설치
프로그램을 실행 후 위쪽 Record 버튼을 누르고 원하는 액션을 취한다.
내 경우에는 위 사이트에서 바코드를 입력하는 것을 녹화 하고
Play from here로 실행시켜보았다. 다음날, 그 다음주에도 실행했을 때 잘 작동되는 것으로 봐서 우선 주, 일 단위로 id등의 사이트 구조가 변경되지 않는다는 것을 알았고 api를 작성하였다.
크롤링은 Selenium 라이브러리를 사용하였다.
자바에서 크롤링은 jsoup, selenium 등의 라이브러리에서 지원하는데 selenium을 선택한 이유는 추후에 설명하도록 한다.
Selenium을 사용하기 위해선 브라우저의 드라이버가 필요하다.
우선 의존성 주입을 해주었다.
// web crawler
implementation 'org.seleniumhq.selenium:selenium-java:4.19.0'
implementation 'org.seleniumhq.selenium:selenium-chrome-driver:4.19.0'
이후 자신이 사용하는 chrome 버전에 맞는 driver를 설치한다.
크롬 드라이버는 아래 링크에서 다운로드 가능하다.
https://googlechromelabs.github.io/chrome-for-testing/
적절한 경로에 압축을 풀고 위치 시킨 다음
System.setProperty("webdriver.chrome.driver", "D:\\chromedriver\\chromedriver.exe");
WebDriver driver = new ChromeDriver();
드라이버 파일의 경로를 설정해준 다음 객체를 생성한다.
그리고 여기서 Selenium을 사용해야 하는 이유가 나온다.
jsoup의 경우 자바스크립트로 생성된 콘텐츠를 가져올 수 없다는 단점, 즉 정적인 페이지에서 사용이 가능하고 브라우저의 제어(버튼 클릭, 사용자 입력)이 불가능하다.
하지만 Selenium의 경우 위의 기능이 모두 가능한 대신 속도가 느리고 리소스를 조금 더 많이 사용한다는 단점이 있다.
위 사이트에서는 상품의 정보를 가져오기 위해서 input에 바코드 번호를 입력하고 검색 버튼을 클릭까지 해야 보인다.
처음에는 더 빠른 jsoup를 사용하려고 했고 예측한건 url에 https://m.retaildb.or.kr/service/product_info/123456789012 이런 식으로 파라미터가 올 거라고 생각했지만 실제로는 그렇지 않았기 때문에 브라우저 제어가 가능한 Selenium을 사용하기로 하였다.
XPath 를 복사하면 /html/body/div/section/div[2]/fieldset/div/input 이러한 경로를 복사 할 수 있다. dom 구조에서 해당 경로를 확인했을 때 input form이 나온다는 것을 알 수 있다.
driver.get("https://m.retaildb.or.kr/service/product_info");
// 바코드 입력 필드 찾기
WebElement barcodeInput = driver.findElement(By.xpath("/html/body/div/section/div[2]/fieldset/div/input"));
barcodeInput.sendKeys(barcode); // 바코드 입력
// 검색 버튼 클릭
WebElement searchButton = driver.findElement(By.xpath("/html/body/div/section/div[2]/fieldset/a"));
searchButton.click();
그렇게 입력 필드를 찾고 검색 버튼의 XPath도 복사해서 클릭하도록 한다.
위 사이트에서 존재하는 바코드를 입력했다면 상품 정보가 나온다. 이 정보를 파싱해야 하는데 마찬가지로 가져올 데이터의 XPath를 복사해서 저장하도록 한다.
// 검색 결과 기다리기 (필요시 추가적인 wait)
Thread.sleep(2000); // 잠시 대기
// 상품명과 카테고리 가져오기
WebElement categoryElement = driver.findElement(By.xpath("/html/body/div/section/div[2]/div/div[1]/div[3]/div/ul/li[1]/p[2]"));
WebElement productNameElement = driver.findElement(By.xpath("/html/body/div/section/div[2]/div/div[1]/div[3]/div/ul/li[2]/p[2]"));
String category = categoryElement.getText();
String productName = productNameElement.getText();
이때 Thread.sleep을 통해 검색되는데 걸리는 시간을 대기하고 2초가 지나면 페이지에서 요소들의 값을 가져온다.
// 크롤링 결과 반환
return new CrawlResponseDto(productName, category);
} catch (Exception e) {
e.printStackTrace();
log.error("Failed to crawl product info: " + e.getMessage());
return null;
} finally {
// WebDriver 종료
driver.quit();
}
}
그러고 가져온 값을 dto로 만들어 반환하고 마지막에 driver 객체의 사용을 종료 하도록 한다.
전체 코드는 아래와 같다.
RegisterService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class RegisterService {
private final RegisterProductInfoRepository registerProductInfoRepository;
public CrawlResponseDto crawlProductInfo(String barcode) {
// 크롬 드라이버 위치 설정
System.setProperty("webdriver.chrome.driver", "D:\\chromedriver\\chromedriver.exe");
// Selenium WebDriver 객체 생성
WebDriver driver = new ChromeDriver();
log.info("WebDriver 객체 생성 완료");
try {
// 웹사이트로 이동
driver.get("https://m.retaildb.or.kr/service/product_info");
// 바코드 입력 필드 찾기
WebElement barcodeInput = driver.findElement(By.xpath("/html/body/div/section/div[2]/fieldset/div/input"));
barcodeInput.sendKeys(barcode); // 바코드 입력
// 검색 버튼 클릭
WebElement searchButton = driver.findElement(By.xpath("/html/body/div/section/div[2]/fieldset/a"));
searchButton.click();
// 검색 결과 기다리기 (필요시 추가적인 wait)
Thread.sleep(2000); // 잠시 대기
// 상품명과 카테고리 가져오기
WebElement categoryElement = driver.findElement(By.xpath("/html/body/div/section/div[2]/div/div[1]/div[3]/div/ul/li[1]/p[2]"));
WebElement productNameElement = driver.findElement(By.xpath("/html/body/div/section/div[2]/div/div[1]/div[3]/div/ul/li[2]/p[2]"));
String category = categoryElement.getText();
String productName = productNameElement.getText();
// 크롤링 결과 반환
return new CrawlResponseDto(productName, category);
} catch (Exception e) {
e.printStackTrace();
log.error("Failed to crawl product info: " + e.getMessage());
return null;
} finally {
// WebDriver 종료
driver.quit();
}
}
public RegisterProductInfo saveProduct(RegistProductRequestDto dto) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String formattedDate = LocalDateTime.now().format(formatter);
RegisterProductInfo productInfo = RegisterProductInfo.builder()
.caName(dto.caName())
.caName2(dto.caName2())
.caName3(dto.caName3())
.caName4(dto.caName4())
.itName(dto.itName())
.enItName(dto.enItName())
.itBarcode(dto.itBarcode())
.itDistributionPeriod(dto.itDistributionPeriod())
.sdName(dto.sdName())
.sppReceiptDate(dto.sppReceiptDate())
.sppVat(dto.sppVat())
.sppWholesaleDis(dto.sppWholesaleDis())
.sppSeniorDcRatio(dto.sppSeniorDcRatio())
.sppInitialBoxQuantity(dto.sppInitialBoxQuantity())
.appBoxSet(dto.appBoxSet())
.createdAt(formattedDate)
.build();
return registerProductInfoRepository.save(productInfo);
}
public Optional<List<RegisterProductInfo>> getProductList(String from, String to) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
LocalDate fromDate = LocalDate.parse(from, formatter);
LocalDate toDate = LocalDate.parse(to, formatter);
String startDateTime = fromDate.atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
String endDateTime;
if (fromDate.equals(toDate)) {
endDateTime = fromDate.plusDays(1).atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
} else {
endDateTime = toDate.plusDays(1).atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
return registerProductInfoRepository.findAllCreatedAtDateBetween(startDateTime, endDateTime);
}
}
RegisterController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api2/register")
public class RegisterController {
private final RegisterService registerService;
@PostMapping("/crawl-product")
public ResponseEntity<?> crawlProduct(@RequestBody BarcodeRequestDto request) {
try {
CrawlResponseDto responseDto = registerService.crawlProductInfo(request.barcode());
return ResponseEntity.ok(responseDto);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(null);
}
}
@PostMapping("/save-product")
public ResponseEntity<?> saveProduct(@RequestBody RegistProductRequestDto request) {
try {
return ResponseEntity.ok().body(registerService.saveProduct(request));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to save product: " + e.getMessage());
}
}
@PostMapping("/product-list")
public ResponseEntity<?> getProductList(@RequestBody FromToRequestDto request) {
try {
return ResponseEntity.ok().body(registerService.getProductList(request.from(), request.to()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to get product list: " + e.getMessage());
}
}
}
코드 시현 영상
크롤링에 사용된 사이트: https://m.retaildb.or.kr/service/product_info