데이터 수집 목적으로 웹 크롤러를 사용해서 인터넷 사이트에 공개된 정보를 모아 CSV (Comman Seprated Values) 파일로 저장하는 경우가 많습니다. 예를 들어, 구글이나 네이버에서 주식 종목별 주가, 인터넷 댓글, 날씨 정보, 부동산 정보를 Crawler를 통해서 데이터를 수집하여 CSV 로 저장하고, 이를 인공지능이나 Machine Learning의 입력 데이터로 활용할 수 있습니다. 각 인터넷 사이트에서 Open API를 제공하면 API를 통해서 데이터를 수집하지만, 그렇지 않는 경우 어쩔 수 없이 크롤러를 사용해서 데이터를 수집해야 합니다.
본 포스팅은 Google Play의 사용자 리뷰 정보를 모아서 CSV 파일로 저장하는 방법을 설명하고자 합니다. 각 앱의 Playstore 리뷰에서 ① 사용자, ② 별점, ③ 작성 날짜, ④ 좋아요 수, ⑤ 리뷰 내용을 Python Selenium과 BeatifulSoup을 사용하여 HTML로 부터 string을 추출하고 이를 Pandas를 사용해서 CSV 파일로 저장합니다.
Python 전체 코드가 필요하신 분은 아래 GitHub 사이트에서 다운로드 후 사용하시면 되고, Crawling 방법을 알고 싶은 분들은 아래 세부 내용을 참고하시면 됩니다. Python Crawler를 이해하기 위해서는 Selenium, WebDriver, BeatifulSoup, Pandas에 대한 이해가 필요합니다.
- GitHub code: https://github.com/kibua20/devDocs/tree/master/playstore_crawler
- Crawler 구현에 필요한 내용: Selenium, WebDriver, BeatifulSoup, Pandas
- Test Web Page: 유튜브 리뷰 https://play.google.com/store/apps/details?id=com.google.android.youtube&showAllReviews=true
참고로, Apple App Store에서 사용자 리뷰을 수집하는 방법은 별도로 포스팅하였습니다.
[SW 개발/Data 분석 (RDB, NoSQL, Dataframe)] - Apple App Store 사용자 댓글(리뷰) 데이터 수집하기 (Sample code 포함)
BeatifulSoup와 Selenium 차이
Python으로 Web page를 Crawling을 할 수 있는 대표적인 모듈은 Selenium과 BeatifulSoup이 있습니다. Java script가 없는 정적 웹 페이지는 BeatifulSoup을 사용하면 보다 빠른 속도로 HTML을 Parsing 할 수 있고, 동적인 웹 페이지는 java script 실행 후 HTML 분석이 가능한 Selenium을 사용해야 합니다.
기능적으로 보면 Selenum은 Crawling보다는 웹 페이지 자동하 테스트 (버튼 클릭, 스크롤 등)를 위해서 개발된 모듈이지만, Html parsing 기능도 지원하고 있기 때문에 Selenium만 잘 사용해도 크롤링은 가능합니다.
BeatifulSoup | Selenium |
HTML, XML Parsing | 웹 페이지 자동화 검증 Tool (클릭, 스크롤, sleep) |
Python Request 모듈에 대한 response를 분석할 때 | Chrome을 테스트 모드로 실행해서 Web page는 전체를 다운로드함 |
정적인 page 분석할 때 | 동적인 페이지 |
속도 빠름, 메모리 절약 | 속도 느림, 메모리 요구 큼 |
BeatifulSoup, Selenium, Webdriver설치
Python Crawling 에 필요한 모듈을 설치합니다. Python 모듈 및 WebDriver에 설치에 관련된 내용은 이전 포스팅을 참고하세요. BeatifulSoup 모듈은 Version 4를 설치해야 합니다.
$ pip3 install selenium BeautifulSoup4
Browser와 버전에 맞는 Webdriver를 다운로드해서 같은 폴더에 넣습니다. 구글 Chrome 버전은 아래 명령어로 확인이 가능합니다.
$ google-chrome --version
※ Web driver 다운로드 사이트:
- Firefox : https://github.com/mozilla/geckodriver/releases
- Chrome : https://sites.google.com/a/chromium.org/chromedriver/downloads
- Edge : https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/
Selenium 실행하여 Html 소스를 BeautifulSoup으로 전달하기
Google Playstore 사이트는 java script으로 구동되고 있는 Dynamic webpage입니다. 이를 requests()의 response을 받아서 BeautifulSoup로 html을 parsing 하면 Java script에 의해서 출력되는 값은 모두 None이기 때문에 selenium으로 웹 페이지를 접속해서 Java script를 실행하고 그 결과를 html로 받아서 parsing해야 합니다. Selenium 자체에서도 Parsing 할 수 있지만 다양한 방법으로 접근하기 위해서 BeautifuleSoup으로 parsing 하도록 하겠습니다.
Webdriver를 구동 전에 Option을 설정하고 Html 정보인 browser.page_source 값을 BeautifulSoup으로 전달합니다.
# Selenium 실행 옵션: 속도 개선은 위해서는 headless로 실행하고 디버깅을 위해서는 화면에 브라우저를 띄움
option = Options()
option.add_argument("disable-infobars")
option.add_argument("disable-extensions")
option.add_argument('disable-gpu')
#option.add_argument('headless')
# webdriver 얻어옴 - google-chrome --version으로 version을 확인하고 맞는 chrome drvier를 다운로드 https://chromedriver.chromium.org/downloads
browser = webdriver.Chrome('./chromedriver', options=option)
browser.get(url)
html = browser.page_source
# html find
soup = BeautifulSoup(html,"html.parser")
※ Selenium 4.0 버전에 대한 설명을 아래 링크에 업데이트하였습니다.
Google PlayStore 리뷰 '모두 보기' 페이지에서 데이터 추출하기
Google PlayStore 웹 페이지에서 ① 사용자, ② 별점, ③ 작성 날짜, ④ 좋아요 수, ⑤ 리뷰 내용을 추출하는 방법입니다.
Google Chrome에서 웹 페이지를 접속 후 개발자 도구(F12)를 사용해서 Html Tag와 Attribute을 확인하고 얻어오는 과정이 필요하며, 얻은 Tag와 Attribute 값을 BeautifulSoup에서 제공하는 select()와 find_all() 함수를 text를 추출할 예정입니다.
1. Selector를 사용해서 '사용자'의 Tag와 Attribue 확인
사용자 이름을 얻어오는 방법은 Chrome 개발자 도구에서 지원하는 "Copy Selector" 기능을 사용합니다. Chrome으로 해당 URL 웹 페이지를 접속하고 F12로 개발자 모드로 진입한 상태에 추출하고자 하는 Element를 클릭하고 해당 Element의 Html을 선택한 상태에서 마우스 오른쪽 메뉴에서 Copy > Copy Selector 메뉴를 선택합니다.
- 개발자 모드에서 Element를 선택하고, 오른쪽 화살표 아이콘을 누름
- 웹 페이지에서 추출하고자 하는 텍스트를 선택, User를 추출하기 위해서 사용자((Kay Lee)를 선택
- HTML에서 마우스 오른쪽 메뉴에서 Copy 메뉴 선택
- Copy selector 선택하여 Clipboard에서 selector를 복사함
Copy Selector를 메뉴를 실행하고 소스 에디터에 Ctrl+V로 붙여 넣기 하면 아래와 같이 selector를 확인할 수 있습니다. Html 전체를 기준으로 구성하는 Tag의 계층구조를 보여주기 때문에 상당히 복잡합니다.
#fcxH9b > div.WpDbMd > c-wiz > div > div.ZfcPIb > div > div > main > div > div.W4P4ne > div:nth-child(3) > div > div.d15Mdf.bAhLNe > div.xKpxId.zc7KVe > div.bAhLNe.kx8XBd > span
div.bAhLNe.kx8XBd 의미는 div tag에서 class 이름이 bAhLNe.kx8XBd 인 것을 의미합니다. 즉, Html 문서 안에 수많은 div tag 중 class name으로 스타일을 지정하고 있어 이를 크롤링에서 활용합니다. 부등호 (' >')의 의미는 각 Tag 간의 계층 구조(=하위)를 나타냅니다. div.bAhLNe.kx8XBd '하위'에 span tag을 가지고 있는 것을 나타냅니다.
div:nth-child(3)는 div tag 리스트에서 3번째 값을 의미합니다. 일반적으로 특정 하나의 값을 얻는 것보다는 list를 얻는 경우가 많기 때문에 크롤링에서는 nth-child는 값을 제거하고 select() 함수를 사용합니다.
해당 웹 페이지에서 사용자 이름('KayLee')을 얻어오기 위해서는 아래와 같이 Selctor 선택합니다. 굳이 전체 selector를 변수로 넣기보다는 고유한 값을 찾을 수 있도록 2단계나 3단계를 정도를 입력합니다. soup.select() 함수 결과는 list로 반환하고 html tag를 포함하고 있어, text만을 추출하기 위해서는 u.text를 출력하여 이름을 얻을 수 있습니다.
# get user : div.bAhLNe.kx8XBd 하위에 있는 span tag를 찾음
users = soup.select('div.bAhLNe.kx8XBd > span')
print (len(users))
for u in users:
print (u, u.text)
또 다른 방법으로 span tag에서 class name 이 X43Kjb 값 기준으로도 '사용자 이름'의 텍스트를 추출가능합니다. 경우에 따라서는 추출된 텍스트에 원하지 않은 값이 포함될 수 있어 정상적인 값을 얻어오는지 trial & error로 반복 테스트를 통해서 결과를 확인해야 합니다. Element 값은 Chrome의 개발자 메뉴에서 "Copy Element" 메뉴를 통해서 값을 확인할 수 있습니다.
# copy element
<span class="X43Kjb">Kay Lee</span>
users = soup.select('span.X43Kjb')
Chrome에서 selector, element와 함께 selenium의 xpath으로 driver.findElement(By.xpath) 함수를 사용해서 값을 얻을 수도 있습니다.
# copy xpath
//*[@id="fcxH9b"]/div[4]/c-wiz/div/div[2]/div/div/main/div/div[1]/div[2]/div/div[2]/div[1]/div[1]/span
2. Selector를 사용해서 '별점' 정보 추출
'별점'은 Text를 구성되어 있지 않고, 이미지로 구성되어 있고, span (class=nt2C1d) tag와 div (class pf5lIe) 하위의 div tag의 aria-lable 값으로 얻어올 수 있습니다. 앞에서 설명한 selector에 'span.nt2C1d > .pf5lIe > div' 조건을 넣고, s['aria-label'] 값에서 불필요한 문구를 제거합니다.
# stars
stars_string = soup.select('span.nt2C1d > .pf5lIe > div')
stars = list()
for s in stars_string:
# 별표 5개 만점에 1개를 받았습니다 --> 1만 저장
tmp = s['aria-label'].replace('별표 5개 만점에', '').replace('개를 받았습니다.', '')
stars.append(tmp)
3. Selector를 사용해서 '날짜' 정보 추출
'날짜' 정보는 span tag에 class = p2TkOb 값으로 추출합니다. 원하지 않는 값이 추출되는 것을 방지하기 위해서 상위 tag 인 div.bAhLNe.kx8XBd > div 인 경우로 한정합니다.
# date
date = soup.select('div.bAhLNe.kx8XBd > div > span.p2TkOb')
4. find_all()을 사용하여 '좋아요' 정보 추출
'좋아요' 개수 정보는 div (class = jUL89d.y92BAb) tag에서 attribute aria-label 값이 '이 리뷰가 유용하다는 평가를 받은 횟수입니다.' 값에 대한 text를 얻어 올 수 있습니다. 아래 예제에서는 '7' 값을 추출합니다.
# likes : div.jUL89d.y92BAb.K3ZHGe aria-label
likes = soup.find_all('div', {'aria-label':'이 리뷰가 유용하다는 평가를 받은 횟수입니다.'})
5. find_all()을 사용하여 'Review' 정보 추출
마지막으로 실제 Review 정보를 추출하는 과정입니다. Review(댓글)은 '짧은' 댓글과 '전체' 댓글 2가지로 구성되어 있습니다. 짧은 댓글이 기본 값으로 보여주고 있다가 "전체 리뷰" 버튼을 클릭하면 전체 댓글을 화면 상에 표시합니다. 짧은 댓글은 span tag의 jsname = bN97Pc의 text에 저장되어 있고, 전체 댓글은 jsname = fbQN7e인 text에 저장되어 있습니다. 댓글의 길이가 짧은 댓글로 표현 가능한 경우 전체 댓글은 None으로 설정하고, 짧은 댓글만 text로 지정되어 있습니다. 따라서 Review은 얻어오는 것은 전체 댓글을 먼저 가져오고, 전체 댓글이 None인 경우 짧은 댓글을 가져올 수 있습니다.
# find short comments: bN97Pc
short_comments = soup.find_all('span',{'jsname':'bN97Pc'})
#full comments fbQN7e
full_comments = soup.find_all('span',{'jsname':'fbQN7e'})
# 상위 5개만 출력
print ('Number of extracted Reviews =', len(users), len(stars), len(date), len(short_comments), len(full_comments))
for u, s, d, l, short_c, full_c in zip (users, stars, date, likes, short_comments, full_comments):
if (len(full_c.text) > 0):
print (u.text,s, d.text, l.text, full_c.text)
else:
print (u.text,s, d.text, l.text, short_c.text)
Google PlayStore 리뷰 '모두 보기' 페이지에서 데이터 추출하기
Google PlayStore 웹 페이지에서 ① 사용자, ② 별점, ③ 작성 날짜, ④ 좋아요 수, ⑤ 리뷰 내용을 list에 저장하고 Pandas을 이용해서 CSV 파일로 저장합니다. 각각의 list의 항목을 같은 index로 변환하기 위해서 zip 함수로 열거하고 각 항목의 값을 list로 만들어서 Dataframe을 만들고 csv파일로 저장합니다.
# Save to CSV
res_dict = list()
for u, s, d, l, short_c, full_c in zip (users, stars, date, likes, short_comments, full_comments):
if (len(full_c.text) > 0):
res_dict.append({
'USER' : u.text,
'STAR' : s,
'DATE' : d.text,
'LIKE' : l.text,
'REVIEW' : full_c.text
})
else:
res_dict.append({
'USER' : u.text,
'STAR' : s,
'DATE' : d.text,
'LIKE' : l.text,
'REVIEW' : short_c.text
})
res_df = pd.DataFrame(res_dict)
res_df['DATE'] = pd.to_datetime(res_df ['DATE'], format="% Y 년 % m월 %d일")
res_df.to_csv(ouputfile,index=False, encoding='utf-8-sig')
Google PlayStore 리뷰 '모두 보기' 크롤링 전체 코드
Google PlayStore 웹 페이지에서 ① 사용자, ② 별점, ③ 작성 날짜, ④ 좋아요 수, ⑤ 리뷰 내용 크롤링의 전체 코드는 아래에 예제와 GitHub에도 반영했습니다. 예제는 Youtube 앱이지만, Playstore의 다른 앱에서도 정상적으로 동작하며 id=com.google.android.youtube 부분을 크롤링하고자 하는 package name으로 변경하여 url을 전달하면 CSV로 저장됩니다.
- Test Web Page: 유튜브 전체 댓글 보기 https://play.google.com/store/apps/details?id=com.google.android.youtube&showAllReviews=true
#!/usr/bin/python3
# -*- coding: utf-8 -*-
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from bs4 import BeautifulSoup
from time import sleep
import pandas as pd
def playstore_crawler(url, ouputfile='./playstore_reviews.csv'):
option = Options()
option.add_argument("disable-infobars")
option.add_argument("disable-extensions")
#option.add_argument("start-maximized")
option.add_argument('disable-gpu')
option.add_argument('headless')
# webdriver 얻어옴 - google-chrome --version으로 version을 확인하고 맞는 chrome drvier를 다운로드: https://chromedriver.chromium.org/downloads
browser = webdriver.Chrome('./chromedriver', options=option)
browser.get(url)
# scroll browser
SCROLL_PAUSE_TIME = 1
# change scroll number (trial & error)
SCROLL_MAX_NUM = 120
last_height = browser.execute_script("return document.body.scrollHeight")
loop = 0
while loop < SCROLL_MAX_NUM :
# scroll
browser.execute_script("window.scrollTo(0, document.body.scrollHeight);")
sleep(SCROLL_PAUSE_TIME)
# click 더보기
try:
browser.find_element_by_xpath("//span[@class='RveJvd snByac']").click()
except:
pass
# # break while loop
# new_height = browser.execute_script("return document.body.scrollHeight")
# if new_height == last_height:
# break
# last_height = new_height
loop = loop + 1
html = browser.page_source
# html find
soup = BeautifulSoup(html,"html.parser")
# get user div.bAhLNe.kx8XBd
users = soup.select('div.bAhLNe.kx8XBd > span')
# print (len(users))
# for u in users:
# print (u, u.text)
# # copy element
# users = soup.select('span.X43Kjb')
# print (len(users))
# for u in users:
# print (u, u.text)
# browser.quit()
# return
# stars
stars_string = soup.select('span.nt2C1d > .pf5lIe > div')
stars = list()
for s in stars_string:
tmp = s['aria-label'].replace('별표 5개 만점에', '').replace('개를 받았습니다.', '')
stars.append(tmp)
# date
date = soup.select('div.bAhLNe.kx8XBd > div > span.p2TkOb')
# likes : div.jUL89d.y92BAb.K3ZHGe aria-label
likes = soup.find_all('div', {'aria-label':'이 리뷰가 유용하다는 평가를 받은 횟수입니다.'})
# find short comments: bN97Pc fbQN7e
short_comments = soup.find_all('span',{'jsname':'bN97Pc'})
#full comments
full_comments = soup.find_all('span',{'jsname':'fbQN7e'})
# 상위 5개만 출력
print ('Number of extracted Reviews =', len(users), len(stars), len(date), len(short_comments), len(full_comments))
loop = 0
for u, s, d, l, short_c, full_c in zip (users, stars, date, likes, short_comments, full_comments):
if (len(full_c.text) > 0):
print (u.text,s, d.text, l.text, full_c.text)
else:
print (u.text,s, d.text, l.text, short_c.text)
if (loop > 5):
break
loop = loop + 1
# Save to CSV
res_dict = list()
for u, s, d, l, short_c, full_c in zip (users, stars, date, likes, short_comments, full_comments):
if (len(full_c.text) > 0):
res_dict.append({
'USER' : u.text,
'STAR' : s,
'DATE' : d.text,
'LIKE' : l.text,
'REVIEW' : full_c.text
})
else:
res_dict.append({
'USER' : u.text,
'STAR' : s,
'DATE' : d.text,
'LIKE' : l.text,
'REVIEW' : short_c.text
})
res_df = pd.DataFrame(res_dict)
res_df['DATE'] = pd.to_datetime(res_df['DATE'], format="%Y년 %m월 %d일")
res_df.to_csv(ouputfile,index=False, encoding='utf-8-sig')
browser.quit()
def main():
# Google Player Store url
url = 'https://play.google.com/store/apps/details?id=com.google.android.youtube&showAllReviews=true'
playstore_crawler(url, 'com.google.android.youtube.csv')
if __name__ == '__main__':
main()
관련 글:
[SW 개발/Data 분석 (RDB, NoSQL, Dataframe)] - 우분투 20.04에서 MariaDB 설치 및 기본 동작 확인 명령어
[SW 개발/Data 분석 (RDB, NoSQL, Dataframe)] - CSV 파일에서 MariaDB(또는 MySQL)로 데이터 가져오는 방법
[개발환경/우분투] - 대용량 파일을 작은 크기로 분할하는 방법: split
[SW 개발/Data 분석 (RDB, NoSQL, Dataframe)] - Jupyter Notebook의 업그레이드: Jupyter Lab 설치 및 extension 사용법 [SW 개발/Android] - Python으로 개발된 Android Apk Decompile Tool: androguard
[SW 개발/Android] - 파이썬으로 Apk Download 자동화: Selenium기반의 Apk 크롤러
[SW 개발/Python] - Python JSON 사용 시 TypeError: Object of type bytes is not JSON serializable
[SW 개발/Python] - [Tips] XML 에서 예약/특수 문자 처리
[SW 개발/Python] - Python 소스 숨기는 방법: pyc 활용 (Bytecode로 컴파일)
[SW 개발/Python] - Python에서 URL 한글 깨짐 현상: quote_plus()와 unquote_plus()
댓글