***개선된 버전을 SIR에 올렸습니다.
안녕하세요?
대한약사회 측에서 운영하는 '휴일지킴이 약국' 사이트를 크롤링하여 도로명 주소 등 데이터를 획득한 후에
위 주소를 국토교통부의 Geocoder API 2.0를 이용하여 경위도 좌표계의 좌표로 변환하여
Folium 및 PyQt5을 이용하여 마커로 표시하는 윈도우 앱입니다!
저는 윈도우 기준으로 작성을 하였지만 Folium에서 HTML로 export할 수 있기 때문에,
홈페이지를 운영하시는 분들도 참고하시면 유용하게 활용하실 것 같네요 ^-^
우선 휴일지킴약국 홈페이지(https://www.pharm114.or.kr/main.asp)에서의 검색 결과를 살펴보면,
각 약국마다 위치정보 및 특이사항의 입력 여부가 다른 등 데이터가 상이하기 때문에 tr 태그의 수가 일정하지 않습니다 ㅠㅠ
(하나의 약국 당 tr 태그 2~4개를 사용합니다.)
이런 점을 감안하여 누락되는 데이터 없이 모든 정보를 크롤링을 할 수 있도록 작성하였습니다~!
그리고 휴일지킴이 약국 사이트는 대한약사회 측에서 API를 제공하지 않고,
봇에 의한 크롤링을 방지하기 위한 약간의 장치들이 있더군요 ㅎㄷㄷ
예컨대 POST 전송시 formdata에 ss_key 값을 입력하여야 합니다.
다행히 이 장치들을 어렵지 않게 우회할 수 있었어요~ ^-^
사실 위 장치들을 우회하는 것보다 인코딩이 EUC-KR이었던 점이 더 귀찮았네요 ㅜㅜㅜㅜㅜ
크롬 개발자도구에서 POST 전송의 Formdata를 확인하면 (unable to decode value)라고 뜨더군요!
사이트 소스를 확인해보면 사이트 자체는 EUC-KR이지만, 일부 처리 과정 있어 UTF-8로 인코딩하는 로직이 있어요.
다음 소스에서 br 태그를 직접 입력하면, 게시글을 볼 때 태그가 출력되는 것이 아니라 개행이 되기 때문에 부득이 'BR태그'라고 적었네요 ㅠㅠ
from PyQt5 import QtWidgets, QtWebEngineWidgets from requests_html import HTMLSession from bs4 import BeautifulSoup import numpy as np from datetime import datetime, timedelta import folium, json, sys, io from folium.plugins import MarkerCluster def crawling_pharm114(): # 휴일지킴이 약국 사이트를 크롤링하는 함수입니다. s = HTMLSession() html = s.get('https://www.pharm114.or.kr/common_files/sub1_page1.asp').content soup = BeautifulSoup(html, 'html5lib') ss_key = soup.find('input', {'name' : 'ss_key'})['value'] # ss_key 값을 구합니다. now = datetime.now() # 다음 세 줄은 30분 간격으로 시간을 나누는 스크립트이나, 현재 시간을 직접 formdata에 대입하여도 무방합니다. delta = timedelta(minutes = 30) start_time = (now - (now - datetime.min) % delta).strftime('%H:%M') end_time = (now + (datetime.min - now) % delta).strftime('%H:%M') formdata = { 'search_first': 'T', 'ss_key': ss_key, 'm_year': now.year, 'm_month': '{:02d}'.format(now.month), # 반드시 이렇게 두 자리로 처리하지 않아도 무방합니다. 'm_day': '{:02d}'.format(now.day), # 반드시 이렇게 두 자리로 처리하지 않아도 무방합니다. 'time_s1': start_time, 'time_e1': end_time, 'addr1': '서울특별시'.encode('euc-kr'), # 인코딩을 euc-kr로 변경합니다. 'addr2': '서초구'.encode('euc-kr'), # 인코딩을 euc-kr로 변경합니다. 'image1.x': '22', # 마우스 위치를 감지하여 대입하는 부분이며, 적절한 값을 입력하면 무방합니다. 'image1.y': '11', # 마우스 위치를 감지하여 대입하는 부분이며, 적절한 값을 입력하면 무방합니다. #'addr3': '검색할 키워드'.encode('euc-kr') 결과 내 재검색(도로명,건물번호,건물명)을 할 경우에 입력하면 됩니다. } html = s.post('https://www.pharm114.or.kr/search/search_result.asp', data = formdata).content soup = BeautifulSoup(html, 'html5lib') try: trs = soup.find('div', {'id' : 'printZone'}).find('table', {'style' : 'TABLE-LAYOUT: fixed'}).tbody.find_all('tr', recursive = False) except: # tr 태그를 검색하지 못한 경우를 처리합니다(다만 크롤링상의 에러일 가능성을 배제할 수 없습니다.) print('현재 해당 지역에 영업 중인 약국이 없습니다!') sys.exit() # HTML에서 원하는 데이터를 추출합니다. result = [] for t in trs: if t.td.has_attr('width'): name = t.find('td', {'height' : '30'}).text script = t.find('script').text adr = script.split("');")[0].split("'")[-1] w86 = t.find_all('td', {'width' : '86'}) phone = w86[0].text time = w86[1].text.strip() next = t.find_next_sibling() if next.find('strong'): loc = next.find('strong').text.split(': ')[-1][:-1] if next.find_next_sibling().find('strong'): remark = next.find_next_sibling().find('strong').text.split(': ')[-1][:-1] result.append([name, adr, phone, time, loc, remark]) else: result.append([name, adr, phone, time, loc]) else: result.append([name, adr, phone, time]) print(result) return result def geocoding(address): # 국토교통부 Geocoder API 2.0를 이용하여 도로명주소를 좌표로 변경하는 함수입니다. s = HTMLSession() address = address.replace('지하 ', '') # 도로명주소에 '지하'가 포함되는 경우 API에서 에러가 발생합니다. apikey = 'API키를 입력하세요!' r = s.get('http://apis.vworld.kr/new2coord.do?q=' + address + '&apiKey=' + apikey + '&domain=http://map.vworld.kr/&output=json').text x, y = list(json.loads(r).values())[1], list(json.loads(r).values())[0] return (x, y) def get_center(result): # 좌표들의 센터를 반환하는 함수입니다. temp_x, temp_y = [], [] for r in result: x, y = geocoding(r[1]) temp_x.append(float(x)) temp_y.append(float(y)) avg_x = np.mean(temp_x) avg_y = np.mean(temp_y) return (avg_x, avg_y) def main(): result = crawling_pharm114() center = get_center(result) m = folium.Map(location = center, zoom_start = 17) marker_cluster = MarkerCluster().add_to(m) for r in result: loc = geocoding(r[1]) folium.Marker( location = loc, tooltip = '<strong style="color:blue">' + r[0] + '</strong>BR태그' + 'BR태그'.join(r[2:]), icon=folium.Icon(color='red', icon='ok') ).add_to(marker_cluster) data = io.BytesIO() m.save(data, close_file=False) app = QtWidgets.QApplication(sys.argv) w = QtWebEngineWidgets.QWebEngineView() w.setWindowTitle('휴일지킴이 약국') w.setHtml(data.getvalue().decode()) w.resize(800, 600) w.show() sys.exit(app.exec_()) return if __name__ == "__main__": main()
실행시켜보면 결과는 다음과 같습니다!
MarkerCluster로 처리하였기 때문에 인근에 위치한 마커들을 군집시켜 클러스터로 표현하며,
특정 클러스터를 클릭하면 해당 위치를 확대하여 보여줍니다 ^^
각 마커에 마우스오버하면 다음과 같이 약국 정보를 표시합니다 ^^
위 소스에서 조금 더 보완해야 될 점에 대하여 살펴보면요 ^^
1. Folium에서 줌 설정
Folium에서의 줌과 관련하여 (i) zoom_start를 어떻게 설정할지, (ii) fit_bounds를 사용할지 등 문제가 있는데요~
이 부분에 대해서는 일단 제가 거주하는 지역에 최적화된 값을 넣기는 하였는데,
보다 universal한 목적으로 사용하려면 보완이 필요할 것 같네요 ^^
테스트해보니 타 지역(약국 간의 거리가 멀리 떨어진 지역)에서는 앱을 실행시킨 후에 줌 아웃을 해야 되는 경우가 발생합니다.
2. 비효율적인 부분
Folium에서 center를 맞추기 위하여 Geocoder API로 각 좌표들을 구하고 그 평균을 계산하는 과정이 들어가기 때문에,
불필요하게 Geocoder API를 두 번 호출하는 방식으로 작성되어 있습니다 ㅠㅠ
부디 위 소스가 주말에 급히 약국을 찾으시는 분들께 조금이나마 도움이 되시기를 기원합니다!
그럼 남은 주말 뜻깊게 보내시고, 일교차가 큰데 감기 조심하세요~ ^-^
제 허접한 소스를 읽어주셔서 감사합니다!!