Java

[Selenium Crwaler] Selenium과 docker를 활용한 크롤링, csv 인코딩

ride-dev 2024. 2. 23. 18:12

공공 데이터와 Selenium

한국석유공사-오피넷(외부 api)로부터 전국 유가 평균, 지역별 저렴 주유소 등의 json data를 DB에 삽입합니다.

그리고 셀레니움을 활용하여 한국석유공사-오피넷에서 전체 주유소 정보 및 주유소 가격을 csv파일로 받아서 DB에 삽입합니다.

 

셀레니움을 사용한 이유는, 한국석유공사-오피넷 api에서 제공하는 json data의 범위가 제한적이며,

한국석유공사-오피넷 웹 사이트에서 직접 데이터를 다운로드하여 얻을 수 있는 data가 필요하기 때문입니다.

좌: 공공데이터, 우: 웹사이트 다운로드

jsoup이 아닌  selenium을 사용한 이유는,

jsoup은 정적인 페이지에 사용하지만 selenium은 동적인 페이지에 사용할 수 있기 때문입니다.

 

크롤링에는 chrome과 chrome-driver가 필요합니다.

절대 경로를 지정하는 것이 필요하며, OS에 따라 root경로가 달라질 수 있습니다.

대표적으로 windows는 C://로 시작합니다.

환경에 독립적으로 동작하도록 docker를 사용할 수 있습니다.

build.gradle

먼저, 의존성을 추가합니다.

//actuactor
implementation 'org.springframework.boot:spring-boot-starter-actuator'
//selenium
implementation 'org.seleniumhq.selenium:selenium-java:4.17.0'

모니터링을 위한 actuator부터, 크롤링을 위한 selenium을 추가해줍니다.

applicatiom.properties

application.properties에서 Spring Auto Configruration에 참조할 값, 스프링 애플리케이션 내에서 사용할 환경변수를 선언합니다.

logging.level.org.springframework=info
logging.level.com.pj=debug

# selenium
selenium.type-basic-info=4
selenium.type-current-price=5
selenium.csv-download-url=https://www.opinet.co.kr/user/opdown/opDownload.do

server.servlet.encoding.charset=UTF-8
server.servlet.encoding.enabled=true
server.servlet.encoding.force=true

selenium.user-agent=user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.6167.184 Safari/537.36
selenium.chrome-path=${SELENIUM_CHROME_PATH}
selenium.web-driver-path=${SELENIUM_WEB_DRIVER_PATH}
selenium.download-filepath=${SELENIUM_DOWNLOAD_FILEPATH}
csv-encoding.output-filepath=${CSV_ENCODING_OUTPUT_FILEPATH}
#selenium.chrome-path=C:\\Users\\user\\git\\oil\\selenium\\chrome-headless-shell-win64\\chrome-headless-shell.exe
#selenium.web-driver-path=C:\\Users\\user\\git\\oil\\selenium\\chromedriver-window64\\chromedriver.exe
#selenium.download-filepath=C:\\Users\\user\\git\\oil\\src\\main\\resources\\downloads\\
#csv-encoding.output-filepath=C:\\Users\\user\\git\\oil\\src\\main\\resources\\csv\\

selenium로직에서 사용할 환경변수를 설정합니다.

웹사이트에서 기본정보를 다운로드하기 위한 변수는 4,

가격 정보를 다운로드하기 위한 변수는 5로 사용하고 있었으며,

환경변수에 이를 적용하였습니다.

또한, 개발 환경이 mac, linux, windows로 다양할 수 있기 때문에,

환경에 독립적으로 서비스할 수 있도록 docker를 사용했습니다.

(chrome과 chrome-driver의 절대 경로를 통일시키기 위해)

docker-compose

db와 spring 부분만 작성했습니다.

volumes를 통해 Docker가 docker-compose가 실시된 경로로 부터 설정된 디렉토리를 참조할 수 있게 합니다.

volumes:
  - 참조할 경로 : 도커에 저장할 경로

그 외에도 environment를 통해 application.properties에 사용할 환경변수를 정의합니다.

version: '3.8'

