셀프 주유소

서울시 주유소 가격을 웹에서 얻어와서 셀프 주유소와 비교 분석한다.

뷰티플숲으로 웹페이지 정보를 얻어 올 수 있지만 그럴 때는 구체적으로 웹페이지 주소가 있어야 가능하다. 그렇지 않고 자바스크립트에 의해서 생성되는 문서들은 직접 클릭을 할 때만 문서가 생성되므로 뷰티플숲으로 정보를 가져오는 것이 불가능하다. 이럴 때 사용할 수 있는 것이 셀레늄(selenium)이다. 셀레늄은 웹브라우저 역할을 담당하는 프로그램으로 코딩을 이용해서 제어가 가능하다.

셀레늄

Selenium은 웹 브라우저의 기능을 하도록하는 모듈이다. 브라우저를 직접 실행하지 않고 selenium 메소드들을 이용해서 웹 브라우저 기능을 대신할 수 있게 한다. Selenium은 Selenium 2(Selenium WebDriver), Selenium 1(Selenium RC), Selenium IDE, Selenium-Grid 툴로 이루어 졌다. 우리가 사용하는 것은 Selenium 2(Selenium WebDriver)이다. 이것은 프로그래밍 언어(Java, C#, Python, Javascript등)에 맞는 인터페이스를 제공하여 프로그래밍을 이용하여 사용하기 편리하다.

Selenium 2를 이용하기 위해서는 웹 브라우저에 맞는 드라이버를 다운로드 해야 한다. 드라이버는 크롬, 파이어폭스, PhantomJS등이 있다. 여기서는 브라우저를 실행시키지 않고 사용할 수 있는 크롬 드라이버(headless 옵션 사용)를 이용한다. 파이썬에서 사용하는 selenium에 대한 문서는 http://selenium-python.readthedocs.io/index.html을 참고한다. 더 자세한 사용법은 Selenium 파이썬 웹드라이버 API를 참조하자.

설치

아나콘다 명령창에서 다음과 같이 입력한다.

conda install selenium

드라이버 다운로드

셀레늄을 파이썬에서 사용하기 위해서는 브라우저에 맞는 드라이버를 다운로드해야 한다. 우리는 크롬 브라우저를 사용할 것이기 때문에 크롬 드라이버를 인터넷으로부터 다운받아 작업 디렉토리 아래 drivers 폴더에 넣는다.

In [1]: import urllib.request
   ...: import os
   ...: 
   ...: driver_dir = 'drivers'
   ...: driver_ver = '2.42'
   ...: if not os.path.exists(driver_dir):
   ...:   os.makedirs(driver_dir)
   ...: 
   ...: url = 'https://chromedriver.storage.googleapis.com/' + driver_ver + '/chromedriver_win32.zip'
   ...: _, zip_file = os.path.split(url)
   ...: 
   ...: zip_path = os.path.join(driver_dir, zip_file)
   ...: 
   ...: if not os.path.exists(zip_path):
   ...:   urllib.request.urlretrieve(url, zip_path)
   ...: 

압축해제

다운받은 파일을 압축해제한다.

In [10]: import zipfile
   ....: 
   ....: if os.path.exists(zip_path):
   ....:   zip_ref = zipfile.ZipFile(zip_path, 'r')
   ....:   for fname in zip_ref.namelist():
   ....:     fpath = os.path.join(driver_dir, fname)
   ....:     if not os.path.exists(fpath):
   ....:       zip_ref.extract(fname, driver_dir)
   ....:   zip_ref.close()
   ....: 

간단한 사용법

설치된 웹드라이버 경로를 설정한다. 웹드라이버는 웹 브라우저에 해당하는 것이라고 생각할 수 있다. 여기서는 크롬 드라이버를 사용한다. 드라이버 경로 설정은 아래와 같이 드라이버의 위치를 직접 지정해 주는 방법도 있고 웹드라이버가 운영체제의 실행 경로에 위치해 있어도 된다.

In [12]: chrome_path = os.path.join(driver_dir, 'chromedriver.exe')

셀레늄에서 드라이버를 생성하기 위한 모듈인 webdriver를 import하고 크롬 드라이버를 이용해서 웹드라이버 객체를 생성한다. 웹드라이버 객체가 브라우저 역할을 하게 된다.

In [13]: from selenium import webdriver
   ....: driver = webdriver.Chrome(chrome_path)
   ....: 

웹드라이버의 get() 메소드를 이용해 파이썬 홈페이지에 접속한다.

In [14]: driver.get("http://www.python.org")

find_element_by_name은 속성(attribute)이 name인 성분(element)을 찾아 반환한다. 성분이란 HTML의 태그들이라고 생각할 수 있다. 다음은 name='q'인 성분을 elem 변수에 지정하고 elem.clear() 메소드를 이용해서 입력(input)란을 모두 지운다.

In [15]: elem = driver.find_element_by_name("q")
   ....: elem.clear()
   ....: 

elem.send_keys() 메소드는 입력란에 문자를 입력한다.

In [16]: elem.send_keys("pycon")

Keys.RETURN은 키보드 리턴키에 해당하는 것으로 엔터를 치는 효과를 낸다.

In [17]: from selenium.webdriver.common.keys import Keys
   ....: elem.send_keys(Keys.RETURN)
   ....: 

이것을 실행하면 크롬 브라우저가 뜨고 파이썬 홈페이지에 접속해서 pycon을 검색한다.

driver.close()를 사용하여 활동 브라우저 탭을 닫거나 driver.quit()을 이용하여 활동 브라우저 창을 종료할 수 있다.

주유소 가격

한국석유공사에서 제공하는 사이트 오피넷(opinet)을 이용해서 전국 주유소의 가격을 얻어와 보자.

In [18]: driver = webdriver.Chrome(chrome_path)

지역별 주유소 찾기 페이지로 이동한다.

In [19]: driver.get("http://www.opinet.co.kr/searRgSelect.do")

시도 선택 성분을 찾는다.

In [20]: sido = driver.find_element_by_css_selector("#SIDO_NM0")

시도 선택 리스트를 모두 찾는다. 여기서 주의해야 할 것은 find_elements_*로 복수로 되어 있다는 것이다. 단수로 되어 있으면 첫번째 것을 반환하고 복수로 되어 있으면 모든 것을 찾아 리스트로 반환한다.

In [21]: sido_options = sido.find_elements_by_tag_name('option')

시도 선택 리스트를 만든다. value 값이 없는 것은 포함시키지 않는다.

In [22]: sido_list = [option.get_attribute('value') for option in sido_options if option.get_attribute('value')]
   ....: sido_list
   ....: 

서울특별시를 선택하자.

In [23]: from selenium.webdriver.support.select import Select
   ....: sido_select = Select(sido)
   ....: sido_select.select_by_value(sido_list[0])
   ....: 

시군구 선택 성분을 찾는다.

In [24]: sigg = driver.find_element_by_css_selector("#SIGUNGU_NM0")
   ....: sigg.tag_name
   ....: 
Out[24]: 'select'

시군구 리스트를 만든다.

In [25]: sigg_list = [option.get_attribute('value') for option in sigg_options \
   ....: if option.get_attribute('value')]
   ....: sigg_list
   ....: 
Out[25]: 
['강남구',
 '강동구',
 '강북구',
 '강서구',
 '관악구',
 '광진구',
 '구로구',
 '금천구',
 '노원구',
 '도봉구',
 '동대문구',
 '동작구',
 '마포구',
 '서대문구',
 '서초구',
 '성동구',
 '성북구',
 '송파구',
 '양천구',
 '영등포구',
 '용산구',
 '은평구',
 '종로구',
 '중구',
 '중랑구']

첫번째로 나오는 강남구를 선택해보자.

In [26]: sigg_select = Select(sigg)
   ....: sigg_select.select_by_value(sigg_list[0])
   ....: 

조회 버튼 성분을 찾고 클릭을 한다.

In [27]: submit = driver.find_element_by_css_selector("#searRgSelect")
   ....: submit.click()
   ....: driver.close()
   ....: 

엑셀 파일 저장하기

헤드리스 크롬 드라이버로 파일을 저장하려면 다음과 같은 함수를 실행한 후 다운로드를 실행해야 한다. 실행할 때 인자로 웹드라이버 객체와 다운로드 디렉토리 문자열을 넘긴다.

In [28]: save_excel = driver.find_element_by_css_selector("#glopopd_excel")
   ....: save_excel.click()
   ....: 

모든 자치구에 대해서 일괄적으로 실행을 하자.

In [29]: import time
   ....: from selenium import webdriver
   ....: from selenium.webdriver.support.select import Select
   ....: 
   ....: driver_dir = 'drivers'
   ....: chrome_path = os.path.join(driver_dir, 'chromedriver.exe')
   ....: driver = webdriver.Chrome(chrome_path)
   ....: # 시도 선택
   ....: sido = driver.find_element_by_css_selector("#SIDO_NM0")
   ....: sido_options = sido.find_elements_by_tag_name('option')
   ....: sido_list = [option.get_attribute('value') for option in sido_options if option.get_attribute('value')]
   ....: sido_select = Select(sido)
   ....: # 서울특별시 선택
   ....: sido_select.select_by_value(sido_list[0])
   ....: # 시군구 선택
   ....: sigg = driver.find_element_by_css_selector("#SIGUNGU_NM0")
   ....: sigg_options = sigg.find_elements_by_tag_name('option')
   ....: # 서울시 자치구 리스트
   ....: sigg_list = [option.get_attribute('value') for option in sigg_options if option.get_attribute('value')]
   ....: # 모든 자치구에 대해서 엑셀 파일 저장
   ....: for gu in sigg_list:
   ....:   sigg = driver.find_element_by_css_selector("#SIGUNGU_NM0")
   ....:   sigg_select = Select(sigg)
   ....:   sigg_select.select_by_value(gu)
   ....:   time.sleep(1)
   ....:   save_excel = driver.find_element_by_css_selector("#glopopd_excel")
   ....:   save_excel.click()
   ....:   time.sleep(3)
   ....:   print(gu + " 저장됨.")
   ....: driver.close()
   ....: 

파일들은 기본적으로 다운로드 폴더에 저장이 된다. 저장된 주유소 엑셀 파일들을 작업 디렉토리에 밑에 data/주유소/서울 디렉토리로 옮긴다.

파일 불러오기

모든 파일을 glob 모듈과 판다스를 이용해서 읽어온다. tmp 리스트를 만들어 각각의 데이터프레임을 성분으로 저장한다.

In [30]: import pandas as pd
   ....: pd.set_option('max_rows', 10)
   ....: from glob import glob
   ....: 
   ....: gas_stn_files = glob("./data/주유소/서울시/*.xls")
   ....: tmp = []
   ....: for stn in gas_stn_files:
   ....:   tmp.append(pd.read_excel(stn, header=2))
   ....: tmp[0]
   ....: 
Out[35]: 
       지역                  상호                          주소      상표          전화번호 셀프여부 고급휘발유   휘발유    경유  실내등유
0   서울특별시              동서울주유소  서울특별시 강동구  천호대로 1456 (상일동)   GS칼텍스   02-426-5372    Y     -  1675  1485     -
1   서울특별시      GS칼텍스㈜직영 신월주유소      서울 강동구 양재대로 1323 (성내동)   GS칼텍스   02-475-2600    N  1918  1679  1489  1100
2   서울특별시    주)지유에너지직영 오렌지주유소    서울특별시 강동구  성안로 102 (성내동)   SK에너지   02-484-6165    N     -  1697  1497  1097
3   서울특별시              구천면주유소       서울 강동구 구천면로 357 (암사동)  현대오일뱅크   02-441-0536    N     -  1697  1495     -
4   서울특별시     (주)퍼스트오일 코알라주유소   서울특별시 강동구  올림픽로 556 (성내동)   S-OIL   02-484-1162    Y     -  1718  1518     -
..    ...                 ...                         ...     ...           ...  ...   ...   ...   ...   ...
13  서울특별시      (주)삼표에너지 고덕주유소         서울 강동구 고덕로 39 (암사동)   GS칼텍스   02-441-3327    N  1989  1789  1589  1180
14  서울특별시               명일주유소        서울 강동구 고덕로 168 (명일동)   SK에너지  02-3428-1739    N  1964  1799  1649     -
15  서울특별시      SK네트윅스(주)길동주유소       서울 강동구 천호대로 1221 (길동)   SK에너지   02-488-3491    Y  1966  1813  1623     -
16  서울특별시  CJ대한통운(주)직영 천호점주유소         서울 강동구 천중로 67 (천호동)  현대오일뱅크   02-473-5189    N     -  1837  1638  1300
17  서울특별시               강동주유소       서울 강동구 양재대로 1509 (길동)   SK에너지   02-477-5101    N  2373  2138  1917  1417

