본문 바로가기

데이터 사이언스/분석 - EDA - 시각화

R로 하는 서울특별시 문화공간 분석 - 2편 : RSelenium을 이용한 구글 맵 크롤링

키워드: R마크다운, R leaflet, R 지도, R 분석, R 시각화, R 셀레니움, R 크롤링, R 구글 리뷰

 

cultures-2-.html
1.57MB

 

 

 


목차
1) Intro.
2) R셀레니움을 이용한 구글 맵 크롤링 
3) 데이터 확인 및 시각화

1) Intro.

 

지난 시간에 이어 오늘도 서울특별시 문화공간에 대한 분석을 계속 진행하고자 하는데요.
오늘은 지난 시간 분석한 데이터를 기반으로, 구글 맵에서 해당 장소들에 대한 정보를 크롤링하는 시간을 가져보도록 하겠습니다.


2) R셀레니움을 이용한 구글 맵 크롤링 


하나. 패키지 로딩 및 R셀레니움 활성화

 

먼저 필요한 패키지를 로딩하겠습니다.

 

크롤링을 할 수 있는 패키지는 여러가지가 있지만, 오늘은 R 셀레니움을 이용하겠습니다. 셀레니움은 파이썬에서 크롤링할때도 아주 자주 사용되는 툴입니다. 처음에는 어려워보이지만 조금만 해 보면 로직이 상당히 단순합니다.

 

require(RSelenium) #R 셀레니움 패키지

require(ggplot2)
require(ggthemes)
require(paletteer)
require(plotly)
require(dplyr)
require(leaflet)
require(leafletCN)
require(extrafont)

문제는 셀레니움은 그냥 패키지만 설치하면 사용할 수 있는 게 아니라는 겁니다. 간단한 몇 가지 세팅들이 필요합니다. 아래의 링크들을 참조하시면 됩니다.

 

https://sancj.tistory.com/62
https://kkokkilkon.tistory.com/104
https://codechacha.com/ko/selenium-chromedriver-version-error/
https://stackoverflow.com/questions/62323368/rselenium-sendkeystoactiveelement-function-throws-unknown-command-error

 

간단히 설명하자면,

 

  1. 최신 버젼의 java를 설치한다. https://java.com/ko/download/ie_manual.jsp?locale=ko
  2. java가 설치된 링크를 cmd에서 열고, java를 원하는 port에서 실행시킨다. (링크들 참조)
  3. R셀레니움을 설치 후, remoteDriver() 함수를 이용해 2에서 실행시킨 local port로 접속한다.
  4. 뼈빠지게 크롤링을 한다.

cmd 창을 이용한 java port 열기

     

아무튼 해당 작업을 완료하고 나서 다음의 함수를 입력하면 크롬 창이 열립니다. 저는 위의 2.에서 포트값을 4445로 열었기 때문에 port 인자에 4445L을 입력했습니다. 본인이 여신 포트로 넣으시면 됩니다. 브라우져는 크롬을 사용했습니다.

rsserv = remoteDriver(remoteServerAddr = 'localhost', port = 4445L, browserName = 'chrome')
rsserv$open()

 

해당 작업을 완료하면 다음과 같이 크롬 창이 열립니다.  


둘. 구글 맵 크롤링

 

이제 본격적으로 크롤링을 진행해 보도록 하겠습니다. 셀레니움의 사용법에 대해서는 다른 블로그에 좋은 설명들이 많이 나와 있지만, 여기서 다시 한 번 코드를 한 줄 한 줄 살펴보면서 어떻게 크롤링을 하는지 알아보겠습니다.

 

먼저 전체 코드는 다음과 같습니다.

 

 

results <- vector('list', length = nrow(cultures)) #결과물 저장할 리스트 생성

