2018년 7월 시카고 맛집 50

이 장에서는 시카고 시의 최고의 맛집 50군데를 웹페이지를 통해 정보를 가져와 가공하는 것에 대해서 알아본다. 시카고 매거진 홈페이지를 통해 정보를 얻는다.

Beautiful Soup

뷰티플숲은 html을 파싱하여 원하는 정보를 쉽게 얻을 수 있도록 돕는 도구이다. 아나콘다 설치시 함께 설치되므로 바로 사용할 수 있다.

시카고 식당 홈페이지 접속해서 웹페이지 내용을 가져온다.

In [1]: import requests
   ...: url = "http://www.chicagomag.com/dining-drinking/July-2018/The-50-Best-Restaurants-in-Chicago/"
   ...: html = requests.get(url).text
   ...: 

Beautiful Soup을 이용해서 파싱을 한다.

In [4]: import bs4
   ...: bs = bs4.BeautifulSoup(html, 'html.parser')
   ...: bs
   ...: 
Out[4]: 
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Urbis magnitudo. Fabulas magnitudo. -->
<meta charset="utf-8"/>
<style>a.edit_from_site {display: none !important;}</style>
<title>
  The 50 Best Restaurants in Chicago |
  Chicago magazine
      | Dining &amp; Drinking July 2018
    </title>
... 중간 생략 ...
<![endif]-->
<script language="JavaScript"   src="http://media.chicagomag.com//core/media/js/base.js?ver=1473876728"   type="text/javascript"></script>
<script language="JavaScript"   src="http://media.chicagomag.com//core/media/themes/Respond/js/bootstrap.min.js?  ver=1473876729" type="text/javascript"></script>
<script language="JavaScript" src="//maps.googleapis.com/maps/api/js?v=3.exp&amp; sensor=false" type="text/javascript"></script>
<script language="JavaScript"   src="http://media.chicagomag.com//theme_overrides/Respond/js/newsletter-subscribe.js? ver=1524850607" type="text/javascript"></script>
<script language="JavaScript"   src="http://media.chicagomag.com//theme_overrides/Respond/js/RivistaGoogleDFP.js? ver=1447178886" type="text/javascript"></script>
<!-- godengo-monitor --></body>
</html>

BeautifulSoup의 select 메소드를 이용하면 CSS 문법을 이용해서 a 태그의 rest 클래스만 찾아낼 수 있다.

In [5]: rest_classes = bs.select('a.rest')
   ...: rest_classes[:3]
   ...: 
Out[6]: 
[<a class="rest top" cost-sort="five" cuisine-sort="contemp" data-sort="1" href="/dining-drinking/July-2018/The-50-Best-Restaurants-in-Chicago/Alinea/" target="_blank">
 <h1>1</h1>
 <img src="alinea-2.jpg"/>
 <h2>Alinea</h2>
 </a>,
 <a class="rest top" cost-sort="five" cuisine-sort="contemp" data-sort="2" href="/dining-drinking/July-2018/The-50-Best-Restaurants-in-Chicago/Oriole/" target="_blank">
 <h1>2</h1>
 <img src="oriole-2.jpg"/>
 <h2>Oriole</h2>
 </a>,
 <a class="rest top" cost-sort="three" cuisine-sort="contemp-american" data-sort="3" href="/dining-drinking/July-2018/The-50-Best-Restaurants-in-Chicago/Lula-Cafe/" target="_blank">
 <h1>3</h1>
 <img src="lula-2.jpg"/>
 <h2>Lula Cafe</h2>
 </a>]

rest_classes의 각 항목들은 BeautifulSoup의 태그(Tag) 객체이다. 태그 객체의 attrs 속성은 태그의 속성들에 대한 사전형 객체를 반환한다. rest_classes의 a 태그 안에는 h2 자식 태그(children tag)가 있고 그것이 식당이름인 것을 알 수 있다. 식당 이름, 하위 url, 정렬 분류 항목(*-sort)들을 뽑아내어 데이터프레임으로 만들자.

In [7]: import pandas as pd
   ...: pd.set_option('max_rows', 10)
   ...: rest_lst = []
   ...: for rest in rest_classes:
   ...:   rest.attrs['name'] = rest.h2.text
   ...:   rest_lst.append(rest.attrs)
   ...: df = pd.DataFrame(rest_lst)
   ...: df
   ...: 
Out[10]: 
          class cost-sort      cuisine-sort data-sort                                               href            name  target
