R Crawler 4

JavaScript 우회하기

Dr.Kevin 1/28/2018

JavaScript를 알아야 할까요? 웹크롤링을 하다 보면 분명히 응답 상태코드가 200으로 정상인데 찾는 HTML element가 없는 경우가 매우 많습니다. 이런 경우, JavaScript를 의심해볼 필요가 있습니다.

HTML을 보면 중간에 <script>라는 tag가 있고, 그 아래로 프로그래밍 코드 뭉치가 보이는 경우가 흔히 있습니다. 만약 우리가 찾는 HTML element<script>에 영향을 받으면 관련 HTML element가 뒤늦게 불려오기 때문에 비록 HTML Response이 정상이어도 찾는 HTML element가 응답 객체에 포함되지 않는 것입니다. (혹시 제가 잘못 기술한 부분이 있다면 피드백을 부탁 드립니다!)

위 문제를 해결하는 방법은 크게 2가지가 있습니다.

  1. 크롬의 사용자도구에서 ‘네트워크’ 탭에서 새로고침(F5)하여 원하는 데이터가 화면에 렌더링되는 시점을 포착하는 방식으로 관련 항목을 찾는다.

  2. RSelenium을 사용하여 원하는 코드를 얻는다.

두 번째 방법은 속도가 느리다는 단점도 있지만 이 포스팅 다음에 소개해드릴 예정이므로, 이번 포스팅에서는 첫 번째 방법을 사용하여 원하는 데이터를 수집해 보겠습니다.

# 필요 패키지를 불러옵니다.
library(httr)
library(rvest)
library(dplyr)
library(stringr)

이번 예제에서는 2017년 프로야구 타자 스탯을 수집해보도록 하겠습니다. KBReport로 이동하여 화면 상단에 여러 메뉴가 있습니다. 여기에서 선수기록을 클릭하면 선수 스탯이 테이블 형태로 출력됩니다. 데이터 조회 조건으로 아래와 같이 변경하였습니다.

  • 팀 : “팀-전체”
  • 포지션 : “포지션-전체”
  • 시즌범위 : “시즌 시작-2017”
  • TO : “종료 시즌-2017”
  • 정규/포스트 시즌 구분 : “정규시즌”
  • 분류1 : “분류-선택안함”
  • 타석수 : “타석수-전체”

위와 같이 설정한 후 우측에 있는 결과 버튼을 클릭하면 아래에 테이블 형태로 데이터가 출력됩니다. URL이 바뀌었으므로 바뀐 URLGET() 함수에 할당하여 실행시키면 정상 응답을 받을 수 있지만 찾고자 하는 HTML element는 없습니다.

다시 크롬으로 돌아가서 출력된 상태에서 크롬 개발자도구를 열고 Network 탭으로 이동한 다음 새로고침(F5)을 누릅니다. 매우 많은 항목들이 주르륵하고 생기는데, 이 때 주목해야 할 것은 개발자도구 위에서 세 번째 줄에 있는 메뉴입니다. 아마도 All이 선택되어 있을 것입니다. 이것을 XHR로 변경하면 항목이 크게 줄어듭니다.

XHRXML Http Request의 머릿글자로 AJAX 요청을 생성하는 JavaScript API라고 합니다[1]. 그리고 AJAXAsynchronos Javascript And XML의 머릿글자로 JavaScript와 XML을 의미한다고 합니다. 단순하게 요약하자면, JavaScript를 통해서 웹서버로부터 XML 데이터를 요청하는 것입니다[2].

저도 잘 모르는 걸 설명하려니 어렵네요. 아무튼 다시 크롬 개발자도구의 Network 탭으로 돌아가서, 세 번째 줄 메뉴에 있는 XHR로 이동하면 첫 번째 그림처럼 보일 것입니다.

이제 새로고침을 하면 두 번째 그림처럼 몇 가지 항목이 생성되는데 중간에 AJAX가 있고 POST 방식으로 요청한다는 것을 확인할 수 있습니다.

그리고 AJAX를 선택하면 오른쪽에서 Request와 Response에 관한 세부사항을 확인할 수 있는데요. General에서 Request URL을 복사하여 POST() 함수의 url인자에 할당합니다.

그리고 나서 Form Data가 보일 때까지 화면을 아래로 내린 다음 인자명과 인자값을 복사해서 POST() 함수의 body 인자에 리스트 형태로 할당해주면 됩니다.