services:
  springboot-app:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ./src/main/resources/downloads:/app/src/main/resources/downloads
      - ./src/main/resources/csv:/app/src/main/resources/csv
      - ./src/main/resources/application.properties:/app/src/main/resources/application.properties
    environment:
      SELENIUM_CHROME_PATH: /usr/local/bin/chrome/chrome
      SELENIUM_WEB_DRIVER_PATH: /usr/local/bin/chromedriver/chromedriver
      SELENIUM_DOWNLOAD_FILEPATH: /app/src/main/resources/downloads/
      CSV_ENCODING_OUTPUT_FILEPATH: /app/src/main/resources/csv/
      TZ: Asia/Seoul
    ports:
      - "8080:8080"
    depends_on:
      - mariadb1
      - mariadb2
      - redis
      - redis2

  redis:
    image: redis:latest
    container_name: redis
    ports:
      - "6379:6379"
    command: ["redis-server", "--port", "6379"]
    environment:
      TZ: Asia/Seoul

  redis2:
    image: redis:latest
    container_name: redis2
    ports:
      - "6380:6379"
    command: ["redis-server", "--port", "6380"]
    environment:
      TZ: Asia/Seoul

  mariadb1:
    image: mariadb:latest
    container_name: mariadb1
    volumes:
      - mariadb1_data:/var/lib/mysql
    ports:
      - "3306:3306"
    environment:
      MARIADB_ROOT_PASSWORD: mypassword
      MARIADB_USER: ride
      MARIADB_PASSWORD: mypassword
      MARIADB_DATABASE: gas_station
      TZ: Asia/Seoul

  mariadb2:
    image: mariadb:latest
    container_name: mariadb2
    volumes:
      - mariadb2_data:/var/lib/mysql
    ports:
      - "3307:3306"
    environment:
      MARIADB_ROOT_PASSWORD: mypassword
      MARIADB_USER: ride
      MARIADB_PASSWORD: mypassword
      MARIADB_DATABASE: member_post
      TZ: Asia/Seoul

volumes:
  mariadb1_data:
  mariadb2_data:

 

 

spring-app은 Dockerfile을 생성하여 다양한 설정을 합니다.

chrome과 chrome-driver를 설치하는 스크립트입니다.

# Java 17 JDK를 사용하는 베이스 이미지
FROM openjdk:17-jdk-slim as build

