[Python] Selenium 으로 구글 맵에서 정보 뽑기 Develop Tip

요즘 인공지능 스피커가 인기입니다.
좀 더 과장되게 이야기하면, 사용자 입장에서는 스피커가 아니라
개인비서라 생각이 들게 합니다.

예를 들어,

"김비서? 언주역 부근에서 4성급 이상에 12만원 이하인 호텔을 검색해줘"

라고 하면

"네. 주인님"

하면 결과를 죽 보여주고,

그 결과 중에 하나의 호텔에 전화걸어주는 것도 충분히 가능하겠죠.

나중에는 

"김비서? 다음주 수요일부터 금요일까지 언주역 부근에서 4성급 이상에 12만원 이하인 호텔을 검색해서 예약해 주고 하루 전에 준비하라고 알려줘"

라고 하면 알아서 다 해주겠지요.

암튼 이런 일을 사람이 하기에는 쉽습니다.
하지만 자동화는 쉽지 않지요.

일단 핵심은 "언주역 부근 호텔"을 찾는 것입니다.

여기선 잠깐! : 아래 은이님의 댓글처럼 구글맵은 실제 웹으로 사용자 인터페이스 말고도 자체 API를 제공하고 있습니다.
여기서는 해당 특정 API를 이용하는 것이 목적이 아닌 Selenium을 이용하여 웹브라우저를 제어하는 것을 목표로 하고 있음을 밝혀두는 바입니다. (고맙습니다. 은이님!)


구글 맵으로 들어가서,

검색 창에 "언주역 부근 호텔"을 검색하고 나면 위와 같이 좌측에 검색 결과 목록이 나옵니다.

그리고 해당 정보를 누르면 왼쪽 아랫편에 상세 정보가 나옵니다.
그런 다음 검색창 아래에 있는 "검색결과로 돌아가기"를 눌러 이전 검색 결과 목록으로 되돌아 간 다음,

두번째 결과를 눌러보면 모두 동일합니다.

위와 같은 것을 자동화 해 본다고 가정합니다.

우선 환경은 macOS 입니다. (윈도우나 Linux도 거의 동일합니다)
전제 조건은 크롬 브라우저를 미리 설치했습니다.

그리고 터미널에서 다음 명령을 줍니다.

$ pip install selenium

셀레니움 패키지만 있으면 됩니다.

$ brew install chromedriver

위에 것은 셀레니움이 어떤 브라우저를 이용하는가를 나타내는데,
크롬 용 드라이버를 설치하는 것입니다.

VM 등을 띄울 때 백엔드에서 보이지 않게 띄우는 것을 Headless running 이라고 하는데,
여기서도 headless run을 하기 위해서는

$ brew install phantomjs

라고 팬텀JS용 드라이버를 이용하면 됩니다.
(윈도우나 리눅스 용은 간단히 검색해 보면 찾으 실 수 있습니다)

이제는 코드를 들어가야 하는데, 알아야 할 것이 있습니다.

우선은 웹브라우저가 동작하는 방식입니다.
우리가 웹에서 어떤 방식으로 작업을 한다 하더라도 HTML과 JS로 동작하며
결과적으로 HTML을 (JS가 HTML의 DOM을 동적으로 변경한다 해도)
그대로 DOM (Document Object Model) Tree로 표현하고 그 결과를
브라우저 상에 보여주게 됩니다.

따라서 Selenium 으로 무언가를 해야 한다면
간단히 HTML (tag, attribute, inner text, css) 등에 대한 기본 지식이 있어야
쉽게 작업을 진행하실 수 있습니다.

좀 더 고급 활용을 하시거나 웹 브라우저가 DOM을 처리하는 방법을 좀 더 알고싶다면,

해당 블로그 내용을 참고하시기 바랍니다.

다음은 작업 하면서 했던 작업들을 기준으로 설명해 보고 마지막에 코드를 링크해 보겠습니다.

최초 시작하는 것은 webdrive를 지정하는 것입니다. 위에서 처럼
크롬 웹드라이브를 지정해 보겠습니다.

    driver = webdriver.Chrome(executable_path="/usr/local/bin/chromedriver")
    driver.set_window_size(1200, 800)