# HTML 요청 합니다.
resp <- POST(url = "http://www.kbreport.com/leader/list/ajax",
             encode = "form",
             body = list(
               rows = 20,
               order = "oWAR",
               orderType = "DESC",
               year_from = 2017,
               year_to = 2017,
               gameType = "R",
               page = 1)
             )

# 응답 상태코드를 확인합니다.
status_code(resp)
## [1] 200

응답 상태코드가 200으로 정상입니다. 이제 크롬 개발자도구에서 Elements로 이동한 다음 데이터가 담긴 테이블의 HTML element를 찾습니다. 타겟 element를 찾는 방법은 이제 잘 아시겠죠? 해당 위치로 가서 마우스 오른쪽 버튼을 클릭한 후 검사(Inspect)를 선택하면 그 주변에서 찾을 수 있습니다. 이 웹페이지는 <table class="ltb-table responsive">로 되어 있군요.

# 원하는 태그가 있는지 확인합니다.
read_html(resp) %>% 
  html_nodes(css = "table.ltb-table")
## {xml_nodeset (1)}
## [1] <table class="ltb-table responsive">\n<tr>\n<th width="20px">#</th>\ ...
# html_table() 함수를 이용하여 쉽게 정리하고 데이터프레임으로 변환합니다.
read_html(resp) %>% 
  html_nodes(css = "table.ltb-table") %>% 
  html_table() %>% 
  as.data.frame()
##    X.   선수명 팀명 경기 타석 타수 안타 홈런 득점 타점 볼넷 삼진 도루
## 1   1     최정   SK  130  527  430  136   46   89  113   70  107    1
## 2   2  김재환* 두산  144  636  544  185   35  110  115   81  123    4
## 3   3   최형우  KIA  142  629  514  176   26   98  120   96   82    0
## 4   4   박건우 두산  131  543  483  177   20   91   78   41   64   20
## 5   5 로사리오 한화  119  510  445  151   37  100  111   50   61   10
## 6   6   나성범   NC  125  561  498  173   24  103   99   48  116   17
## 7   7   손아섭 롯데  144  667  576  193   20  113   80   83   96   25
## 8   8   김선빈  KIA  137  529  476  176    5   84   64   39   40    4
## 9   9 버나디나  KIA  139  621  557  178   27  118  111   41  112   32
## 10 10   박민우   NC  106  452  388  141    3   84   47   46   51   11
## 11 11   김하성 넥센  141  601  526  159   23   90  114   58   65   16
## 12 12 스크럭스   NC  115  518  437  131   35   91  111   65  134    4
## 13 13     러프 삼성  134  591  515  162   31   90  124   60  107    2
## 14 14   구자욱 삼성  144  647  564  175   21  108  107   63  138   10
## 15 15   안치홍  KIA  132  545  487  154   21   95   93   43   70    7
## 16 16   한동민   SK  103  414  350  103   29   64   73   46   79    2
## 17 17   서건창 넥센  139  615  539  179    6   87   76   67   68   15
## 18 18   나지완  KIA  137  551  459  138   27   85   94   62  105    1
## 19 19   이대호 롯데  142  608  540  173   34   73  111   50   84    1
## 20 20   박용택   LG  138  596  509  175   14   83   90   72   88    4
##    BABIP  타율 출루율 장타율   OPS  wOBA  WAR
## 1  0.316 0.316  0.427  0.684 1.111 0.442 7.30
## 2  0.385 0.340  0.429  0.603 1.032 0.427 7.22
## 3  0.362 0.342  0.450  0.576 1.026 0.430 7.20
## 4  0.390 0.366  0.424  0.582 1.006 0.424 7.04
## 5  0.324 0.339  0.414  0.661 1.075 0.436 5.75
## 6  0.413 0.347  0.415  0.584 0.999 0.416 5.64
## 7  0.374 0.335  0.420  0.514 0.934 0.398 5.60
## 8  0.393 0.370  0.420  0.477 0.897 0.391 5.19
## 9  0.354 0.320  0.373  0.540 0.913 0.380 5.01
## 10 0.408 0.363  0.441  0.472 0.913 0.404 4.92
## 11 0.306 0.302  0.376  0.513 0.889 0.375 4.76
## 12 0.353 0.300  0.402  0.595 0.997 0.411 4.70
## 13 0.344 0.315  0.396  0.569 0.965 0.402 4.59
## 14 0.371 0.310  0.383  0.527 0.910 0.382 4.28
## 15 0.332 0.316  0.373  0.513 0.886 0.374 4.23
## 16 0.302 0.294  0.396  0.614 1.010 0.414 3.92
## 17 0.367 0.332  0.403  0.429 0.832 0.368 3.90
## 18 0.332 0.301  0.405  0.534 0.939 0.398 3.87
## 19 0.327 0.320  0.391  0.533 0.924 0.388 3.86
## 20 0.387 0.344  0.424  0.479 0.903 0.391 3.67

