코드 저장소.

[Coffies Vol.02] 크롤링 데이터 캐싱 - 1 본문

포폴/Coffies Vol.02

[Coffies Vol.02] 크롤링 데이터 캐싱 - 1

slown 2024. 8. 7. 18:13

목차

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를 활용한 캐싱 전략이다. 

 

캐싱에 관련된 내용은 다음글에 작성을 하겠다.