0   [rest, top]      five           contemp         1  /dining-drinking/July-2018/The-50-Best-Restaur...          Alinea  _blank
1   [rest, top]      five           contemp         2  /dining-drinking/July-2018/The-50-Best-Restaur...          Oriole  _blank
2   [rest, top]     three  contemp-american         3  /dining-drinking/July-2018/The-50-Best-Restaur...       Lula Cafe  _blank
3   [rest, top]      five           contemp         4  /dining-drinking/July-2018/The-50-Best-Restaur...           Schwa  _blank
4   [rest, top]      five           mexican         5  /dining-drinking/July-2018/The-50-Best-Restaur...     Topolobampo  _blank
..          ...       ...               ...       ...                                                ...             ...     ...
45       [rest]       one          barbecue        46  /dining-drinking/July-2018/The-50-Best-Restaur...          Smoque  _blank
46       [rest]     three            korean        47  /dining-drinking/July-2018/The-50-Best-Restaur...       Parachute  _blank
47       [rest]      four        steakhouse        48  /dining-drinking/July-2018/The-50-Best-Restaur...       Boeufhaus  _blank
48       [rest]     three     mediterranean        49  /dining-drinking/July-2018/The-50-Best-Restaur...  The Purple Pig  _blank
49       [rest]       two              thai        50  /dining-drinking/July-2018/The-50-Best-Restaur...    Rainbow Thai  _blank

[50 rows x 7 columns]

위에서 rest.attrs 자료에 name 항목을 추가했으므로 rest_classes의 각 태그에 name 항목이 추가된 것을 알 수 있다. cost-sort는 가격대별, cuisine-sort는 요리법별, data-sort는 순위별 정렬을 나타낸 열이다.

데이터프레임의 필요없는 열들 class, target은 제거하자.

In [11]: df.drop(columns=['class', 'target'], inplace=True)
   ....: df.columns
   ....: 
Out[12]: Index(['cost-sort', 'cuisine-sort', 'data-sort', 'href', 'name'], dtype='object')

열 이름 중에 -sort를 제거하여 열 이름을 변경하자.

In [13]: import re
   ....: df.rename(columns=lambda x: re.sub(r'-sort', '', x), inplace=True)
   ....: df.columns
   ....: 
Out[15]: Index(['cost', 'cuisine', 'data', 'href', 'name'], dtype='object')

가격대를 지정하자.

In [16]: df['price'] = df.cost.map({'one': '$10-$19', 'two': '$20-$39', 'three': '$40-$69', 'four': '$70-$99', 'five': '$100 OR MORE'})
   ....: df
   ....: 
Out[17]: 
     cost           cuisine data                                               href            name         price
0    five           contemp    1  /dining-drinking/July-2018/The-50-Best-Restaur...          Alinea  $100 OR MORE
1    five           contemp    2  /dining-drinking/July-2018/The-50-Best-Restaur...          Oriole  $100 OR MORE
2   three  contemp-american    3  /dining-drinking/July-2018/The-50-Best-Restaur...       Lula Cafe       $40-$69
3    five           contemp    4  /dining-drinking/July-2018/The-50-Best-Restaur...           Schwa  $100 OR MORE
4    five           mexican    5  /dining-drinking/July-2018/The-50-Best-Restaur...     Topolobampo  $100 OR MORE
..    ...               ...  ...                                                ...             ...           ...
45    one          barbecue   46  /dining-drinking/July-2018/The-50-Best-Restaur...          Smoque       $10-$19
46  three            korean   47  /dining-drinking/July-2018/The-50-Best-Restaur...       Parachute       $40-$69
47   four        steakhouse   48  /dining-drinking/July-2018/The-50-Best-Restaur...       Boeufhaus       $70-$99
48  three     mediterranean   49  /dining-drinking/July-2018/The-50-Best-Restaur...  The Purple Pig       $40-$69
49    two              thai   50  /dining-drinking/July-2018/The-50-Best-Restaur...    Rainbow Thai       $20-$39

[50 rows x 6 columns]

요리법(cuisine)별로 갯수를 세어보자.

In [18]: df.cuisine.value_counts()
Out[18]: 
contemp             15
american             5
steakhouse           4
japanese             3
contemp-american     3
                    ..
peruvian             1
southern             1
barbecue             1
pizza                1
macanese             1
Name: cuisine, Length: 18, dtype: int64

한국 음식도 3개가 있는 것을 알 수 있다.