위와 같이 하면 아주 간단하게 원하는 데이터를 수집할 수 있습니다. 그런데 지금 우리는 겨우 20명의 타자 데이터만 수집하였습니다. 화면 맨 아래에 보면 페이지를 이동하는 네비게이션이 있습니다. 마지막 페이지로 이동해보니 모두 15개 페이지가 있음을 확인할 수 있었습니다.

페이지 이동은 어떻게 처리하면 좋을까요? 왠지 앞에서 POST() 함수의 body 인자를 설정할 때 page = 1로 되어 있던 기억이 납니다. 그럼 여기에서 1 대신에 2를 넣으면 2페이지가 되겠죠? 즉, page에 할당되는 값을 1부터 15까지 순환하며 데이터를 수집하면 됩니다.

같은 명령을 반복실행하려면 for() 함수를 사용하면 됩니다. 그리고 각 페이지별로 수집한 데이터를 데이터 프레임으로 만든 후 rbind() 함수를 이용하여 행 기준으로 추가하면 간단하게 해결됩니다.

순환함수 실행에 앞서 최종 결과 객체인 hitterStat을 빈 데이터 프레임으로 만들어 줍니다.

# 최종 결과 객체를 먼저 생성합니다.
hitterStat <- data.frame()

# 총 15 페이지를 순환 실행하여 수집합니다.
for (i in 1:15) {
  resp <- POST(url = "http://www.kbreport.com/leader/list/ajax",
               encode = "form",
               body = list(
                 row = 20,
                 order = "oWAR",
                 orderType = "DESC",
                 year_from = 2017,
                 year_to = 2017,
                 gameType = "R",
                 page = i)
               )
  
  # 테이블에 있는 데이터를 데이터프레임에 할당합니다.
  df <- read_html(resp) %>% 
    html_nodes(css = "table.ltb-table") %>% 
    html_table() %>% 
    as.data.frame()
  
  # 새로 만든 데이터테이블을 결과 객체에 행 기준으로 추가합니다
  hitterStat <- rbind(hitterStat, df)
}

모든 데이터를 다 수집하였으니 이제 데이터 정제과정을 거치겠습니다. 거의 모든 데이터가 숫자 데이터이므로 번거로운 작업은 그렇게 많지 않을 것 같습니다.

# 데이터 테이블 구조를 확인합니다.
str(hitterStat)
## 'data.frame':    292 obs. of  20 variables:
##  $ X.    : int  1 2 3 4 5 6 7 8 9 10 ...
##  $ 선수명: chr  "최정" "김재환*" "최형우" "박건우" ...
##  $ 팀명  : chr  "SK" "두산" "KIA" "두산" ...
##  $ 경기  : int  130 144 142 131 119 125 144 137 139 106 ...
##  $ 타석  : int  527 636 629 543 510 561 667 529 621 452 ...
##  $ 타수  : int  430 544 514 483 445 498 576 476 557 388 ...
##  $ 안타  : int  136 185 176 177 151 173 193 176 178 141 ...
##  $ 홈런  : int  46 35 26 20 37 24 20 5 27 3 ...
##  $ 득점  : int  89 110 98 91 100 103 113 84 118 84 ...
##  $ 타점  : int  113 115 120 78 111 99 80 64 111 47 ...
##  $ 볼넷  : int  70 81 96 41 50 48 83 39 41 46 ...
##  $ 삼진  : int  107 123 82 64 61 116 96 40 112 51 ...
##  $ 도루  : int  1 4 0 20 10 17 25 4 32 11 ...
##  $ BABIP : chr  "0.316" "0.385" "0.362" "0.39" ...
##  $ 타율  : chr  "0.316" "0.34" "0.342" "0.366" ...
##  $ 출루율: chr  "0.427" "0.429" "0.45" "0.424" ...
##  $ 장타율: chr  "0.684" "0.603" "0.576" "0.582" ...
##  $ OPS   : chr  "1.111" "1.032" "1.026" "1.006" ...
##  $ wOBA  : chr  "0.442" "0.427" "0.43" "0.424" ...
##  $ WAR   : chr  "7.3" "7.22" "7.2" "7.04" ...

