일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 일정관리프로젝트
- JPA
- LV1
- Spring Frame Work
- LV0
- 이것이 자바다
- docker
- CoffiesVol.02
- 프로그래머스
- mysql
- 알고리즘
- LV01
- 데이터 베이스
- 포트폴리오
- 네트워크
- Java
- LV03
- 포트 폴리오
- Join
- 배열
- jpa blog
- LV.02
- SQL
- 코테
- Til
- 연습문제
- 디자인 패턴
- Redis
- Lv.0
- LV02
- Today
- Total
코드 저장소.
[Coffies Vol.02] 크롤링 데이터 캐싱 - 1 본문
목차
1.문제 상황
2.고안
3.적용 및 결과
1.문제 상황
가게 정보를 직접 입력을 하는 것보다는 가게정보를 카카오맵에서 가져와서 정보를 다루는 것이 낫다고 해서
크롤링을 함. 기존의 방식은 사진과 같습니다.
이 방식에서 여러 문제가 있는데 보완을 해 볼점은 다음과 같습니다.
- 한번 작업을 완료를 하는데 시간이 많이 걸림
- 크롤링에서 가게가 중복으로 저장
- 크롤링 작업시 예상치 못하게 작업이 중단된 경우 중단된 곳까지 저장이 됨.
2.고안
우선은 현 상황에서의 문제점을 해결하기 위해서 다음과 같은 생각을 해봤습니다.
- 크롤링 작업 속도가 느리다.
- Redis를 사용해서 작업 동시에 가게 정보를 캐싱하기.
- 크롤링에 중복으로 저장되는 가게내용
- 크롤링 작업시 미리 csv파일을 만들어서 데이터 백업 겸 중복으로 저장을 하지 않게 1차로 방지하기.
- 정기적으로 새로운 가게정보를 얻기 위해서 Redis에 있는 내용과 대조해서 2차 중복방지하기.
- 크롤링 작업시 예상치 못하게 작업이 중단이 된 경우 중단된 곳까지 저장이 됨.
- 트랜잭션에 롤백을 적용하기.
그래서 이것을 적용해본 도식은 다음과 같습니다.
3.적용 및 결과
우선은 처음으로 셀레니움을 사용을 해서 크롤링을 사용해서 csv파일까지 저장을 하는 부분까지 입니다.
크롤링 작업 및 디비저장 코드 (CrawlingService)
@Scheduled(cron = "0 0 0 1 * ?")
public void runCrawlingAndSaveToCSV() {
WebDriver driver = null;
try {
System.setProperty(WEB_DRIVER_ID, WEB_DRIVER_PATH);
ChromeOptions options = new ChromeOptions();
options.addArguments("headless");
options.addArguments("--start-maximized");
options.addArguments("--disable-popup-blocking");
options.addArguments("--remote-allow-origins=*");
driver = new ChromeDriver(options);
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)); //암시적 대기 10초
driver.get("https://map.kakao.com//");
String searchKeyword = "강북구 카페";
WebElement searchBox = driver.findElement(By.id("search.keyword.query"));
searchBox.sendKeys(searchKeyword);
searchBox.sendKeys(Keys.RETURN);
TimeUnit.SECONDS.sleep(2);
//가게 정보 수집 + csv 파일 + 디비 저장
collectStoreInfo(driver);
} catch (Exception e) {
log.error("Error occurred during crawling and CSV generation: {}", e.getMessage());
} finally {
if (driver != null) {
driver.quit();
}
}
}
위의 코드는 다음과 같습니다.
- 매달 1일 자정에 크롤링 시작하는 스케줄러.
- 크롬을 headless 모드(화면 없이 백그라운드)로 띄움.
- 카카오맵에 "강북구 카페" 검색.
- collectStoreInfo(driver) 메서드 호출 → 본격 크롤링.
/**
* 가게 정보 수집 (크롤링 수집)
* @param driver 셀레니움 드라이버
**/
private void collectStoreInfo(WebDriver driver) throws InterruptedException {
List<List<String>> dataLines = new ArrayList<>();
//csv 파일에서 필요한 구분
dataLines.add(List.of("번호", "가게이름", "가게주소", "가게시작시간", "가게종료시간", "전화번호", "메인이미지URL", "서브이미지1URL", "서브이미지2URL", "서브이미지3URL"));
JavascriptExecutor js = (JavascriptExecutor) driver;
js.executeScript("document.getElementById('dimmedLayer').style.display='none';");
TimeUnit.SECONDS.sleep(2);
WebElement option2 = driver.findElement(By.xpath("//*[@id=\"info.main.options\"]/li[2]/a"));
option2.click();
TimeUnit.SECONDS.sleep(2);
WebElement btn = driver.findElement(By.cssSelector(".more"));
((JavascriptExecutor) driver).executeScript("arguments[0].click();", btn);
boolean hasNextPage = true;
//csv 파일 초기화
initializeCSV();
while (hasNextPage) {
for (int pageNo = 1; pageNo <= 5; pageNo++) {
int attempt = 0;
boolean success = false;
while (!success & attempt < MAX_ATTEMPTS) {
try {
String xPath = "//*[@id=\"info.search.page.no" + pageNo + "\"]";
WebElement pageElement = new WebDriverWait(driver, Duration.ofSeconds(30L)).until(
ExpectedConditions.elementToBeClickable(By.xpath(xPath)));
pageElement.sendKeys(Keys.ENTER);
Thread.sleep(2000);
List<WebElement> storeList = driver.findElements(By.cssSelector(".PlaceItem"));
String originalWindow = driver.getWindowHandle();
for (WebElement store : storeList) {
try {
WebElement detailButton = store.findElement(By.cssSelector(".moreview"));
String detailUrl = detailButton.getAttribute("href");
((JavascriptExecutor) driver).executeScript("window.open(arguments[0]);", detailUrl);
Thread.sleep(2000);
Set<String> allWindows = driver.getWindowHandles();
for (String window : allWindows) {
if (!window.equals(originalWindow)) {
driver.switchTo().window(window);
break;
}
}
List<String> storeInfo = new ArrayList<>();
storeInfo.add(String.valueOf(storeNumber++));
String storeName = driver.getTitle().trim().replace(" | 카카오맵", "");
storeInfo.add(storeName);
try {
String storeAddress = driver.findElement(By.cssSelector(".txt_address")).getText();
storeInfo.add(storeAddress);
} catch (Exception e) {
storeInfo.add("");
}
try {
StringBuilder storeHours = new StringBuilder();
List<WebElement> hoursElements = driver.findElements(By.cssSelector(".list_operation > li"));
for (WebElement element : hoursElements) {
storeHours.append(element.getText()).append(" ");
}
String[] hours = storeHours.toString().trim().split("~");
//가게 운영 시작시간
String storeStartTime = hours.length > 0 ? hours[0].trim() : "";
//가게 운영 종료시간
String storeEndTime = hours.length > 1 ? hours[1].trim() : "";
//시간 저장
storeInfo.add(extractTimeFromHours(storeStartTime));
storeInfo.add(extractTimeFromHours(storeEndTime));
} catch (Exception e) {
storeInfo.add("");
storeInfo.add("");
}
try {
//가게 전화번호
String storePhone = driver.findElement(By.cssSelector(".txt_contact")).getText();
//가게 전화번호 저장
storeInfo.add(storePhone);
} catch (Exception e) {
storeInfo.add("");
}
try {
WebElement mainImageElement = new WebDriverWait(driver, Duration.ofSeconds(WAIT_TIME))
.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(".link_photo[data-pidx='0']")));
//메인 이미지 추출 & 저장
String mainImageUrl = extractUrlFromStyle(mainImageElement.getAttribute("style")).replace("\"", "");
storeInfo.add(mainImageUrl);
} catch (TimeoutException e) {//이미지가 없는 경우 기본 이미지를 사용
for (int i = 0; i < 4; i++) {
storeInfo.add(ensureProtocol(DEFAULT_IMAGE_PATH));
}
}
//나머지 이미지 3장
for (int i = 1; i <= 3; i++) {
try {
WebElement imageElement = new WebDriverWait(driver, Duration.ofSeconds(WAIT_TIME))
.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(".link_photo[data-pidx='" + i + "']")));
//나머지 이미지 추출 & 저장
String imageUrl = extractUrlFromStyle(imageElement.getAttribute("style")).replace("\"", "");
storeInfo.add(imageUrl);
} catch (TimeoutException e) {//이미지가 없는 경우 기본 이미지 사용
storeInfo.add(ensureProtocol(DEFAULT_IMAGE_PATH));
}
}
//csv 파일 작성
writeToCSV(storeInfo);
driver.close();
driver.switchTo().window(originalWindow);
Thread.sleep(4000);
success = true;
} catch (Exception e) {
driver.navigate().back();
Thread.sleep(4000);
}
}
} catch (Exception e) {
attempt++;
}
}
if (attempt >= MAX_ATTEMPTS) {
hasNextPage = false;
break;
}
}
try {
WebElement nextPageBtn = driver.findElement(By.id("info.search.page.next"));
if (nextPageBtn.isDisplayed() && nextPageBtn.isEnabled()) {
((JavascriptExecutor) driver).executeScript("arguments[0].scrollIntoView(true);", nextPageBtn);
((JavascriptExecutor) driver).executeScript("arguments[0].click();", nextPageBtn);
Thread.sleep(4000);
} else {
hasNextPage = false;
}
} catch (Exception e) {
hasNextPage = false;
}
}
//크롤링 후 디비에 저장
processCsvAndSaveToDatabase("store_info.csv");
}
가게의 정보를 크롤링을 사용해서 정보를 수집을 하는 메서드입니다.
- 5페이지씩 검색 결과를 탐색.
- 각 가게를 클릭해서 상세 페이지를 새 창으로 열기.
- 새 창에서:
- 가게 이름
- 주소
- 운영 시작/종료 시간
- 전화번호
- 메인 이미지 URL
- 서브 이미지 3개 URL
- 이 데이터를 CSV 파일에 저장.
- 문제 생기면 재시도(MAX_ATTEMPTS까지).
/**
* csv 파일 작성
* @param storeInfo 크롤링으로 모인 가게정보
**/
private void writeToCSV(List<String> storeInfo) throws IOException {
String filePath = "store_info.csv";
try (CSVWriter writer = new CSVWriter(new FileWriter(filePath, true))) {
String[] record = storeInfo.toArray(new String[0]);
writer.writeNext(record);
log.info("CSV 파일에 저장: {}", String.join(",", record));
} catch (IOException e) {
log.error("CSV 파일 저장 실패: {}", e.getMessage());
throw e;
}
}
/**
* csv 파일 초기화
**/
private void initializeCSV() {
String filePath = "store_info.csv";
try (CSVWriter writer = new CSVWriter(new FileWriter(filePath))) {
String[] header = {"번호", "가게명", "가게주소", "가게시작시간", "가게종료시간", "가게전화번호", "메인이미지URL", "서브이미지1URL", "서브이미지2URL", "서브이미지3URL"};
writer.writeNext(header);
} catch (IOException e) {
log.error("CSV 파일 초기화 실패: {}", e.getMessage());
}
}
/**
* csv파일을 읽고 디비에 저장하기.
* @param csvFilePath csv 파일 경로
**/
@Transactional
public void processCsvAndSaveToDatabase(String csvFilePath) {
try (CSVReader csvReader = new CSVReader(new FileReader(csvFilePath))) {
String[] values;
csvReader.readNext(); // 헤더 스킵
while ((values = csvReader.readNext()) != null) {
//csv파일을 읽으면서 디비 저장
saveOrUpdatePlaceAndImages(values);
}
} catch (IOException e) {
log.error("Error occurred while reading CSV file: {}", e.getMessage());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
CSV 초기화 및 작성
- initializeCSV() :
→ 크롤링 시작 전, CSV 파일에 헤더 작성. - writeToCSV(storeInfo) :
→ 가게 하나하나 크롤링할 때마다 CSV에 한 줄씩 저장.
크롤링 후 데이터베이스 저장 (processCsvAndSaveToDatabase)
- 크롤링이 끝나면,
- CSV 파일을 읽어서
- 가게 엔티티와 가게 이미지 엔티티를 DB에 저장.
- 저장할 때는 @Transactional 붙여서,
중간에 실패하면 전부 롤백해서 데이터 일관성 보장.
@Transactional
public void saveOrUpdatePlaceAndImages(String[] csvLine) throws Exception {
String placeName = csvLine[1];
// 가게 여부 확인
Place existingPlace = placeRepository.findByPlaceName(placeName);
Place place;
if (existingPlace != null) {
place = existingPlace;
PlaceRequestDto placeRequestDto = createPlaceRequestDto(csvLine);
place.placeUpdate(placeRequestDto);
} else {
place = createPlace(csvLine);
placeRepository.save(place);
}
place = createPlace(csvLine);
placeRepository.save(place);
//이미지 저장
List<PlaceImage> placeImages = createPlaceImages(csvLine);
for (PlaceImage placeImage : placeImages) {
placeImage.setPlace(place);
placeImageRepository.save(placeImage);
}
}
/**
* 이미지 생성(+리사이징)
* @param csvLine csv파일에 이미지 관련 열(메인이미지 URL,서브이미지 URL)
* @return List<PlaceImage> 가게이미지들
**/
private List<PlaceImage> createPlaceImages(String[] csvLine) throws Exception {
List<PlaceImage> placeImages = new ArrayList<>();
// 메인이미지 URL 처리
String mainImageUrl = ensureProtocol(csvLine[6]);
log.info("mainUrl::"+mainImageUrl);
//URL을 MultipartFile로 전환
List<MultipartFile> mainImages = downloadImagesFromUrls(Collections.singletonList(mainImageUrl));
log.info("이미지처리::"+mainImages);
if(!mainImages.isEmpty()){
//이미지 업로드
List<PlaceImage> mainPlaceImages = fileHandler.placeImagesUpload(mainImages);
log.info("이미지 업로드??:"+mainPlaceImages);
// 리사이징 적용 (메인이미지: 360x360)
for (PlaceImage image : mainPlaceImages) {
image.setIsTitle("Y");
String resizedImagePath = fileHandler.ResizeImage(image, 360, 360);
log.info("resizing:::"+resizedImagePath);
image.setThumbFileImagePath(resizedImagePath);
}
placeImages.addAll(mainPlaceImages);
}else {
log.warn("Main image URL is invalid: {}", mainImageUrl);
}
//서브이미지 URL 처리
List<String> subImageUrls = Arrays.asList(ensureProtocol(csvLine[7]), ensureProtocol(csvLine[8]), ensureProtocol(csvLine[9]));
log.info("subimages::"+subImageUrls);
for (int i = 0; i < subImageUrls.size(); i++) {
//서브이미지 URL에서 MultipartFile로 전환
List<MultipartFile> subImages = downloadImagesFromUrls(subImageUrls);
if(!subImageUrls.isEmpty()) {
//이미지 업로드
List<PlaceImage> subPlaceImages = fileHandler.placeImagesUpload(subImages);
log.info(subPlaceImages);
// 리사이징 적용 (서브이미지: 120x120)
for (PlaceImage image : subPlaceImages) {
String resizedImagePath = fileHandler.ResizeImage(image, 120, 120);
log.info("resizeing:::"+resizedImagePath);
image.setThumbFileImagePath(resizedImagePath);
}
placeImages.addAll(subPlaceImages);
}else {
log.warn("Sub image URL is invalid: {}", mainImageUrl);
}
}
return placeImages;
}
가게 저장
- 이미 등록된 가게면 업데이트, 없으면 새로 저장.
- 이미지들도 같이 저장.
이미지 처리
- 이미지 URL에서 이미지를 다운로드해서 MultipartFile로 변환.
- 파일 서버나 로컬 서버에 업로드.
- 메인이미지(360x360), 서브이미지(120x120) 크기로 리사이징.
/**
* 이미지 URL을 MultipartFile로 전환
* @param urls 이미지 URL (List)
* @return images (List<MultipartFile>)
**/
private List<MultipartFile> downloadImagesFromUrls(List<String> urls) throws Exception {
List<MultipartFile> images = new ArrayList<>();
for (String urlString : urls) {
URL url;
try{
url = new URL(urlString);
if (url.getProtocol().equals("file")) {
// 파일 URL 처리
File file = new File(url.toURI());
MultipartFile multipartFile = convertFileToMultipartFile(file);
images.add(multipartFile);
} else {
// HTTP URL 처리
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setDoOutput(true);
connection.connect();
try (InputStream input = connection.getInputStream();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
int n;
while ((n = input.read(buffer)) != -1) {
output.write(buffer, 0, n);
}
MultipartFile multipartFile = convertByteArrayToMultipartFile(output.toByteArray(), url.getFile(), connection.getContentType());
images.add(multipartFile);
}
}
}catch (Exception e) {
// 오류 발생 시 기본 이미지 추가
File defaultImageFile = new File("C:/spring_work/workspace/CoffiesVol.02/default_image.png");
MultipartFile defaultMultipartFile = convertFileToMultipartFile(defaultImageFile);
images.add(defaultMultipartFile);
}
}
return images;
}
/**
* File을 MultipartFile로 변환
* @param file 파일
* @return MultipartFile
**/
public static MultipartFile convertFileToMultipartFile(File file) throws IOException {
DiskFileItem fileItem = new DiskFileItem(
"file",
Files.probeContentType(file.toPath()),
false,
file.getName(),
(int) file.length(),
file.getParentFile()
);
try (FileInputStream input = new FileInputStream(file)) {
IOUtils.copy(input, fileItem.getOutputStream());
}
return new CommonsMultipartFile(fileItem);
}
/**
* ByteArray를 MultipartFile로 변환
* @param bytes ㅇ
* @param contentType 컨텐츠 타입
* @param fileName 파일명
* @return MultipartFile
**/
public static MultipartFile convertByteArrayToMultipartFile(byte[] bytes, String fileName, String contentType) throws IOException {
DiskFileItem fileItem = new DiskFileItem(
"file",
contentType,
false,
fileName,
bytes.length,
null
);
try (ByteArrayInputStream input = new ByteArrayInputStream(bytes)) {
IOUtils.copy(input, fileItem.getOutputStream());
}
return new CommonsMultipartFile(fileItem);
}
/**
* csv 파일에 있는 이미지 URL 추출
* @param url 이미지 URL
**/
private String ensureProtocol(String url) {
// 경로 구분자를 통일
String normalizedUrl = url.replace("\\", "/");
String normalizedDefaultImagePath = DEFAULT_IMAGE_PATH.replace("\\", "/");
log.info("Normalized URL: " + normalizedUrl);
log.info("Normalized Default Image Path: " + normalizedDefaultImagePath);
// 기본 이미지 경로를 파일 URL로 변환
if (normalizedUrl.equalsIgnoreCase(normalizedDefaultImagePath)) {
log.info("기본 이미지.");
return "file:///" + normalizedDefaultImagePath.replace(" ", "%20");
}
// //가 포함된 URL에서 //를 제거
if (normalizedUrl.startsWith("//")) {
return "http:" + normalizedUrl;
}
// HTTP 프로토콜이 없는 경우 추가
if (!normalizedUrl.startsWith("http://") &&
!normalizedUrl.startsWith("https://") &&
!normalizedUrl.startsWith("file:///C") &&
!normalizedUrl.equalsIgnoreCase(normalizedDefaultImagePath)&&
normalizedUrl.equalsIgnoreCase(normalizedDefaultImagePath)) {
log.info("이미지.");
return "http:" + normalizedUrl;
}else{
return "file:///" + normalizedDefaultImagePath.replace(" ", "%20");
}
}
/**
* CSS 스타일 문자열에서 URL을 추출
* @param style 스타일 문자열
**/
private String extractUrlFromStyle(String style) {
int start = style.indexOf("url(") + 4;
int end = style.indexOf(")", start);
if (start > 3 && end > start) {
return style.substring(start, end - 1);
} else {
return DEFAULT_IMAGE_PATH;
}
}
/**
* 가게 시간 추출(정규식을 사용)
* @param hours 가게 시간
**/
private String extractTimeFromHours(String hours) {
Pattern pattern = Pattern.compile("\\d{1,2}:\\d{2}");
Matcher matcher = pattern.matcher(hours);
if (matcher.find()) {
return matcher.group();
} else {
return "";
}
}
downloadImagesFromUrls
역할:
주어진 이미지 URL 리스트를 돌면서
- 정상 이미지면 다운로드해서 MultipartFile 객체로 만들고
- 에러나면 기본 이미지로 대체해서
최종적으로 이미지 리스트 반환.
과정 흐름:
- URL이 file://이면 → 로컬 파일에서 읽기
- URL이 http://, https://이면 → 웹에서 다운로드
- 실패하면 기본 이미지(default_image.png)로 대체.
이미지 다운로드 + 에러 대체 처리 기능.
convertFileToMultipartFile(File file)
역할:
- 파일 시스템에 있는 파일을
- 스프링에서 업로드 처리할 때 쓰는 MultipartFile 객체로 변환.
=> 서버 파일 → MultipartFile로 변환하는 기능.
convertByteArrayToMultipartFile
역할:
- 바이트 배열(이미지 데이터) 을 받아서
- MultipartFile 로 변환.
(HTTP로 다운로드한 이미지는 보통 바이트 배열이기 때문에 이게 필요함.)
=> 웹에서 받은 파일 데이터 → MultipartFile로 변환하는 기능.
ensureProtocol
역할:
- CSV에 있는 URL이 불완전할 경우, 정상 URL로 수정.
하는 일:
- \\ 슬래시 문제 해결
- //url 처럼 앞에 프로토콜이 없는 URL에 http: 자동 추가
- 기본 이미지 경로면 file:/// 프로토콜 붙이기
=> URL이 깨져 있어도 정상적으로 고쳐주는 함수.
extractUrlFromStyle
역할:
- CSS 스타일 문자열(예: background-image: url("이미지경로");)에서
- url() 안에 있는 실제 이미지 URL만 뽑아내기.
=> CSS 코드 안에서 이미지 URL만 정확히 추출하는 기능.
extractTimeFromHours
역할:
- 텍스트로 된 영업 시간 정보에서
- "오전 9:00 ~ 오후 6:00" 같은 시간 문자열 중 "09:00", "18:00" 부분만 뽑아내기.
정규식 사용:
\d{1,2}:\d{2} → "숫자:숫자" 형태를 찾아냄.
=> 가게 운영 시간 파싱하는 기능.
그리고 다음에 해당 테스트 코드는 다음과 같습니다.
package com.example.coffies_vol_02.config.crawling;
import com.opencsv.CSVWriter;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.*;
import org.openqa.selenium.*;
import org.openqa.selenium.TimeoutException;
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.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.FileWriter;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Log4j2
@SpringBootTest
public class SeleniumTest {
private WebDriver driver;
public static String WEB_DRIVER_ID = "webdriver.chrome.driver";
public static String WEB_DRIVER_PATH = "C:\\Users\\well4\\OneDrive\\바탕 화면\\chromedriver-win32 (1)\\chromedriver-win32\\chromedriver.exe";
private static final String DEFAULT_IMAGE_PATH = "C:\\spring_work\\workspace\\CoffiesVol.02\\default_image.png"; // 대체 이미지 URL
private static final int MAX_ATTEMPTS = 3;
private static final int WAIT_TIME = 20; // 20초 대기
private static int storeNumber = 1; // 가게 번호 초기화
@Autowired
private CrawlingService crawlingService;
@BeforeEach
public void init() throws Exception {
System.setProperty(WEB_DRIVER_ID, WEB_DRIVER_PATH);
// 2. WebDriver 옵션 설정
ChromeOptions options = new ChromeOptions();
options.addArguments("headless"); // 창 숨기는 옵션 추가
options.addArguments("--disable-gpu");
options.addArguments("--no-sandbox");
options.addArguments("--disable-dev-shm-usage");
options.addArguments("--start-maximized");
options.addArguments("--disable-popup-blocking");
options.addArguments("--remote-allow-origins=*");
driver = new ChromeDriver(options);
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)); //암시적 대기 10초
// 카카오맵 접속
driver.get("https://map.kakao.com//");
// 접속한 페이지의 소스 확인.
log.debug(driver.getPageSource());
//검색 키워드
String searchKeyword = "강북구 카페";
//검색어를 입력하기.
WebElement searchBox = driver.findElement(By.id("search.keyword.query"));
searchBox.sendKeys(searchKeyword);
searchBox.sendKeys(Keys.RETURN);
TimeUnit.SECONDS.sleep(2);
}
@AfterEach
public void quit() {
if (driver != null) {
driver.quit();
}
}
@Test
@Disabled
@DisplayName("가게정보 수집 + csv 파일로 저장하기.(이미지 포함)")
public void test8() throws InterruptedException{
List<List<String>> dataLines = new ArrayList<>();
dataLines.add(List.of("번호", "가게이름", "가게주소", "가게시작시간", "가게종료시간", "전화번호", "메인이미지URL", "서브이미지1URL", "서브이미지2URL", "서브이미지3URL"));
//장소 탭 클릭 방지.
JavascriptExecutor js = (JavascriptExecutor) driver;
js.executeScript("document.getElementById('dimmedLayer').style.display='none';");
TimeUnit.SECONDS.sleep(2);
//장소 탭 누르기.
WebElement option2 = driver.findElement(By.xpath("//*[@id=\"info.main.options\"]/li[2]/a"));
option2.click();
TimeUnit.SECONDS.sleep(2);
//목록에 있는 더보기 클릭하기.
WebElement btn = driver.findElement(By.cssSelector(".more"));
((JavascriptExecutor) driver).executeScript("arguments[0].click();", btn);
boolean hasNextPage = true;
initializeCSV();
while (hasNextPage) {
for (int pageNo = 1; pageNo <= 5; pageNo++) {
int attempt = 0;
boolean success = false;
while(!success&attempt <MAX_ATTEMPTS){
try {
// 페이지 넘기기
String xPath = "//*[@id=\"info.search.page.no" + pageNo + "\"]";
WebElement pageElement = new WebDriverWait(driver, Duration.ofSeconds(30L)).until(
ExpectedConditions.elementToBeClickable(By.xpath(xPath)));
pageElement.sendKeys(Keys.ENTER);
Thread.sleep(2000);
// 가게 목록 가져오기
List<WebElement> storeList = driver.findElements(By.cssSelector(".PlaceItem"));
// 현재 창 핸들 저장
String originalWindow = driver.getWindowHandle();
// 각 가게의 상세 조회 버튼 클릭
for (WebElement store : storeList) {
try {
// 상세 조회 버튼 다시 찾기
WebElement detailButton = store.findElement(By.cssSelector(".moreview"));
String detailUrl = detailButton.getAttribute("href");
// 새로운 탭에서 상세 조회 페이지 열기
((JavascriptExecutor) driver).executeScript("window.open(arguments[0]);", detailUrl);
Thread.sleep(2000);
// 새 탭으로 전환
Set<String> allWindows = driver.getWindowHandles();
for (String window : allWindows) {
if (!window.equals(originalWindow)) {
driver.switchTo().window(window);
break;
}
}
// 가게 정보를 저장할 리스트 생성
List<String> storeInfo = new ArrayList<>();
storeInfo.add(String.valueOf(storeNumber++)); // 번호
System.out.println(storeNumber);
// 상세 페이지 처리
// 가게명
String storeName = driver.getTitle().trim();
// "| 카카오맵" 부분 제거
storeName = storeName.replace(" | 카카오맵", "");
System.out.println("가게명:"+storeName);
storeInfo.add(storeName);
// 가게 주소
try {
String storeAddress = driver.findElement(By.cssSelector(".txt_address")).getText();
System.out.println("가게주소::"+storeAddress);
storeInfo.add(storeAddress);
} catch (Exception e) {
storeInfo.add("");
}
// 가게 운영시간
try {
StringBuilder storeHours = new StringBuilder();
List<WebElement> hoursElements = driver.findElements(By.cssSelector(".list_operation > li"));
for (WebElement element : hoursElements) {
storeHours.append(element.getText()).append(" ");
}
String[] hours = storeHours.toString().trim().split("~");
String storeStartTime = hours.length > 0 ? hours[0].trim() : "";
String storeEndTime = hours.length > 1 ? hours[1].trim() : "";
System.out.println("시작시간::"+extractTimeFromHours(storeStartTime));
System.out.println("종료 시간::"+extractTimeFromHours(storeEndTime));
storeInfo.add(extractTimeFromHours(storeStartTime)); // 시작시간
storeInfo.add(extractTimeFromHours(storeEndTime)); // 종료시간
} catch (Exception e) {
storeInfo.add("");
storeInfo.add("");
}
// 가게 전화번호
try {
String storePhone = driver.findElement(By.cssSelector(".txt_contact")).getText();
storeInfo.add(storePhone);
System.out.println("전화번호::"+storePhone);
} catch (Exception e) {
storeInfo.add("");
}
// 가게 이미지 정보 가져오기
try {
// 메인 이미지
WebElement mainImageElement = new WebDriverWait(driver, Duration.ofSeconds(WAIT_TIME))
.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(".link_photo[data-pidx='0']")));
String mainImageUrl = extractUrlFromStyle(mainImageElement.getAttribute("style"))
.replace("\"", "");
System.out.println("메인 이미지 URL: " + mainImageUrl);
//메인 이미지 추가
storeInfo.add(mainImageUrl);
} catch (TimeoutException e) {
System.out.println("이미지: 정보 없음");
storeInfo.add(DEFAULT_IMAGE_PATH);
}
// 나머지 3장 이미지
for (int i = 1; i <= 3; i++) {
try {
WebElement imageElement = new WebDriverWait(driver, Duration.ofSeconds(WAIT_TIME))
.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(".link_photo[data-pidx='" + i + "']")));
String imageUrl = extractUrlFromStyle(imageElement.getAttribute("style"))
.replace("\"", "");
System.out.println("이미지 URL: " + imageUrl);
//서브 이미지 추가
storeInfo.add(imageUrl);
} catch (TimeoutException e) {
//없는 경우 지정된 기본 이미지 저장하기.
System.out.println("이미지 URL (data-pidx=" + i + "): 정보 없음");
storeInfo.add(DEFAULT_IMAGE_PATH);
}
}
//csv 파일 저장하기.
writeToCSV(storeInfo);
// 상세 조회 후 탭 닫기
driver.close();
// 원래 창으로 돌아가기
driver.switchTo().window(originalWindow);
Thread.sleep(4000);
success =true;
} catch (Exception e) {
System.out.println(e.getMessage());
// 상세 조회 버튼 클릭 실패 시 다시 목록 페이지로 돌아가기
driver.navigate().back();
Thread.sleep(4000);
}
}
} catch (Exception e) {
attempt++;
System.out.println(e.getMessage());
}
}
if (attempt >= MAX_ATTEMPTS) {
System.out.println("최대 재시도 횟수 초과: 페이지 넘김 실패");
hasNextPage = false;
break;
}
}
// 다음 페이지 버튼 클릭
try {
WebElement nextPageBtn = driver.findElement(By.id("info.search.page.next"));
if (nextPageBtn.isDisplayed() && nextPageBtn.isEnabled()) {
// 스크롤해서 다음 페이지 버튼이 보이도록 함
((JavascriptExecutor) driver).executeScript("arguments[0].scrollIntoView(true);", nextPageBtn);
((JavascriptExecutor) driver).executeScript("arguments[0].click();", nextPageBtn);
Thread.sleep(4000);
} else {
hasNextPage = false;
System.out.println("다음 페이지 버튼을 찾을 수 없음 또는 클릭할 수 없음");
}
} catch (Exception e) {
System.out.println(e.getMessage());
hasNextPage = false;
}
}
}
@Test
public void crawlingServiceTest(){
crawlingService.runCrawlingAndSaveToCSV();
}
// CSS 스타일 문자열에서 URL을 추출
private String extractUrlFromStyle(String style) {
int start = style.indexOf("url(") + 4;
int end = style.indexOf(")", start);
if (start > 3 && end > start) {
return style.substring(start, end - 1);
} else {
return DEFAULT_IMAGE_PATH;
}
}
//시작시간 정규식 제거
private String extractTimeFromHours(String hours) {
// 예시: "월,화,수 10:00"
// 시간 정보 추출하는 정규표현식 사용
Pattern pattern = Pattern.compile("\\d{1,2}:\\d{2}");
Matcher matcher = pattern.matcher(hours);
if (matcher.find()) {
return matcher.group(); // 시간 정보만 반환
} else {
return ""; // 시간 정보가 없을 경우 처리
}
}
//csv 파일 작성
private static void writeToCSV(List<String> storeInfo) {
String filePath = "store_info.csv";
try (CSVWriter writer = new CSVWriter(new FileWriter(filePath, true))) {
String[] record = storeInfo.toArray(new String[0]);
writer.writeNext(record);
System.out.println("CSV 파일에 저장: " + String.join(",", record));
} catch (IOException e) {
System.err.println("CSV 파일 저장 실패: " + e.getMessage());
}
}
//csv파일 초기화
private static void initializeCSV() {
String filePath = "store_info.csv";
try (CSVWriter writer = new CSVWriter(new FileWriter(filePath))) {
String[] header = {"번호", "가게명", "가게주소", "가게시작시간", "가게종료시간", "가게전화번호", "메인이미지URL", "서브이미지1URL", "서브이미지2URL", "서브이미지3URL"};
writer.writeNext(header);
} catch (IOException e) {
e.printStackTrace();
}
}
}
테스트를 하게 되면 셀레니움의 경우에는 기본적으로 속도가 느리기 때문에 테스트를 한 결과는 테스트는 통과가 되었지만 해당 코드를 적용해서 걸린 시간은 3시간이었다. csv파일에 백업을 한 후에 디비에 저장을 하는 것은 좋았지만 처리를 하는 속도가 느리다는 단점이 있어서 이 문제를 어떻게 해결을 해볼까하려고 해서 내 놓은 대안은 다음과 같다.
Redis를 사용해서 크롤링을 한 후에 가게의 정보 이미지url을 캐싱을 하기.
크롤링을 사용할 경우 비동기처리를 해서 크롤링을 하기.
내가 생각을 한 대안은 이 2가지였다. 그리고 그중에서 그나마 괜찮다고 생각한 대안은 Redis를 활용한 캐싱 전략이다.
캐싱에 관련된 내용은 다음글에 작성을 하겠다.
'포폴 > Coffies Vol.02' 카테고리의 다른 글
[Coffies Vol.02] @Retryable을 사용한 재시도 설정-2 (0) | 2025.03.09 |
---|---|
[CoffiesVol.02] Redisson을 활용해서 분산락을 적용 (0) | 2024.08.06 |
[Coffies Vol.02] Redis keys 대신 Scan을 사용 (0) | 2024.07.29 |
[Coffies Vol.02] @Retryable을 사용한 재시도 설정 (0) | 2024.07.21 |
[Coffies Vol.02]Redis로 세션 불일치 상황에서의 세션 공유 (0) | 2024.06.19 |