[18 rows x 10 columns]

pd.concat 함수를 이용해서 데이터프레임 리스트를 하나의 데이터프레임으로 만든다.

In [36]: gas_stn = pd.concat(tmp)

각 열의 타입을 알아보자.

In [37]: gas_stn.applymap(lambda x: type(x)).apply(lambda x: list(x.unique()))
Out[37]: 
지역                      [<class 'str'>]
상호                      [<class 'str'>]
주소                      [<class 'str'>]
상표                      [<class 'str'>]
전화번호                    [<class 'str'>]
셀프여부                    [<class 'str'>]
고급휘발유                   [<class 'str'>]
휘발유      [<class 'int'>, <class 'str'>]
경유       [<class 'int'>, <class 'str'>]
실내등유                    [<class 'str'>]
dtype: object

휘발유와 경유는 str, int 타입으로 구성되어 있고 나머지는 str 타입임을 알 수 있다.

휘발유 경유 열에 대해서 int 타입을 str 타입으로 변경한 후 숫자가 아닌 것을 찾아 내보자. 휘발유, 경유 열만 따로 뽑아내자.

In [38]: df = gas_stn.filter(regex=r'^휘발유|경유')

타입을 str로 변경하자.

In [39]: df = df.astype(str)

숫자가 아닌 행들을 보여보자.

