점프투스프링부트 개선 | 웹 크롤링

Park JeaHyun·2023년 3월 7일

최근 테니스에 흥미가 생겨 꽤 자주 테니스를 치고 있다. 회사 테니스장은 꽤 편하게 예약할 수 있었지만 서울시에서 운영하는 테니스장 예약은 쉽지 않더라... 특히 예약 페이지가 너무 불편함~ 그래서 예약 페이지의 예약 현황을 자동으로 크롤링하는 아이디어가 떠올랐다.

목표

  • 스케줄링을 통해 보라매 테니스장의 예약 현황을 크롤링한다.
  • 크롤링된 데이터는 DB에 저장된다.

Coat Entity

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Coat {
    @Id
    Integer id;

    String name;

    Integer coatNumber;

    LocalDate date;

    String reservation;

    String link;

    LocalDateTime modifyDate;
}

Selenium

문제

Selenium 라이브러리를 그래이들에 추가하니깐 build가 실패하는 이슈가 생겼다. 메일을 전송하는 서비스 쪽에서 JavaMainSenderImpl을 만들지 못했다. 아마 메일 라이브러리와 Selenium 라이브러리가 충돌하는 것 같다.

implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.seleniumhq.selenium:selenium-java'

해결 방법

https://github.com/spring-projects/spring-boot/issues/33452
링크를 참고해서 아래처럼 수정하니 해결됐다. 정확한 원인은 알아봐야 할 것 같다.

implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation("org.seleniumhq.selenium:selenium-java") {
    exclude group: "com.sun.activation", module: "jakarta.activation"
}

테니스 예약 정보 크롤링 및 DB 저장

크롤링을 수행하는 전용 클래스를 만들었다.

  • 크롤링한 정보를 DB에 업데이트 해줘야 하기 때문에 CaotRepository 의존성을 추가
  • 로컬, 배포 환경에서 크롬 드라이버의 위치가 다르므로 설정 파일에서 드라이버 위치를 읽어옴
  • URL에 코트 별 ID 값이 다르므로 클래스 변수로 관리
  • 추후에 다양한 크롤링을 진행할 수도 있어서 크롬 옵션을 만드는 부분을 메서드로 분리
  • @Scheduled 를 통해 해당 메서드가 30분마다 수행되도록 구현
  • 배포 환경에서 예약 사이트를 접속하니 영어로 페이지가 보여졌다. 예약 홈페이지에 먼저 방문해서 언어를 한국어로 바꿔준다. 이후에는 계속 한국어로 페이지가 로딩됨.
  • 아래 html 형태 형태에 맞게 크롤링 코드를 작성하고 가져온 정보를 Coat 엔티티를 만듬
  • Coat 엔티티 리스트를 DB에 저장

@Slf4j
@Component
@RequiredArgsConstructor
public class WebDriverUtil {

    private final CoatRepository coatRepository;

    @Value("${webdriver.path}")
    private String WEB_DRIVER_PATH;

    private static final String WEB_DRIVER_ID = "webdriver.chrome.driver";
    private static final String RESERVATION_HOME_URL = "https://yeyak.seoul.go.kr/web/main.do";
    private static final String TENNIS_RESERVATION_POST_URL = "https://yeyak.seoul.go.kr/web/reservation/selectReservView.do?rsv_svc_id=";
    private static final List<String> COAT_ID_LIST = Arrays.asList(
            "S201030105206531192", "S201030105601087749", "S201030145802586611", "S210302233348803598",
            "S210302233656019242", "S210319185351345647", "S210319192158999179");
    private static final String YEBIGUN_HOME_URL = "https://www.yebigun1.mil.kr/dmobis/index_main.do";

    public ChromeOptions getDefaultOption() {
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--no-sandbox");
        options.addArguments("--disable-dev-shm-usage");
        return options;
    }