위에 처럼 크롬 드라이버와 윈도우 크기를 지정합니다.
크롬드라이버가 연결되는 순간 실제 크롬 브라우저가 뜹니다.
그리고 윈도우 크기를 지정하면 해당 크기로 재조정됩니다.

눈에 보이지는 않지만 동일하게 동작하는 headless browser 인 phantomjs 를 대신
이용하려면

    driver = webdriver.PhantomJS()
    driver.set_window_size(1200, 800)

라고 하면 됩니다.

그런데 서너 군데의 사이트를 작업을 해 보니, headless 에 따라
크롬과 동일한 결과가 안나오는 것도 있고, 브라우저의 크기에 따라
나오던 결과가 안나오는 경우도 있었습니다.

모두 JavaScript 등에서 사용자 창을 읽어 무언가 처리하는 부분에
문제가 있지 않나 싶습니다.

암튼 처음처럼 크롬으로 열어 동일한 크기에서 동작하도록 하면 대부분의 문제는 없었습니다.

이제는 구글맵의 검색창을 찾아 검색 단어를 넣는 부분을 살펴보겠습니다.

우선 직접 크롬을 하나 띄워 동일한 구글맵에 들어가 보겠습니다.

구글맵에 들어간 상태에서 Command+Option+i 를 눌러 위에 창처럼 하단에 무언가 뜨는 것을 확인합니다. 만약 우측에 생겼다면 세로 "..."을 눌러 하단에 위치하게 하면 됩니다. (경우 경우에 따라 하단이나, 우측, 또는 별도 창으로 띄우는게 편할 수 있습니다)

이제는 DOM 객체 중에서 해당 검색창의 엘리먼트를 찾으면 됩니다.
찾는 방법은 어렵지 않습니다.

좌측 중간 아래에 빨간 원에 있는 아이콘을 누른 다음,

마우스를 지도검색 창에 대고 클릭하면 위와 같은 화면으로 보이며 하단에 해당 HTML 태그 및 CSS 스타일이 보입니다.

요즘에는 input 태그를 직접 이용하지 않는 경우도 많지만 위에는 input 태그에 위와 같이 id 속성이 "searchboxinput" 이라고 되어있는 것을 확인할 수 있습니다.

유사하게 검색을 하려면 검색창에 있는 돗보기 아이콘을 누르면 되는데, 다시 찾아보니

위에처럼 button 태그에 id는 "searchbox-searchbutton" 이었습니다.

그럼 대충 input tag로 id 속성이 "searchboxinput" 인 태그를 찾아 검색하고자 하는 문자열을 입력시키고 나서
"searchbox-searchbutton" 속성의 button을 click 하면 동작해야 하는 것입니다.

이제 다시 본론으로 돌아와 실제 작업을 해 보겠습니다.

이전 작업했던,

    driver = webdriver.Chrome(executable_path="/usr/local/bin/chromedriver")
    driver.set_window_size(1200, 800)

에서 driver를 이용하여 우선 구글맵을 띄워야 합니다.

    driver.get("https://www.google.co.kr/maps")
    driver.implicitly_wait(3)

위에처럼 해당 구글맵 주소를 넣고 driver의 get으로 (아마도 HTTP GET 메서드 일듯) 가져옵니다.
implicitly_wait(3)은 제어되고 있는 크롬이 암묵적으로 구글맵에서 모든 그리기가 끝날 때까지 기다리는
시간을 주는 것이라 생각하면 됩니다.

특이한 것은 3초를 기다리라고 해도 그 안에 모든 내용의 보여주기가 끝나면 3초 내에 끝나고
다음 명령으로 넘어간다는 것입니다.
(sleep 을 안해도 된다는 것이지요)

화면 페이지가 변경될 때마다 implicitly_wait(3)을 주어 충분히 기다려 주어야 해당
DOM element가 있음을 알아야 합니다.

그 다음 검색 단추를 눌러 보겠습니다.

    elem = driver.find_element_by_id("searchboxinput")
    elem.send_keys(SEARCH)