각 식당의 자세한 정보를 접근해서 가져와보자. 각 식당 접속 주소를 만든다. 공통 주소 base_url='http://www.chicagomag.com/'을 만든다.

In [19]: base_url = re.match(r'(.*)/dining', url)[1]
   ....: base_url
   ....: 
Out[20]: 'http://www.chicagomag.com'

base_url과 각 식당 사이트 주소를 합쳐 url 열을 새로 만든다.

In [21]: df['url'] = base_url + df['href']

모든 식당의 정보를 가져오기 전에 첫번째 것에 대해서 실행해보자.

In [22]: html1 = requests.get(df['url'][0]).text
   ....: bs1 = bs4.BeautifulSoup(html1, 'html.parser')
   ....: rest1 = bs1.select('div.body-target > section.related-content.pull-right')[0]
   ....: rest1
   ....: 
Out[25]: 
<section class="related-content pull-right">
<div class="mini-map" id="mini-map"> </div>
<script>
      var latitude = 41.913455;
      var longitude = -87.648164;
      var mapID = "mini-map";
      makeMiniMap(latitude,longitude,mapID);
      </script>
<h3>Alinea</h3>
<ul>
<li>1723 N. Halsted St.</li>
<li>Lincoln Park</li>
<li>Contemporary</li>
<li>$$$$$</li>
</ul>
</section>

주소는 다음과 같다.

In [26]: address = rest1.select('li')[0].text

경위도 좌표는 다음과 같이 얻을 수 있다. 새 줄 표시(\r\n)를 없애고 하나의 줄로 만든다.

In [27]: lat_lon_txt = re.sub(r'\r\n', '', rest1.script.contents[0])
   ....: lat_lon_txt
   ....: 
Out[28]: '      var latitude = 41.913455;      var longitude = -87.648164;      var mapID = "mini-map";      makeMiniMap(latitude,longitude,mapID);      '

위도, 경도 좌표를 뽑아낸다.

In [29]: (lat, lon) = re.findall(r'lat.*=\s(.*\d);.*long.*=\s(.*\d);', lat_lon_txt)[0]
   ....: lat, lon
   ....: 
Out[30]: ('41.913455', '-87.648164')

모든 식당에 대해서 자료를 얻어오자.

In [31]: tmp = {'address': [], 'lat': [], 'lon': []}
   ....: for url in df['url']:
   ....:   html = requests.get(url).text
   ....:   bs = bs4.BeautifulSoup(html, 'html.parser')
   ....:   rest = bs.select('div.body-target > section.related-content.pull-right')[0]
   ....:   tmp['address'].append(rest.select('li')[0].text)
   ....:   lat_lon_txt = re.sub(r'\r\n', '', rest.script.contents[0])
   ....:   (lat, lon) = re.findall(r'lat.*=\s(.*\d);.*long.*=\s(.*\d);', lat_lon_txt)[0]
   ....:   tmp['lat'].append(lat)
   ....:   tmp['lon'].append(lon)
   ....: 

얻어온 자료를 데이터프레임으로 만들고 기존의 데이터프레임과 합친다.

In [32]: tmp_df = pd.DataFrame(tmp)
   ....: rest_df = pd.concat([df, tmp_df], axis=1)
   ....: 

디비에 저장하기 전에 타입을 변경하자.

In [34]: rest_df.lat = rest_df.lat.astype('float')
   ....: rest_df.lon = rest_df.lon.astype('float')
   ....: rest_df.data = rest_df.data.astype('int')
   ....: 

디비에 저장하자. data 폴더에 chicago.sqlite3라는 디비를 만들어 저장하자.

In [37]: import sqlite3
   ....: conn = sqlite3.connect('./data/chicago.sqlite3')
   ....: rest_df.to_sql('best_restaurant', conn)
   ....: conn.close()
   ....: 

맛집들을 지도에 표시해보자. 디비에서 식당 이름, 위도, 경도를 추출하자.

In [38]: import sqlite3
   ....: conn = sqlite3.connect('./data/chicago.sqlite3')
   ....: rest_pos = pd.read_sql("select name, lat, lon from best_restaurant", conn)
   ....: conn.close()
   ....: 

위도, 경도의 평균값을 구하여 지도의 중심 좌표로 삼고 위치를 표시한다.

In [42]: import folium
   ....: lat = rest_pos.lat.mean()
   ....: lon = rest_pos.lon.mean()
   ....: map_rest = folium.Map(location=(lat, lon), zoom_start=10)
   ....: for _, row in rest_pos.iterrows():
   ....:   folium.Marker(
   ....:     location=(row['lat'], row['lon']),
   ....:     popup=row['name']
   ....:   ).add_to(map_rest)
   ....: map_rest
   ....: 

네이버 영화 평점

네이버 영화 평점 사이트를 접속해서 정보를 가져오자. 한국영화진흥협회에서는 더 자세한 정보를 api를 통해 제공하고 있다. 영화관입장권통합전산망 API를 참고한다.

In [43]: url = "https://movie.naver.com/movie/sdb/rank/rmovie.nhn?sel=cur&date={date}"
   ....: date = "20181014"
   ....: html = requests.get(url.format(date=date)).text
   ....: bs = bs4.BeautifulSoup(html, 'html.parser')
   ....: 

영화 제목과 평점들만 출력해보자.

In [47]: for item in bs.select("#old_content > table > tbody > tr"):
   ....:   title = item.select("div.tit5 a")
   ....:   point = item.select("td.point")
   ....:   if title:
   ....:     print(title[0].text, point[0].text)
   ....: 
스타 이즈 본 9.27
어느 가족 9.24
극장판 헬로카봇 : 백악기 시대 9.24
미쓰백 9.20
카메라를 멈추면 안 돼! 9.17
타샤 튜더 9.14
보디가드 9.01
루이스 9.00
맘마미아!2 8.99
곰돌이 푸 다시 만나 행복해 8.99
탑건 8.91
소공녀 8.88
서치 8.88
리틀 포레스트 8.75
신비아파트: 금빛 도깨비와 비밀의 동굴 8.74
너의 결혼식 8.53
박화영 8.48
신은 죽지 않았다 3: 어둠 속의 빛 8.39
그랜드 부다페스트 호텔 8.36
암수살인 8.35
죄 많은 소녀 8.32
안시성 8.15
피아니스트 8.11
프란시스 하 8.08
베놈 8.03
협상 7.92
파리로 가는 길 7.90
아이 엠 러브 7.89
신과함께-인과 연 7.72
원더풀 고스트 7.36
명당 7.32
킬링 디어 7.09
더 넌 6.93
물괴 5.14
상류사회 4.30

일정 기간에 대한 평점들을 수집해보자. 2018년 5월 1일부터 2018년 10월 14일까지에 대해서 수집하자.

In [48]: date_range = pd.date_range('2018-05-01', end='2018-10-14')
   ....: film_list = {'date': [], 'title': [], 'point': []}
   ....: for date in date_range:
   ....:   html = requests.get(url.format(date=date.strftime("%Y%m%d"))).text
   ....:   bs = bs4.BeautifulSoup(html, 'html.parser')
   ....:   for item in bs.select("#old_content > table > tbody > tr"):
   ....:     title = item.select("div.tit5 a")
   ....:     point = item.select("td.point")
   ....:     if title:
   ....:       print(title[0].text, point[0].text)
   ....:       film_list['date'].append(date)
   ....:       film_list['title'].append(title[0].text)
   ....:       film_list['point'].append(point[0].text)
   ....: 

데이터프레임으로 변경하자.

In [49]: film = pd.DataFrame(film_list)
   ....: film
   ....: 
Out[50]: 
           date        title point
0    2018-05-01           당갈  9.53
1    2018-05-01           덕구  9.50
2    2018-05-01           원더  9.40
3    2018-05-01       위대한 쇼맨  9.38
4    2018-05-01  지금, 만나러 갑니다  9.33
...         ...          ...   ...
7268 2018-10-14           명당  7.32
7269 2018-10-14        킬링 디어  7.09
7270 2018-10-14          더 넌  6.93
7271 2018-10-14           물괴  5.14
7272 2018-10-14         상류사회  4.30

[7273 rows x 3 columns]

디비로 저장하기 전에 타입을 변경하자.

In [51]: film.info()
   ....: film.point = film.point.astype('float')
   ....: 
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 7273 entries, 0 to 7272
Data columns (total 3 columns):
date     7273 non-null datetime64[ns]
title    7273 non-null object
point    7273 non-null object
dtypes: datetime64[ns](1), object(2)
memory usage: 170.5+ KB

./data/naver.sqlite3 파일 디비로 저장하자.

In [53]: conn = sqlite3.connect('./data/naver.sqlite3')
   ....: film.to_sql('film_rate', conn)
   ....: conn.close()
   ....: 

제목별로 평점이 가장 많은 순서로 정렬해보자.

In [54]: import numpy as np
   ....: by_title_sum = film.pivot_table(index='title', values='point', aggfunc=np.sum)
   ....: by_title_sum.sort_values('point', ascending=False)
   ....: 
Out[56]: 
                   point
title                   
소공녀              1457.52
당갈               1348.91
어벤져스: 인피니티 워     1255.07
시카리오: 데이 오브 솔다도   892.66
걸어도 걸어도           855.75
...                  ...
앤트맨                 8.60
언어의 정원              8.21
폭스캐처                8.15
머니백                 5.92
오션스 트웰브             5.78

[267 rows x 1 columns]

제목별로 평점의 평균이 가장 높은 순서로 정렬해보자.

In [57]: by_title_mean = film.pivot_table(index='title', values='point', aggfunc=np.mean)
   ....: by_title_mean.sort_values('point', ascending=False)
   ....: 
Out[58]: 
          point
title          
당갈     9.566738
아일라    9.555185
덕구     9.503333
허스토리   9.423382
원더     9.408780
...         ...
물괴     5.300000
레슬러    4.986905
상류사회   4.311379
인랑     4.012308
게이트    3.755294

[267 rows x 1 columns]

최장 기간 상영일 영화별로 정렬

In [59]: df = film.pivot_table(index='date', columns='title', values='point')
   ....: df
   ....: 
Out[60]: 
title       12 솔져스  1987    4등  500일의 썸머  5일의 마중  B급 며느리  개들의 섬  건축학개론  걸어도 걸어도  게이트 ...   한 솔로: 스타워즈 스토리  한여름의 판타지아  허스토리  현기증  혐오스런 마츠코의 일생    협상  호텔 아르테미스  환상의 빛  훌라 걸스  휘트니
date                                                                                 ...                                                                                        
2018-05-01    8.35   NaN  8.53       NaN     NaN    8.41    NaN    NaN      NaN  NaN ...              NaN        NaN   NaN  NaN           NaN   NaN       NaN   8.01    NaN  NaN
2018-05-02    8.33   NaN  8.53       NaN     NaN    8.41    NaN    NaN      NaN  NaN ...              NaN        NaN   NaN  NaN           NaN   NaN       NaN   8.01    NaN  NaN
2018-05-03    8.31   NaN  8.53       NaN     NaN    8.41    NaN    NaN      NaN  NaN ...              NaN        NaN   NaN  NaN           NaN   NaN       NaN   8.01    NaN  NaN
2018-05-04    8.31   NaN  8.53       NaN     NaN    8.41    NaN    NaN      NaN  NaN ...              NaN        NaN   NaN  NaN           NaN   NaN       NaN   8.01    NaN  NaN
2018-05-05     NaN   NaN  8.53       NaN     NaN     NaN    NaN    NaN      NaN  NaN ...              NaN        NaN   NaN  NaN           NaN   NaN       NaN   8.01    NaN  NaN
...            ...   ...   ...       ...     ...     ...    ...    ...      ...  ... ...              ...        ...   ...  ...           ...   ...       ...    ...    ...  ...
2018-10-10     NaN   NaN   NaN       NaN     NaN     NaN    NaN    NaN      NaN  NaN ...              NaN        NaN   NaN  NaN           NaN  7.93       NaN    NaN    NaN  NaN
2018-10-11     NaN   NaN   NaN       NaN     NaN     NaN    NaN    NaN      NaN  NaN ...              NaN        NaN   NaN  NaN           NaN  7.93       NaN    NaN    NaN  NaN
2018-10-12     NaN   NaN   NaN       NaN     NaN     NaN    NaN    NaN      NaN  NaN ...              NaN        NaN   NaN  NaN           NaN  7.93       NaN    NaN    NaN  NaN
2018-10-13     NaN   NaN   NaN       NaN     NaN     NaN    NaN    NaN      NaN  NaN ...              NaN        NaN   NaN  NaN           NaN  7.92       NaN    NaN    NaN  NaN
2018-10-14     NaN   NaN   NaN       NaN     NaN     NaN    NaN    NaN      NaN  NaN ...              NaN        NaN   NaN  NaN           NaN  7.92       NaN    NaN    NaN  NaN

[167 rows x 267 columns]

na가 아닌 날짜들의 숫자를 센다.

In [61]: days_played = df.notna().sum()