In [40]: df.apply(lambda x: x[~x.str.match(r'\d+')])
Out[40]: 
   휘발유 경유
13   -  -
25   -  -
33   -  -
34   -  -
12   -  -
17   -  -
20   -  -
41   -  -

다행히 - 문자들 밖에 없으므로 강제로 형변환을 해도 문제가 되지 않는다. 다른 종류의 기름 열에 대해서도 알아보자.

다음은 휘발유, 경유가 아닌 열을 뽑아낸 것이다.

In [41]: import numpy as np
   ....: col = np.setdiff1d(gas_stn.columns[gas_stn.columns.str.endswith('유')], df.columns.values)
   ....: dfc = gas_stn[col]
   ....: dfc
   ....: 
Out[44]: 
   고급휘발유  실내등유
0      -     -
1   1918  1100
2      -  1097
3      -     -
4      -     -
..   ...   ...
37  2388     -
38  2354  1279
39  2354     -
40  2590  1380
41     -     -

[518 rows x 2 columns]

숫자열이 아닌 행들의 종류를 알아보자.

In [45]: dfc.apply(lambda x: [x[~x.str.match(r'\d+')].unique()])
Out[45]: 
고급휘발유    [[-]]
실내등유     [[-]]
dtype: object

문자 - 외에는 다른 것이 없음을 알 수 있다. -의 갯수를 세어보자.