위에서는 driver.find_element_by_id 로 id 속성이 해당 내용인 것의 엘리먼트를 구해오는 것입니다.
그리고 해당 tag가 input 이었기 때문에 send_keys로 검색 문자열을 넣어 주면 됩니다.

그리고 검색단추를 누르는 것은,

    elem = driver.find_element_by_id("searchbox-searchbutton")
    elem.click()

라고 하면 됩니다.

여기 까지는 아주 쉽습니다.

문제는 그 다음인데, 아까 수동으로 띄웠던 창에서 동일하게 검색하고 나온 왼편 결과를 보고는,
첫번째를 골라 아래에 보면 div 태그네요... (아마도 요즘에는 대부분이 이 div 태그이군요)


이제는 위에 태그를 포함하는데 결과만을 가지는 것을 찾기위하여 하단의 HTML 코드에서 그 parent를 찾아 올라가 봅니다.

위에처럼 그 상위의 div 태그인데 클래스 이름이 "section-listbox section-scrollbox scrollable-y scrollable-show" 라고 되어 있습니다. 클래스 이름이 중간 공백도 포함되어 있군요. 위에 확인해 보면 jstcache 라는 속성은 구글에서 지정한 것인데, 임의의 숫자로 보아 매번 동일한 속성값을 갖는다는 보장이 없겠군요. 그래서 처음에는 위의 클래스 이름으로 검색을 위하여

elem = driver.find_element_by_class_name('section-listbox section-scrollbox scrollable-y scrollable-show')

를 호출해 보았는데 못 찾았습니다. 아마도 공백 등 때문이 아닌가 싶어 이것 저것 해 보았는데 실패 했습니다.
"section-listbox" 로 찾으면 찾기는 하는데 위에 것이 아닌것으로 보아 여러 군데 나오는 클래스 명 이라 보았습니다.

따라서 고유 이름의 클래스 명이나 id 속성으로 찾고자 하였는데,

위에 속성의 할아버지 격인,

"widget-pane-content-holder" 이라는 클래스명을 가진 div 태그는 가져오는 것이었습니다.

잘 가져오는지 확인할 수 있는 방법은 여러가지가 있겠으나 저는
PyCharm을 이용합니다.

오류가 발생하면 해당 가져오는 라인에서 브레이크포인트를 걸고 디버그를 하여 멈춘 상태에서,

위와 같이 하단 디버그 창의 콘솔(Console)에 좌측의 밑에 두번째 아이콘을 눌러 파이썬 인터프리터를 띄웁니다.

그다음 위에처럼 여러 다양하게 시도하고 나서 해당 컴포넌트를 가져오는지 확인하고, 잘 가져왔는지는
text 를 출력하여 확인해 봅니다.

참고로 해당 엘리먼트를 가져오는 방법은

find_element_by_name('HTML_name')
find_element_by_id('HTML_id')
find_element_by_xpath('//html/body/some/xpath')

또는

find_element_by_css_selector('#css > div.selector')
find_element_by_class_name('some_class_name')
find_element_by_tag_name('h1')

등등이 있으니 시도해 보시기 바랍니다.

이제는 할아버지를 찾았으니 우리가 원하는 항목을 하나씩
뽑아올 수 있는 방법을 확인해 보겠습니다.

위에 처럼 몇번의 시행착오를 한 결과,

위와 같은 결과 목록의 div는 "widget-pane-content-holder" 이라는 클래스명을 가진 div 태그로부터 
다음과 같은 상대 xpath로 찾아지는 것을 확인하였습니다.

다음의 두줄입니다.

        elem = driver.find_element_by_class_name('widget-pane-content-holder')
        dt = elem.find_element_by_xpath('.//div/div[@role="listbox"]')

두번째 줄의 find_element_by_xpath 는 driver가 아닌 위에서 찾은 elem 에서 또 상대적으로
찾았습니다. xpath 도 './/...' 이라고 하여 현재부터를 의미하였습니다.

javascript를 비롯하여 이런 DOM 관련 함수들을 보면 재귀적인 것이 특징입니다.

위에서 구한 dt는 결과 목록의 상위 div 태그를 가져왔으며, 그 아래 하나씩 구해오는 것은

        for ndx in range(100):
            d = dt.find_element_by_xpath('.//div[@data-result-index="%s"]' % ndx)