    @Scheduled(cron = "0 0/30 * * * ?") // 30분 마다
    public void getBoramaeCoatInfo() throws InterruptedException {
        log.info("reservation information of Boramae tennis coat crawling start");
        System.setProperty(WEB_DRIVER_ID, WEB_DRIVER_PATH);
        WebDriver driver = new ChromeDriver(getDefaultOption());

        driver.get(RESERVATION_HOME_URL);
        Thread.sleep(1000); // 브라우저 로딩 대기
        driver.manage().window().maximize();

        WebElement lang = driver.findElement(By.cssSelector(".language"));
        lang.click();
        WebElement kor = driver.findElement(By.cssSelector(".language a")); // 한국어로 변경
        kor.click();

		// 크롤링 로직 시작
        List<Coat> coatInfoList = new ArrayList<>();
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
        for (int i = 0; i < COAT_ID_LIST.size(); i++) {
            int coatNumber = i + 1;
            String coatId = COAT_ID_LIST.get(i);

            driver.get(TENNIS_RESERVATION_POST_URL + coatId); // n번 코트 예약 페이지로 이동
            Thread.sleep(1000); // 브라우저 로딩 대기

            WebElement pop_x = driver.findElement(By.className("pop_x")); // 팝업 닫기
            pop_x.click();

            List<WebElement> trList = driver.findElements(By.cssSelector(".tbl_cal tbody td"));
            for (WebElement element : trList) {
                String date = element.getAttribute("id");
                if (date.contains("calendar")) { // 존재하는 일자이면
                    date = date.substring(9); // 날짜만 파싱
                    WebElement num = element.findElement(By.className("num"));
                    if (num.getText().equals(" "))
                        continue; // 평일은 넘어감
                    coatInfoList.add(Coat.builder()
                            .id(Integer.parseInt(coatNumber + date))
                            .coatNumber(coatNumber)
                            .date(LocalDate.parse(date, formatter))
                            .reservation(num.getText())
                            .name("보라매")
                            .link(TENNIS_RESERVATION_POST_URL + coatId)
                            .modifyDate(LocalDateTime.now())
                            .build());
                }
            }
        }
		// 크롤링 로직 끝
        
        driver.close(); // 탭 닫기
        driver.quit(); // 브라우저 닫기

        coatRepository.saveAll(coatInfoList);
        log.info("reservation information of Boramae tennis coat crawling end");
    }
}

쿠키 저장 및 사용

간혹 크롤링하고 싶은 페이지가 로그인이 필요한 경우일 수 있다. 이때 웹브라우저가 인증되고 유효한 쿠키를 가지고 있으면 좋겠다고 생각했다.

로그인 과정은 웹 사이트에 맞게 구현하면 된다.

public class WebDriverUtil {

	...(생략)...

	// 쿠키 저장
    public void saveCookie(WebDriver driver) {
        driver.get(URL);

        File file = new File("Cookies.data");
        try {
            file.delete();
            file.createNewFile();
            FileWriter fileWriter = new FileWriter(file);
            BufferedWriter bw = new BufferedWriter(fileWriter);
            for (Cookie ck : driver.manage().getCookies()) {
                bw.write(ck.getName() + ";" + ck.getValue() + ";" + ck.getDomain() + ";" + ck.getPath() + ";"
                        + ck.getExpiry() + ";" + ck.isSecure());
                bw.newLine();
            }
            bw.close();
            fileWriter.close();
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }

	// 쿠키 사용
    public void getCookie(WebDriver driver) {
        try {
            File file = new File("Cookies.data");
            FileReader fileReader = new FileReader(file);
            BufferedReader br = new BufferedReader(fileReader);
            String strLine;
            while ((strLine = br.readLine()) != null) {
                StringTokenizer st = new StringTokenizer(strLine, ";");
                while (st.hasMoreTokens()) {
                    String name = st.nextToken();
                    String value = st.nextToken();
                    String domain = st.nextToken();
                    String path = st.nextToken();
                    Date expiry = null;
                    String val;
                    SimpleDateFormat dateFormat = new SimpleDateFormat("EEE MMM dd HH:mm:ss z yyyy", Locale.ENGLISH);
                    if (!(val = st.nextToken()).equals("null")) {
                        expiry = dateFormat.parse(val);
                    }
                    Boolean isSecure = new Boolean(st.nextToken()).booleanValue();
                    Cookie ck = new Cookie(name, value, domain, path, expiry, isSecure);
                    log.info(ck.toString());
                    driver.manage().addCookie(ck);
                }
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

참고

우분투 셀레니움 설치 방법

0개의 댓글