In [46]: dfc.apply(lambda x: sum(~x.str.match(r'\d+')))
Out[46]: 
고급휘발유    328
실내등유     293
dtype: int64

데이터프레임의 타입을 변경하자. 기름 열에 해당하는 타입을 숫자형으로 강제 변형한다.

In [47]: col_names = gas_stn.columns[gas_stn.columns.str.endswith('유')]
   ....: gas_stn[col_names] = gas_stn[col_names].apply(pd.to_numeric, errors='coerce')
   ....: 

인덱스를 다시 만들자.

In [49]: gas_stn.reset_index(inplace=True)
   ....: gas_stn.drop(columns='index', inplace=True)
   ....: 

자치구 열을 추가하자.

주소 열에서 자치구만 뽑아내자.

In [51]: gas_stn.주소.str.extract(r'(\w*구)\b').squeeze().unique()
Out[51]: 
array(['강동구', '동대문구', '동작구', '마포구', '서대문구', '서초구', '성동구', '성북구', '송파구',
       '양천구', '영등포구', '강북구', '용산구', '은평구', '종로구', '중구', '중랑구', '강서구',
       '관악구', '광진구', '구로구', '금천구', '노원구', '도봉구', '강남구'], dtype=object)

자치구 열을 추가하자.

In [52]: gas_stn['자치구'] = gas_stn.주소.str.extract(r'(\w*구)\b')
   ....: gas_stn
   ....: 
Out[53]: 
        지역                상호                          주소      상표          전화번호 셀프여부   고급휘발유     휘발유      경유    실내등유  자치구