위와 같은 식으로 구해옵니다. 

대신 처음에 몇개까지 인지 길이를 구해오지 않았기에,

    for ndx in range(100):
        try:
            d = dt.find_element_by_xpath('.//div[@data-result-index="%s"]' % ndx)
        except NoSuchElementException:
            break   # End of list

위와 같이 구해 왔습니다.

로직은 수없이 많을 수 있으니 자신만의 방법을 찾아 보시기 바랍니다.

암튼 위와 같이 구해온 d (div 태그 결과 중 n 번째 항목)에 대해 필요 작업 후

d.click()
를 하면 해당 상세 페이지로 넘어갑니다.

또한 암묵적으로 다 그리기를 기다린 후,

해당 항목을 찾는데,

위에처럼 'widget-pane-content-holder' 이라는 클래스명을 가지고 있는 엘리먼트를 찾아,

상대위치에서 xpath로 './/div/div[@data-section-id="ad"]'를 구하면 주소를 구할 수 있고, './/div/div[@data-section-id="ap"]'를 구하면 홈페이지를 구할 수 있고, './/div/div[@data-section-id="pn0"]' 를 구하면 전화번호를 구할 수 있었습니다. (해당 각 항목은 생략 될 수도 있다 생각했습니다.)

필요 상세 정보를 다 구한 다음에는 "검색결과로 돌아가기"를 구하려고 찾아보니,

'widget-pane-content-holder' 이라는 클래스명을 가지고 있는 엘리먼트의 './/div/button' xpath 위치에 해당 단추를 구할 수 있었고, 그것을 누르면 되었습니다.

결국 최종 코드는,

여기에서 구하실 수 있습니다.

또한 실제 실행 결과 화면은 링크를 누르시면 보입니다.

해당 프로그램을 터미널에서 돌려보면 결과가,