데이터 프레임 구조를 확인해보니 총 292명의 선수, 20개의 컬럼으로 구성되어 있습니다. 첫 번째 컬럼명이 현재 X.로 되어 있는데 이것을 순위로 변경해주는 것이 좋겠습니다.

# 첫 번째 컬럼명을 "순위"로 변경합니다.
colnames(hitterStat)[1] <- "순위"

14~20 번째 컬럼이 문자 벡터로 되어 있습니다. 이 부분을 보정하기 위해 먼저 요약정보를 확인하고 필요한 조치를 취하도록 하겠습니다.

# 14번째 컬럼 요약정보를 확인합니다.
table(hitterStat[14])
## 
##     -     0 0.000   0.1 0.143  0.15 0.159 0.167 0.179 0.182 0.185 0.191 
##    11    21     8     1     2     1     1     2     1     3     1     1 
##   0.2 0.200 0.205 0.211 0.214 0.218 0.222 0.231 0.233 0.238 0.242 0.244 
##     3     1     1     1     1     1     1     1     1     1     1     1 
## 0.246  0.25 0.250 0.252 0.257 0.259  0.26 0.263 0.264 0.267 0.268 0.269 
##     1     6     2     1     1     1     1     1     1     3     1     1 
## 0.272 0.273 0.275 0.276 0.277 0.279  0.28 0.282 0.283 0.285 0.286 0.287 
##     1     2     2     2     1     2     1     1     2     1     1     1 
## 0.288 0.289  0.29 0.290 0.291 0.292 0.293 0.294 0.297 0.299   0.3 0.300 
##     2     3     1     1     2     3     2     5     2     1     5     3 
## 0.301 0.302 0.303 0.305 0.306 0.308  0.31 0.311 0.313 0.314 0.316 0.317 
##     1     2     1     2     4     1     1     1     4     2     2     2 
## 0.318  0.32 0.321 0.323 0.324 0.326 0.327 0.328 0.329  0.33 0.331 0.332 
##     1     1     3     1     6     3     3     2     1     2     1     3 
## 0.333 0.334 0.335 0.337 0.338  0.34 0.340 0.341 0.342 0.343 0.344 0.346 
##    16     1     2     1     1     2     1     1     1     1     1     3 
## 0.347 0.348  0.35 0.351 0.352 0.353 0.354 0.355 0.356 0.358 0.359 0.362 
##     2     3     1     2     1     6     3     1     1     3     2     4 
## 0.363 0.364 0.365 0.367  0.37 0.371 0.374 0.375 0.378 0.381 0.383 0.384 
##     2     2     1     2     1     2     1     1     1     2     1     1 
## 0.385 0.387 0.388  0.39 0.393 0.394   0.4 0.408  0.41 0.413 0.417 0.418 
##     1     2     1     1     1     1     2     1     1     1     1     1 
## 0.423 0.429 0.455 0.474   0.5   0.6  0.75     1 1.000 
##     1     3     1     1     5     1     1     3     1

15번째 페이지에서 보여지는 것처럼 일부 선수들의 데이터가 하이픈(-)로 되어 있습니다. 이것 때문에 숫자가 아닌 문자 데이터로 강제전환된 것입니다. 이것을 제거하고 숫자 벡터로 변환하겠습니다.

# 데이터 중 "-"를 제거합니다.
hitterStat[hitterStat == "-"] <- NA

# 14~20 번째 컬럼을 숫자 벡터로 변환합니다.
hitterStat[, 14:20] <- data.matrix(hitterStat[, 14:20])