# 필요한 패키지 설치
RUN apt-get update && apt-get install -y \
    wget \
    unzip \
    locales \
    && rm -rf /var/lib/apt/lists/* \
    && sed -i '/ko_KR.UTF-8/s/^# //g' /etc/locale.gen \
    && locale-gen \

# 로케일 설정 추가
ENV LANG ko_KR.UTF-8
ENV LANGUAGE ko_KR:kr
ENV LC_ALL ko_KR.UTF-8

# Chrome headless shell과 ChromeDriver 다운로드 및 설치
RUN wget -q --continue -P /tmp "https://storage.googleapis.com/chrome-for-testing-public/121.0.6167.184/linux64/chrome-headless-shell-linux64.zip" \
    && unzip /tmp/chrome-headless-shell-linux64.zip -d /usr/local/bin \
    && rm /tmp/chrome-headless-shell-linux64.zip \
    && chmod +x /usr/local/bin/chrome-headless-shell-linux64 \
    && wget -q --continue -P /tmp "https://storage.googleapis.com/chrome-for-testing-public/121.0.6167.184/linux64/chromedriver-linux64.zip" \
    && unzip /tmp/chromedriver-linux64.zip -d /usr/local/bin \
    && rm /tmp/chromedriver-linux64.zip \
    && chmod +x /usr/local/bin/chromedriver-linux64

# 작업 디렉토리 설정
WORKDIR /app

# Gradle 래퍼와 프로젝트 파일 복사
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
COPY src src

# gradlew 파일의 줄바꿈 포맷을 LF로 변경 및 실행 권한 부여
RUN sed -i 's/\r$//' ./gradlew && chmod +x ./gradlew

# 애플리케이션 빌드 (단위 테스트 제외)
RUN ./gradlew build -x test

# 최종 실행 이미지
FROM openjdk:17-jdk-slim
WORKDIR /app

RUN apt-get update && apt-get install -y \
    wget \
    libglib2.0-0 \
    libnss3 \
    libgconf-2-4 \
    libfontconfig1 \
    libx11-6 \
    libx11-xcb1 \
    libxcomposite1 \
    libxcursor1 \
    libxdamage1 \
    libxext6 \
    libxi6 \
    libxtst6 \
    libgdk-pixbuf2.0-0 \
    libgtk-3-0 \
    libpango-1.0-0 \
    libcairo2 \
    libatspi2.0-0 \
    libatk1.0-0 \
    libasound2 \
    libdbus-1-3 \
    libxss1 \
    libxrandr2 \
    libgbm1 \
    locales \
    --no-install-recommends \
    && rm -rf /var/lib/apt/lists/* \
    && sed -i '/ko_KR.UTF-8/s/^# //g' /etc/locale.gen \
    && locale-gen

# 로케일 설정 추가
ENV LANG ko_KR.UTF-8
ENV LANGUAGE ko_KR:kr
ENV LC_ALL ko_KR.UTF-8

COPY --from=build /app/build/libs/*.jar app.jar
COPY --from=build /usr/local/bin/chrome-headless-shell-linux64 /usr/local/bin/chrome
COPY --from=build /usr/local/bin/chromedriver-linux64 /usr/local/bin/chromedriver

ENTRYPOINT ["java","-Dfile.encoding=UTF-8","-jar","app.jar"]

PropertiesConfiguration

환경변수를 주입받은 Bean을 생성하여,

다른 Bean에서 사용할 수 있게 합니다.

package com.pj.oil.config;

import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

@Configuration
@Getter
public class PropertyConfiguration {

//

    @Value("${selenium.download-filepath}")
    private String downloadFilepath;
    @Value("${selenium.web-driver-path}")
    private String webDriverPath;
    @Value("${selenium.chrome-path}")
    private String chromePath;
    @Value("${selenium.type-basic-info}")
    private int typeBasicInfo;
    @Value("${selenium.type-current-price}")
    private int typeCurrentPrice;
    @Value("${selenium.user-agent}")
    private String userAgent;
    private final String basicInfoLpg = "사업자_기본정보(충전소).csv";
    private final String basicInfoOil = "사업자_기본정보(주유소).csv";
    private final String currentPriceLpg = "현재_판매가격(충전소).csv";
    private final String currentPriceOil = "현재_판매가격(주유소).csv";
    @Value("${selenium.csv-download-url}")
    private String csvDownloadUrl;
    @Value("${csv-encoding.output-filepath}")
    private String outFilepath;

}

util

크롤링, 인코딩에 대한 util 클래스입니다.

chrome을 headless로 사용하여 성능을 높였고,

동적인 페이지이기 때문에,

특정 요소가 나타날 때까지 대기하게 설정했습니다.

 

다운로드를 시작하면, 1초 간격으로 파일이 다운로드 되었는지 확인하도록 했습니다.

package com.pj.oil.util;

import com.pj.oil.config.PropertyConfiguration;
import org.openqa.selenium.*;
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.FluentWait;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.HashMap;

@Component
public class CrawlerUtil {
    private static final Logger LOGGER = LoggerFactory.getLogger(CrawlerUtil.class);

    private final PropertyConfiguration config;
    private final DateUtil dateUtil;

    private static String downloadPathWithDate;

    public CrawlerUtil(
            PropertyConfiguration config,
            DateUtil dateUtil
    ) {
        this.config = config;
        this.dateUtil = dateUtil;
        System.setProperty("file.encoding", "UTF-8");
    }

    public boolean downloadCSVFromWeb() {
        LOGGER.info("[downloadCSVFromWeb]");
        WebDriver driver = initializeWebDriver();
        WebDriverWait wait = new WebDriverWait(driver, Duration.ofMinutes(2));
        boolean downloadOilData;
        boolean downloadLpgData;
        try {
            navigateToPage(driver, config.getCsvDownloadUrl());
            LOGGER.info("[downloadCSVFromWeb] 주유소 데이터 다운로드");
            downloadOilData = downloadData(driver, wait, false);// 주유소 데이터 다운로드
            switchToChargeStation(driver);
            downloadLpgData = downloadData(driver, wait, true);// 충전소 데이터 다운로드
            LOGGER.info("[downloadCSVFromWeb] 충전소 데이터 다운로드");
        } finally {
            LOGGER.info("[downloadCSVFromWeb] driver quit");
            driver.quit();
        }
        return downloadOilData&&downloadLpgData;
    }

    private WebDriver initializeWebDriver() {
        LOGGER.info("[initializeWebDriver]");
        LOGGER.info("driver path:{} chrome path: {}", config.getWebDriverPath(), config.getChromePath());
        LOGGER.info("user-agent : {}", config.getUserAgent());

        System.setProperty("webdriver.chrome.driver", config.getWebDriverPath());
        ChromeOptions options = new ChromeOptions();
        options.setBinary(config.getChromePath());
        options.addArguments(config.getUserAgent());
        options.addArguments("--headless"); //브라우저 안띄움
        options.addArguments("--no-sandbox");
        options.addArguments("--disable-popup-blocking"); //팝업안띄움
        options.addArguments("--disable-gpu"); //gpu 비활성화
        options.addArguments("--blink-settings=imagesEnabled=false"); //이미지 다운 안받음
        options.addArguments("--disable-dev-shm-usage"); //Linux /dev/shm 메모리 공유 비활성화

        downloadPathWithDate = config.getDownloadFilepath() + dateUtil.getTodayDateString();
        LOGGER.info("downloadPathWithDate: {}", downloadPathWithDate);
        Path downloadDir = Paths.get(downloadPathWithDate);
        if (!Files.exists(downloadDir)) {
            try {
                Files.createDirectories(downloadDir);
                LOGGER.info("디렉토리 생성 성공: {}", downloadDir);
            } catch (IOException e) {
                LOGGER.error("디렉토리 생성 중 에러 발생: {}", downloadDir, e);
                throw new RuntimeException("디렉토리 생성 실패", e);
            }
        }

        configureDownloadOptions(options, downloadPathWithDate);
        return new ChromeDriver(options);
    }

    private boolean downloadData(WebDriver driver, WebDriverWait wait, boolean isChargeStation) {
        LOGGER.info("[downloadData]");
        boolean downloadBasicCSV = downloadCSV(driver, wait, config.getTypeBasicInfo(), isChargeStation);
        boolean downloadPriceCSV = downloadCSV(driver, wait, config.getTypeCurrentPrice(), isChargeStation);
        return downloadBasicCSV && downloadPriceCSV;
    }

    private void configureDownloadOptions(ChromeOptions options, String downloadFilepath) {
        LOGGER.info("[configureDownloadOptions] downloadFilepath: {}", downloadFilepath);
        HashMap<String, Object> prefs = new HashMap<>();
        prefs.put("profile.default_content_setting_values.automatic_downloads", 1);
        prefs.put("download.default_directory", downloadFilepath);
        prefs.put("profile.default_content_settings.popups", 0);
        prefs.put("download.prompt_for_download", "false");
        prefs.put("safebrowsing.enabled", "true");
        options.setExperimentalOption("prefs", prefs);
    }

    private boolean downloadCSV(WebDriver driver, WebDriverWait wait, int downloadType, boolean isChargeStation) {
        LOGGER.info("[downloadCSV] downloadType: {}", downloadType);
        FluentWait<WebDriver> fluentWait = new FluentWait<>(driver)
                .withTimeout(Duration.ofMinutes(5)) // 총 대기 시간을 넉넉하게 설정
                .pollingEvery(Duration.ofSeconds(1)) // 폴링 간격 설정
                .ignoring(WebDriverException.class);

        int attempt = 0;
        boolean downloadCompleted = false;

        while (attempt < 3 && !downloadCompleted) { // 최대 3번 시도
            try {
                JavascriptExecutor js = (JavascriptExecutor) driver;
                js.executeScript("fn_Download(" + downloadType + ");");
                Alert alert = wait.until(ExpectedConditions.alertIsPresent());
                LOGGER.info("alert: {}", alert.getText());
                alert.accept();

                String expectedFileName = getExpectedFileName(downloadType, isChargeStation);

                downloadCompleted = fluentWait.until((WebDriver webDriver) -> {
                    Path downloadFilePath = Paths.get(downloadPathWithDate, expectedFileName);

                    return Files.exists(downloadFilePath) && !downloadFilePath.toString().endsWith(".crdownload");
                });

                if (downloadCompleted) {
                    LOGGER.info("다운로드 완료: {}", expectedFileName);
                } else {
                    LOGGER.warn("다운로드 실패, 재시도 중...: {}", expectedFileName);
                    attempt++;
                }
            } catch (Exception e) {
                LOGGER.error("다운로드 중 예외 발생: {}", e.getMessage());
                attempt++;
                if (attempt >= 3) {
                    LOGGER.error("다운로드 최대 재시도 횟수 초과: {}", e.getMessage());
                    break;
                }
            }
        }
        return downloadCompleted;
    }

    private String getExpectedFileName(int downloadType, boolean isChargeStation) {
        LOGGER.info("[getExpectedFileName]");
        String fileName;
        if (downloadType == config.getTypeBasicInfo()) {
            if (isChargeStation) {
                fileName = config.getBasicInfoLpg();
            } else {
                fileName = config.getBasicInfoOil();
            }
        } else { // downloadType == TYPE_CURRENT_PRICE
            if (isChargeStation) {
                fileName = config.getCurrentPriceLpg();
            } else {
                fileName = config.getCurrentPriceOil();
            }
        }
        LOGGER.info("[getExpectedFileName] filename: {}", fileName);
        return fileName;
    }

    private void navigateToPage(WebDriver driver, String url) {
        LOGGER.info("[navigateToPage] url: {}", url);
        driver.get(url);
        new WebDriverWait(driver, Duration.ofSeconds(10))
                .until(ExpectedConditions.visibilityOfElementLocated(By.className("tbl_type20")));
    }

    private void switchToChargeStation(WebDriver driver) {
        LOGGER.info("[switchToChargeStation]");
        // 자동차 충전소 라디오 버튼 선택
        driver.findElement(By.id("rdo1_1")).click();
        driver.findElement(By.id("rdo2_1")).click();
    }
}
package com.pj.oil.util;

import com.pj.oil.config.PropertyConfiguration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;

@Component
public class EncodingUtil {
    private static final Logger LOGGER = LoggerFactory.getLogger(EncodingUtil.class);
    private static final int CHUNK_SIZE = 1000; // 청크 크기 설정

    private final PropertyConfiguration config;
    private final DateUtil dateUtil;

    private final String inputPathWithDate;
    private final String outputPathWithDate;

    public EncodingUtil(PropertyConfiguration config, DateUtil dateUtil) {
        this.config = config;
        this.dateUtil = dateUtil;
        this.inputPathWithDate = config.getDownloadFilepath() + dateUtil.getTodayDateString() + "/";
        this.outputPathWithDate = config.getOutFilepath() + dateUtil.getTodayDateString() + "/" + dateUtil.getTodayDateString() + "-";
    }

    public boolean convertFileEncoding(String inputFileName, String outputFileName) {
        String input = inputPathWithDate + inputFileName;
        String output = outputPathWithDate + outputFileName;
        LOGGER.info("[convertFileEncoding] input: {}, output: {}", input, output);
        Path inputPath = Paths.get(input);
        Path outputPath = Paths.get(output);

        // 디렉토리 확인 및 생성
        Path parentDir = outputPath.getParent();
        if (!Files.exists(parentDir)) {
            try {
                Files.createDirectories(parentDir);
            } catch (IOException e) {
                LOGGER.error("디렉토리 생성 중 에러 발생: {}", parentDir, e);
                return false;
            }
        }

        int attempt = 0;
        boolean encodingCompleted = false;
        while(attempt < 3 && !encodingCompleted) {

            try (BufferedReader reader = Files.newBufferedReader(inputPath, Charset.forName("Cp949"));
                 BufferedWriter writer = Files.newBufferedWriter(outputPath, StandardCharsets.UTF_8)) {

                List<String> buffer = new ArrayList<>(CHUNK_SIZE);
                reader.lines().forEach(line -> {
                    buffer.add(line);
                    if (buffer.size() == CHUNK_SIZE) {
                        flushBuffer(buffer, writer);
                    }
                });

                // 남은 데이터 처리
                flushBuffer(buffer, writer);
                encodingCompleted = true;
            } catch (IOException e) {
                LOGGER.error("파일 인코딩 변환 중 에러 발생: {}", e.getMessage(), e);
                attempt++;
                if(attempt >= 3) {
                    LOGGER.error("파일 인코딩 변환 최대 재시도 횟수 초과: {}", e.getMessage(), e);
                    break;
                }
            }
        }
        return encodingCompleted;
    }

    private void flushBuffer(List<String> buffer, BufferedWriter writer) {
        try {
            for (String line : buffer) {
                writer.write(line);
                writer.newLine();
            }
        } catch (IOException e) {
            LOGGER.error("버퍼 데이터 쓰기 중 에러 발생: {}", e.getMessage(), e);
        } finally {
            buffer.clear(); // 버퍼 초기화
        }
    }
}

ANSI로 다운로드 되는 파일을 UTF-8로 변환하는 작업을 했습니다.

아래는 실행 결과입니다.

728x90