for (i in 1:nrow(cultures)){
  
address <- cultures$문화공간명[i]
  
  #검색
  rsserv$navigate('https://www.google.com') #구글 페이지로 이동
  Sys.sleep(0.7)
  search <- rsserv$findElement(using = 'class', "gLFyf") #검색창으로 이동
  Sys.sleep(0.7)
  search$sendKeysToElement(list(address, key = 'enter')) #미리 입력한 장소명 검색
  Sys.sleep(0.7)
  
  
  
  #리뷰 찾기
  test = rsserv$findElement(using = 'css selector', 'body')$getElementText()
  
  if (stringr::str_detect(test, 'Google 리뷰') == TRUE){
    
      revloc = rsserv$findElement(using = 'class', "hqzQac") #Google 리뷰 보기 창 열기
      Sys.sleep(0.7)
      revloc$clickElement() #클릭
      Sys.sleep(0.7)
      loc = rsserv$findElement(using = 'class',
                               value = 'review-score-container') #스코어 기록된 곳으로
      Sys.sleep(0.7)
      score = as.numeric(substr(loc$getElementText(), start = 1, stop = 3)) #스코어 값 반환
      Sys.sleep(0.7)
      
      #리뷰개수
      startloc = stringr::str_locate(loc$getElementText(), '리뷰')[2] + 2
      stoploc = stringr::str_locate(loc$getElementText(), '개')[1] - 1
      
      numrev = as.numeric(gsub(",", "", substr(loc$getElementText(), 
                                               start = startloc, 
                                               stop = stoploc))
                          ) #리뷰개수 반환
      
      print(paste(address,
                  "score:", score, ", ",
                  "number of reviews:", numrev))
      
      results[[i]] <- c(score, numrev)
      Sys.sleep(0.7)
      
    } else {
    
      print(paste(address,
                  "score: NA", ", ",
                  "number of reviews: NA"))
      results[[i]] <- c('NA', 'NA')
      Sys.sleep(0.7)
      
    }

}

복잡해 보이지만 한 줄 한 줄 살펴보면 별 거 아닙니다. 한 번 확인해 봅시다.

 

첫 줄은 그냥 크롤링 저장하는 리스트 만드는 줄이니 생략하겠습니다. for문은 주어진 cultures 데이터의 전체 행 개수만큼 반복됩니다. for문의 첫째줄도 그냥 i번째 문화공간 장소명을 가져오는 심플한 코드이니 생략합니다. 중요한 건 그 다음부터입니다.

 


a) 아까 rsserv라는 object를 열었습니다. 이 object와 ’$’를 통해 열린 크롬 창에서 여러가지 작업을 수행 가능합니다. 먼저 navigate을 이용해 구글 홈페이지로 이동하겠습니다.

rsserv$navigate('https://www.google.com')

 

 

b) 이제 findElement 함수를 이용해 우리가 미리 저장한 address(장소명) 값을 검색을 해야 합니다. 열린 크롬 창에서 개발자 도구(F12)를 엽니다. 왜 개발자 도구가 필요할까요? 하단 이미지에서 확인할 수 있듯이 우리는 저 검색창에다 장소명 값을 입력을 해야 합니다. 눈으로 보면서 마우스로 클릭하고 키보드로 입력할 수 있는 사람과 달리, 컴퓨터는 검색창이 가지고 있는 고유한 값을 찾아줘야 행동을 수행할 수 있습니다.

개발자 도구를 이용해 원하는 것 찾기

개발자 도구 좌측 상단에 마우스 모양의 버튼을 클릭하고, 웹페이지의 원하는 부분(이 경우 검색창)으로 마우스 커서를 가져가면 우리가 원하는 고유한 값을 찾아줍니다. 이 때 검색창이 가지고 있는 고유한 값들이 여러가지가 있습니다. 다음 중 하나의 적절한 값을 찾으면 됩니다.  

 

“xpath”, “css selector”, “id”, “name”, “tag name”, “class name”, “link text”, “partial link text”   이미지에서 확인할 수 있듯이, 저는 class에 gLFyf가 포함되어 있는 line을 찾았습니다. 이제 findElement를 이용해 해당 값을 찾아냅니다. (분석 목적, 페이지 특성 등에 따라 이는 얼마든지 바뀔 수 있습니다.)

search <- rsserv$findElement(using = 'class', "gLFyf") #검색창으로 이동

 

 

c) 그 다음으로 findElement를 이용해 선택한(혹은 찾아낸) 검색창에서, 미리 지정한 address 값을 겁색합니다. sendKeysToElement는 찾아낸 element에 명령을 보냅니다. address를 입력하고 enter하라는 뜻입니다.

search$sendKeysToElement(list(address, key = 'enter')) #미리 입력한 장소명 검색

 

 

d) 다음으로는 해당 페이지의 모든 소스를 다 가져오는 단계입니다. 왜 이것이 필요할까요? 우리가 가진 데이터의 모든 문화공간들이 구글 리뷰가 입력이 되어 있지는 않았기 때문입니다. 이 경우 하단의 코드가 정상적으로 작동하지 않기 때문에, 페이지 소스에서 리뷰 정보가 포함되어 있는지 if문을 통해 test하는 것이 필요합니다. 개발자 도구를 통해 확인해 보니, 리뷰가 있는 경우는 페이지에 ’Google 리뷰’라는 단어가 꼭 포함되어 있었습니다.