# 데이터테이블 구조를 다시 확인합니다.
str(hitterStat)
## 'data.frame':    292 obs. of  20 variables:
##  $ 순위  : int  1 2 3 4 5 6 7 8 9 10 ...
##  $ 선수명: chr  "최정" "김재환*" "최형우" "박건우" ...
##  $ 팀명  : chr  "SK" "두산" "KIA" "두산" ...
##  $ 경기  : int  130 144 142 131 119 125 144 137 139 106 ...
##  $ 타석  : int  527 636 629 543 510 561 667 529 621 452 ...
##  $ 타수  : int  430 544 514 483 445 498 576 476 557 388 ...
##  $ 안타  : int  136 185 176 177 151 173 193 176 178 141 ...
##  $ 홈런  : int  46 35 26 20 37 24 20 5 27 3 ...
##  $ 득점  : int  89 110 98 91 100 103 113 84 118 84 ...
##  $ 타점  : int  113 115 120 78 111 99 80 64 111 47 ...
##  $ 볼넷  : int  70 81 96 41 50 48 83 39 41 46 ...
##  $ 삼진  : int  107 123 82 64 61 116 96 40 112 51 ...
##  $ 도루  : int  1 4 0 20 10 17 25 4 32 11 ...
##  $ BABIP : num  0.316 0.385 0.362 0.39 0.324 0.413 0.374 0.393 0.354 0.408 ...
##  $ 타율  : num  0.316 0.34 0.342 0.366 0.339 0.347 0.335 0.37 0.32 0.363 ...
##  $ 출루율: num  0.427 0.429 0.45 0.424 0.414 0.415 0.42 0.42 0.373 0.441 ...
##  $ 장타율: num  0.684 0.603 0.576 0.582 0.661 0.584 0.514 0.477 0.54 0.472 ...
##  $ OPS   : num  1.11 1.03 1.03 1.01 1.07 ...
##  $ wOBA  : num  0.442 0.427 0.43 0.424 0.436 0.416 0.398 0.391 0.38 0.404 ...
##  $ WAR   : num  7.3 7.22 7.2 7.04 5.75 5.64 5.6 5.19 5.01 4.92 ...
# 처음 10행만 미리보기 합니다.
head(x = hitterStat, n = 10L)
##    순위   선수명 팀명 경기 타석 타수 안타 홈런 득점 타점 볼넷 삼진 도루
## 1     1     최정   SK  130  527  430  136   46   89  113   70  107    1
## 2     2  김재환* 두산  144  636  544  185   35  110  115   81  123    4
## 3     3   최형우  KIA  142  629  514  176   26   98  120   96   82    0
## 4     4   박건우 두산  131  543  483  177   20   91   78   41   64   20
## 5     5 로사리오 한화  119  510  445  151   37  100  111   50   61   10
## 6     6   나성범   NC  125  561  498  173   24  103   99   48  116   17
## 7     7   손아섭 롯데  144  667  576  193   20  113   80   83   96   25
## 8     8   김선빈  KIA  137  529  476  176    5   84   64   39   40    4
## 9     9 버나디나  KIA  139  621  557  178   27  118  111   41  112   32
## 10   10   박민우   NC  106  452  388  141    3   84   47   46   51   11
##    BABIP  타율 출루율 장타율   OPS  wOBA  WAR
## 1  0.316 0.316  0.427  0.684 1.111 0.442 7.30
## 2  0.385 0.340  0.429  0.603 1.032 0.427 7.22
## 3  0.362 0.342  0.450  0.576 1.026 0.430 7.20
## 4  0.390 0.366  0.424  0.582 1.006 0.424 7.04
## 5  0.324 0.339  0.414  0.661 1.075 0.436 5.75
## 6  0.413 0.347  0.415  0.584 0.999 0.416 5.64
## 7  0.374 0.335  0.420  0.514 0.934 0.398 5.60
## 8  0.393 0.370  0.420  0.477 0.897 0.391 5.19
## 9  0.354 0.320  0.373  0.540 0.913 0.380 5.01
## 10 0.408 0.363  0.441  0.472 0.913 0.404 4.92

이제까지 작업한 파일을 나중에 사용하기 위해 xlsx 파일로 저장하겠습니다. xlsx로 저장하려면 xlsx 패키지의 write.xlsx() 함수를 사용합니다.

# 필요 패키지를 불러옵니다.
library(xlsx)
## Loading required package: rJava

## Loading required package: xlsxjars
# 저장할 폴더를 지정합니다. 있는지 확인하고 없으면 새로 만듭니다.
newDir <- "./data"
if (dir.exists(paths = newDir) == FALSE) {
  dir.create(path = newDir)
}

# xlsx 파일로 저장합니다.
write.xlsx(x = hitterStat, 
           file = "./data/2017_Baseball_hitter_stat.xlsx",
           row.names = FALSE)

이상으로 JavaScript를 우회하여 웹데이터를 수집하는 방법에 대해 알아봤습니다. 다음 포스팅에서는 RSelenium을 이용하여 데이터를 수집하는 R Crawler 마지막 부분을 다루도록 하겠습니다.

[1] XHR에 대한 자세한 내용은 여기를 확인해보시기 바랍니다

[2] AJAX에 대한 자세한 내용은 여기를 참조하시기 바랍니다

Written on January 28, 2018