$ python googlemap_search_result.py
{'hotel': '호텔 카푸치노', 'info': '광고 4.0 ₩106,967,4성급 호텔', 'address': '서울특별시 강남구 논현1동 봉은사로 155', 'homepage': 'hotelcappuccino.co.kr', 'phone': '02-2038-9500'}
{'hotel': '글래드 라이브 강남', 'info': '광고 3.9 ₩121,000,3성급 호텔', 'address': '서울특별시 강남구 논현2동 봉은사로 223', 'homepage': 'gladlive-hotels.com', 'phone': '02-6177-5000'}
{'hotel': '발트호텔', 'info': '2.2 ₩90,000,호텔', 'address': '서울특별시 강남구 역삼1동 역삼동 651-2', 'homepage': 'valthotel.com', 'phone': '02-567-6240'}
{'hotel': '호텔 삼정', 'info': '3.7 ₩80,612,4성급 호텔', 'address': '서울특별시 강남구 역삼1동 봉은사로 150', 'homepage': 'samjunghotel.co.kr', 'phone': '02-557-1221'}
{'hotel': '호텔 카푸치노', 'info': '4.0 ₩106,967,4성급 호텔', 'address': '서울특별시 강남구 논현1동 봉은사로 155', 'homepage': 'hotelcappuccino.co.kr', 'phone': '02-2038-9500'}
{'hotel': '글래드 라이브 강남', 'info': '3.9 ₩121,000,3성급 호텔', 'address': '서울특별시 강남구 논현2동 봉은사로 223', 'homepage': 'gladlive-hotels.com', 'phone': '02-6177-5000'}
{'hotel': '강남패밀리호텔', 'info': '4.1,₩82,809,₩105,000,3성급 호텔,할인 상품 21% 저렴', 'address': '서울특별시 강남구 논현1동 봉은사로 143', 'homepage': 'gangnamfamilyhotel.com', 'phone': '02-6474-1515'}
{'hotel': '캘리포니아호텔', 'info': '3.4 ₩59,529,3성급 호텔', 'address': '서울특별시 강남구 역삼1동 봉은사로 164', 'homepage': 'californiahotel.co.kr', 'phone': '02-553-4100'}
{'hotel': '호텔마레', 'info': '2.8 ₩91,670,3성급 호텔', 'address': '서울특별시 강남구 삼성2동 37-16', 'phone': '02-546-2271'}
{'hotel': '스타호텔', 'info': '3.5 ₩60,429,2성급 호텔', 'address': '서울특별시 강남구 역삼동 736-53', 'phone': '02-561-9442'}
{'hotel': '아르누보시티', 'info': '3.9 ₩95,120,4성급 호텔', 'address': '서울특별시 강남구 역삼동 701-1', 'homepage': 'hotelarv.com', 'phone': '02-557-7100'}
{'hotel': '베스트웨스턴프리미어강남호텔', 'info': '3.8 ₩108,449,4성급 호텔', 'address': '서울특별시 강남구 논현1동 봉은사로 139', 'homepage': 'bestwesterngangnam.com', 'phone': '02-6474-2000'}
{'hotel': '648호텔', 'info': '3.5,₩63,781,₩73,703,2성급 호텔,할인 상품 13% 저렴', 'address': '서울특별시 강남구 역삼1동 강남대로94길 56-4', 'homepage': '648hotel.co.kr', 'phone': '02-553-4737'}
{'hotel': '힐탑관광호텔', 'info': '2.8 (10),호텔 · 논현동 151-30', 'address': '서울특별시 강남구 논현동 151-30', 'homepage': 'hotelhilltop.co.kr', 'phone': '02-540-5652'}
{'hotel': '케이프타운 비지니스호텔', 'info': '1.0,중저가 호텔', 'address': '서울특별시 강남구 삼성2동 선릉로92길 41', 'homepage': 'cafe.naver.com', 'phone': '02-2051-2091'}
{'hotel': '모리호텔', 'info': '2.7 (3),호텔 · 신사동 502-7', 'address': '서울특별시 강남구 신사동 502-7', 'phone': '02-544-0383'}
{'hotel': '호텔꾸띠', 'info': '3.1 ₩68,033,2성급 호텔', 'address': '서울특별시 강남구 대치동 890-27', 'phone': '02-567-5515'}
{'hotel': '트리아호텔', 'info': '3.7 ₩79,324,4성급 호텔', 'address': '서울특별시 강남구 역삼동 677-11', 'phone': '02-553-2471'}
{'hotel': '오클라우드 호텔', 'info': '4.0 ₩103,725,4성급 호텔', 'address': '서울특별시 서초구 서초4동 사평대로58길 12', 'homepage': 'ocloudhotel.com', 'phone': '1899-9994'}
{'hotel': '프린세스호텔', 'info': '3.9 ₩67,097,호텔', 'address': '서울특별시 강남구 신사동 641-1', 'phone': '02-544-0366'}
{'hotel': '머큐어 앰배서더 강남 쏘도베', 'info': '4.1 ₩116,838,4성급 호텔', 'address': '25-gi, 역삼1동 Gangnam-gu, Seoul', 'homepage': 'accorhotels.com', 'phone': '02-2050-6000'}

위와 같이 나옵니다.


어느분께는 도움이 되셨기를...






덧글

  • 은이 2017/12/07 13:56 # 답글

    지나가다가 슥슥...
    페이지가 어떻게 동작하는지 알아보며, 디버깅을 이용한 접근방법으로 아주 좋은 강의입니다만,
    별도의 레퍼런스화된 API를 제공하는 경우는(여기서는 구글맵API)
    해당 API를 이용하는게 올바른 방법이라는 부언 설명을 추가하면 좋을 것 같습니다 :)
  • 지훈현서아빠 2017/12/07 14:03 #

    넵! 아주 좋은 지적이십니다.
    자동화에 접근하는 방법은 참으로 다양하게 많습니다.
    위에 것은 단지 일반적인 웹 페이지에서 정보를 가져오는 일반적인
    방법을 기술한 것이라 이해해주시면 될 듯 합니다.
    고맙습니다. ^^
  • 웃긴 늑대개 2017/12/07 14:25 # 답글

    멋지네요! 숙박업 제공 사이트들이 이런 방식으로 동작하는걸까요?

    그런데 문제는... 가격으로 장난질 하는 인간 ㅠㅜ
댓글 입력 영역

구글애드텍스트