test = rsserv$findElement(using = 'css selector', 'body')$getElementText() #페이지 소스의 모든 텍스트 값을 가져옴

if (stringr::str_detect(test, 'Google 리뷰') == TRUE) #단어 포함되어 있는지 stringr 패키지의 str_detect함수 사용해 확인

 

 

e) 이제 원하는 값을 찾아보겠습니다. 구글 리뷰 보기 창의 class 값을 찾고, 우리가 원하는 정보가 저장되어 있는 review-score-container 값을 위의 logic과 동일하게 findElement 함수를 이용해 찾습니다. clickElement는 findElement를 이용해 찾은 값을 마우스 클릭해주는 함수입니다.

revloc = rsserv$findElement(using = 'class', "hqzQac") #Google 리뷰 보기 창 열기
revloc$clickElement() #클릭
loc = rsserv$findElement(using = 'class',
                         value = 'review-score-container') #스코어 기록된 곳으로

 

 

f) review-score-container에서 우리가 원하는 값은 두 가지입니다. 별점 평균(0~5 사이)와 리뷰 개수입니다. 리뷰 개수는 서울 시민들이 어느 정도 해당 공간을 방문하는지에 대한 추정치로 사용할 수 있을 것으로 예상됩니다. 그렇다면 review-score-container가 어떠한 text를 담고 있는지 확인해 볼까요? getElementText() 함수를 이용해봅시다.

loc$getElementText()

 

 

먼저 score입니다. 보시다시피, string의 1~3번째 글자가 해당 정보를 포함하고 있네요. substr 함수를 이용하면 1~3번째 글자만 가져올 수 있습니다(파이썬에서는 “:” 인덱싱으로 조금 더 편하게 할 수 있는 것으로 알고 있습니다.)

score = as.numeric(substr(loc$getElementText(), start = 1, stop = 3)) #스코어 값 반환

다음으로 리뷰 개수입니다. 리뷰 개수의 경우는 그 위치가 고정되어 있지 않습니다. 리뷰 개수에 따라 스트링의 길이가 달라질 테니까요(예. 리뷰 150개, 리뷰 3개, 리뷰 1238개 등). 따라서 stringr::string_locate()를 이용하겠습니다(stringr 패키지는 사랑입니다…).


보시면 ’리뷰 150개’라는 스트링에서 숫자 150이 시작되는 부분은 “리뷰” 위치 다음다음입니다(공백 포함). 그 숫자가 끝나는 부분은 “개”의 바로 직전입니다. 이를 이용해 숫자가 위치한 부분의 인덱스를 만들어줍시다.


마지막으로, 100의 자리가 넘어가기 시작하면 comma가 붙습니다(예. 15,230). 이렇게 되면 numeric으로 변환이 안 되기 때문에 gsub()를 이용해서 comma를 제거해줍시다.

startloc = stringr::str_locate(loc$getElementText(), '리뷰')[2] + 2
stoploc = stringr::str_locate(loc$getElementText(), '개')[1] - 1
          
numrev = as.numeric(gsub(",", "", substr(loc$getElementText(), 
                                         start = startloc, 
                                         stop = stoploc))
                    ) #리뷰개수 반환

 

 

g) 이제 다 끝났습니다. for문에서 결과값을 확인할 수 있도록 print해주고, results 리스트의 i번째 위치에 저장하겠습니다. 만약 아까 페이지 소스 테스트를 통과하지 못한 경우는 else문을 통해 ’NA’값을 넣어줍니다.

print(paste(address,
                      "score:", score, ", ",
                      "number of reviews:", numrev))
          
          results[[i]] <- c(score, numrev)
          
        } else {
          
          print(paste(address,
                      "score: NA", ", ",
                      "number of reviews: NA"))
          results[[i]] <- c('NA', 'NA')
          
        }

 

 

h) 마지막으로 하나 중요한 게 있습니다. 중간에 Sys.sleep라는 라인이 들어가 있는데요. 만약 이게 들어가 있지 않으면 인터넷 로딩 속도보다 코드가 돌아가는 속도가 훨씬 빨라서, 페이지가 아직 로딩이 안 됐는데 다음 findElement() 함수가 돌아가다가 원하는 element가 없다는 에러를 뱉습니다. 그래서 중간중간에 임의로 텀을 주는 라인입니다. 본인의 인터넷 환경이나 로딩하는 페이지의 속도 등에 따라 각자 알맞게 조정하시면 됩니다(혹시 예전에 스타크래프트 1 유즈맵 만들어보신 분들은 wait 함수 생각하시면 됨)  

 

 


3) 데이터 확인 및 시각화

이제 크롤링 작업이 마무리 된 리스트를 데이터프레임으로 변환 후, NA값을 잠시 0으로 바꿉니다. 위에서 보셨듯이, NA를 직접 넣지 않고 string으로 된 ’NA’를 넣었습니다. 이 상태에서는 numeric으로 변환이 안 되기 때문에 그렇게 처리했습니다. numeric으로 변환 후, score는 다시 진짜 NA로, numrev는 어차피 실제로 구글 페이지에서 없었기 때문에 0으로 impute합니다.

res_df <- do.call(rbind.data.frame, results)
colnames(res_df) = c('scores', 'num_reviews')




cultures <- cbind(cultures, res_df)


cultures$num_reviews[cultures$num_reviews == 'NA'] <- 0
cultures$scores[cultures$scores == 'NA'] <- 0

cultures$num_reviews <- as.numeric(cultures$num_reviews)
cultures$scores <- as.numeric(cultures$scores)


#score가 0인 경우는 NA로 전환
cultures$scores[cultures$scores == 0] <- NA

 

이제 본격적으로 시각화를 해 볼까요? 먼저 score값이 어떻게 분포되어 있는지 확인해봅시다.

summary(cultures$scores)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.    NA's 
##   1.000   4.000   4.200   4.202   4.400   5.000     265
head(cultures %>% select(문화공간명, 장르분류명, scores, num_reviews))
##            문화공간명 장르분류명 scores num_reviews
## 1 신도림 오페라하우스     공연장    4.2           9
## 2      신촌문화발전소     공연장    4.7          17
## 3            서울광장     공연장    4.2        2818
## 4          오류아트홀     공연장    4.5          63
## 5        정효아트센터     공연장     NA           0
## 6     구로 꿈나무극장     공연장     NA           0

측정되지 않은 결측치가 265개나 있습니다. 아마 네이버 맵이나 다음 맵으로 했으면 더 정확한 값들을 얻을 수 있지 않았을까 하네요. Median과 Mean 값이 거의 차이가 나지 않습니다. 아마 대부분의 유저들이 나쁜 리뷰를 남기는 경우는 없었기 때문이 아닐까 하네요. 3~5점 정도의 점수는 다들 주는 것 같습니다(착한 국민 대한민국 국민).

 

 

플롯으로 한 번 확인해 보겠습니다.

ggplot(data = cultures,
               aes(x = scores))+
          geom_histogram(alpha = 0.5,
                         fill = '#7B00FF',
                         color = NA)+
          ggthemes::theme_stata()

역시 4~5점 사이에 대다수의 데이터가 몰려 있는 것을 확인할 수 있었습니다. 이런 경우 사실 각 문화장소에 대해 좋은 평가지표가 된다고 하기는 어렵죠(다들 차이가 거의 없으니…). 어쨌든 조금 더 확인해 봅시다.

 

다음은 장소 타입 별 플롯입니다.

ggplot(data = cultures,
                       aes(x = scores,
                           fill = 장르분류명))+
                  geom_histogram(alpha = 0.5,
                                 color = NA,
                                 position = 'dodge')+
                  ggthemes::theme_stata()+
                  scale_color_paletteer_d("miscpalettes::berry")+
                  scale_x_continuous(breaks = seq(0,5, 0.25))+
                  theme(axis.text.x = element_text(angle = 315, hjust = 0.1),
                        axis.title  = element_text(hjust = 0.5, family = 'MapoPeacefull'))+
                  facet_wrap(~장르분류명)+
                  ggtitle('공간 분류에 따른 scores의 분포')

역시 큰 차이는 없습니다.

 

 

다음으로는 지도에 표시해보겠습니다. 결측치 row들은 제외하고 plot하겠습니다.

cultures_nozero <- cultures[cultures$num_reviews != 0, ]
cultures_nozero$num_reviews_log <- log(cultures_nozero$num_reviews)

pal <- colorNumeric(palette = as.character(paletteer_c("pals::warmcool", n = 5, direction = -1)), 
                    domain  = cultures_nozero$scores)

seoul_leaf <- leaflet(cultures_nozero) %>% 
  addTiles() %>% 
  setView(lng = 126.97,
          lat = 37.542,
          zoom = 11) %>% 
  addProviderTiles('CartoDB.Positron') %>% 
  addCircleMarkers(data = cultures_nozero %>% 
                     mutate(pop = paste0('공간명 : ', 문화공간명,
                                         '<br> 분류 : ', 장르분류명,
                                         '<br> Scores : ', scores)),
                   popup = ~pop,
                   lng = ~lon, lat = ~lat, color = ~pal(scores),
                   radius = 3) %>% 
  addLegend('bottomright', pal = pal, values = ~scores,
            title = 'Scores', opacity = 1)

seoul_leaf          

상단 html 파일에서 interact 가능

 

 


다음으로는 리뷰수의 분포입니다.

ggplot(data = cultures_nozero,
              aes(x = num_reviews))+
  geom_histogram(alpha = 0.5,   
                 fill = '#7B00FF',
                 color = NA,    
                 bins = 100)+
  ggthemes::theme_stata()
#공간별
ggplot(data = cultures_nozero,
              aes(x = num_reviews,
                  fill = 장르분류명))+
  geom_histogram(alpha = 0.5,
                 color = NA,
                 position = 'dodge')+
  ggthemes::theme_stata()+
  scale_fill_paletteer_d("ggsci::springfield_simpsons")+
  scale_x_continuous(breaks = seq(0, 40000, 5000))+
  theme(axis.text.x = element_text(angle = 315, hjust = 0.1))+
  facet_wrap(~장르분류명)

 

이 또한 적게 관측된 경우가 많았으나, scores 보다는 어느 정도 분포가 넓게 되어 있는 것을 확인할 수 있습니다. 우편향되어 있는 분포는 log변환하면 조금 더 차이를 쉽게 확인할 수 있습니다.

ggplot(data = cultures_nozero,
      aes(x = num_reviews_log,
          fill = 장르분류명))+
  geom_histogram(alpha = 0.5,
                 color = NA,
                 position = 'dodge')+
  ggtitle('NA를 제외한 log(리뷰수) 분포')+
  ggthemes::theme_stata()+
  scale_color_paletteer_d("miscpalettes::berry")+
  scale_x_continuous(breaks = seq(0,9, 1))+
  theme(axis.text.x = element_text(angle = 315, hjust = 0.1),
        axis.title  = element_text(hjust = 0.5, family = 'MapoPeacefull'))+
  facet_wrap(~장르분류명)

 

아무래도 공연장이 가장 많은 방문자가 있었고 분포도 고르네요. 지도 상에서 확인해봅시다.

 

pal <- colorNumeric(palette = as.character(paletteer_c("pals::warmcool", n = 10, direction = -1)), 
                    domain  = cultures_nozero$num_reviews_log)

seoul_leaf <- leaflet(cultures_nozero) %>% 
  addTiles() %>% 
  setView(lng = 126.97,
          lat = 37.542,
          zoom = 11) %>% 
  addProviderTiles('CartoDB.Positron') %>% 
  addCircleMarkers(data = cultures_nozero %>% 
                     mutate(pop = paste0('공간명 : ', 문화공간명,
                                         '<br> 분류 : ', 장르분류명,
                                         '<br> Number of Reviews : ', num_reviews)),
                   popup = ~pop,
                   lng = ~lon, lat = ~lat, color = ~pal(num_reviews_log),
                   radius = 3) %>% 
  addLegend('bottomright', pal = pal, values = ~num_reviews_log,
            title = 'Scores', opacity = 1)

seoul_leaf            

 

scores보다는 훨씬 다양한 분포를 확인할 수 있었습니다.  

 

 

 

 


마치며.

 

오늘은 지난 시간에 이어 문화공간 분석을 진행했습니다. 셀레니움 사용법을 자세히 살펴보고, 평점과 리뷰 수를 크롤링해보는 시간을 가졌습니다. 아쉽게도 데이터의 질이 분석 목적에 그렇게 맞지는 않아, 만족스러운 결과는 얻지 못했네요. 카카오맵 등을 이용해 다른 시도를 해 보아야 할 필요를 느낍니다. 또한, 실제 리뷰 내용들을 이용한 감정분석, 워드클라우드 등의 추가 작업이 가능할 것으로 보입니다.
 

다음 시간에는 각 문화공간 주변의 교통 환경을 분석에 적용해보도록 하겠습니다. 감사합니다:)