0    서울특별시            동서울주유소  서울특별시 강동구  천호대로 1456 (상일동)   GS칼텍스   02-426-5372    Y     NaN  1675.0  1485.0     NaN  강동구
1    서울특별시    GS칼텍스㈜직영 신월주유소      서울 강동구 양재대로 1323 (성내동)   GS칼텍스   02-475-2600    N  1918.0  1679.0  1489.0  1100.0  강동구
2    서울특별시  주)지유에너지직영 오렌지주유소    서울특별시 강동구  성안로 102 (성내동)   SK에너지   02-484-6165    N     NaN  1697.0  1497.0  1097.0  강동구
3    서울특별시            구천면주유소       서울 강동구 구천면로 357 (암사동)  현대오일뱅크   02-441-0536    N     NaN  1697.0  1495.0     NaN  강동구
4    서울특별시   (주)퍼스트오일 코알라주유소   서울특별시 강동구  올림픽로 556 (성내동)   S-OIL   02-484-1162    Y     NaN  1718.0  1518.0     NaN  강동구
..     ...               ...                         ...     ...           ...  ...     ...     ...     ...     ...  ...
513  서울특별시              선우상사     서울 강남구 남부순환로 2651 (도곡동)   SK에너지  02-3462-5100    N  2388.0  2183.0  1997.0     NaN  강남구
514  서울특별시             오천주유소       서울 강남구 봉은사로 503 (삼성동)   SK에너지   02-545-2822    N  2354.0  2188.0  1986.0  1279.0  강남구
515  서울특별시             삼성주유소        서울 강남구 삼성로 521 (삼성동)   SK에너지   02-538-0809    N  2354.0  2188.0  1986.0     NaN  강남구
516  서울특별시           뉴서울(강남)        서울 강남구 언주로 716 (논현동)   SK에너지   02-518-5631    N  2590.0  2290.0  2090.0  1380.0  강남구
517  서울특별시             동우주유소   서울특별시 강남구  봉은사로 311 (논현동)   SK에너지   02-542-6726    N     NaN     NaN     NaN     NaN  강남구

[518 rows x 11 columns]

디비로 저장하자.

In [54]: conn = sqlite3.connect('./data/gas_station.sqlite3')
   ....: gas_stn.to_sql('gas_station', conn, index=False)
   ....: conn.close()
   ....: 

셀프 주유소 가격 비교

상자그림(boxplot)을 이용해서 휘발유 가격의 셀프 주유소와 일반 주유소를 비교한다.

In [55]: from matplotlib import font_manager, rc
   ....: font_name = font_manager.FontProperties(fname="c:/Windows/Fonts/malgun.ttf").get_name()
   ....: rc('font', family=font_name)
   ....: import seaborn as sns
   ....: ax = sns.boxplot(data=gas_stn, x='셀프여부', y='휘발유', palette='Set3')
   ....: 
_images/plot_gas_self_box.png

상표별로 셀프와 일반 주유소와 가격을 비교하자.

In [60]: ax.clear()
   ....: sns.boxplot(data=gas_stn, x='상표', y='휘발유', hue='셀프여부', palette='Set3')
   ....: 
Out[61]: <matplotlib.axes._subplots.AxesSubplot at 0x156b2df8320>
_images/plot_gas_self_brand_box.png

swarmplot을 이용하면 자료의 분포를 더 자세히 알 수 있다.

In [62]: ax.clear()
   ....: sns.boxplot(data=gas_stn, x='상표', y='휘발유', palette='Set3')
   ....: sns.swarmplot(data=gas_stn, x='상표', y='휘발유', size=3, color=".6")
   ....: 
Out[63]: <matplotlib.axes._subplots.AxesSubplot at 0x156b2df8320>
Out[64]: <matplotlib.axes._subplots.AxesSubplot at 0x156b2df8320>
_images/plot_gas_self_brand_box_swarm.png

경위도 좌표 가져오기

In [65]: def getCoords(key, addr):
   ....:   """경위도 좌표 반환"""
   ....:   url =  "http://api.vworld.kr/req/search?key={key}&request=search&type=address&category=road&page=1&size=10&query={query}&format=json"
   ....:   res = requests.get(url.format(key=key, query=addr)).json()
   ....:   resp = res['response']
   ....:   if resp['status'] == 'OK':
   ....:     item = resp['result']['items'][0]
   ....:     return item['point']
   ....:   else:
   ....:     return {'x': '', 'y': ''}
   ....: 

가격이 상위 10개, 하위 10개만 표시하자.

In [66]: bot10 = gas_stn.sort_values('휘발유').head(10)
   ....: top10 = gas_stn.sort_values('휘발유', ascending=False).head(10)
   ....: 
In [68]: coords = {'위도': [], '경도': []}
   ....: for addr in top10.주소:
   ....:   res = getCoords(key, addr)
   ....:   coords['위도'].append(res['y'])
   ....:   coords['경도'].append(res['x'])
   ....:   print(addr, res)
   ....: