코드

[Python] 휴일지킴이 약국을 크롤링하여 Folium 지도에 마커로 표시하는 PyQt 윈도우 앱

by 이니스프리 posted Mar 13, 2021
?

단축키

Prev이전 문서

Next다음 문서

ESC닫기

크게 작게 위로 아래로 댓글로 가기 인쇄
Extra Form
라이선스 기타(따로 작성)

***개선된 버전을 SIR에 올렸습니다.

https://sir.kr/g5_tip/15526

 

안녕하세요? 저녁식사는 맛있게 드셨는지요?

 

여러모로 부족한 점이 ...

 

 

 

안녕하세요?

 

대한약사회 측에서 운영하는 '휴일지킴이 약국' 사이트를 크롤링하여 도로명 주소 등 데이터를 획득한 후에 

 

위 주소를 국토교통부의 Geocoder API 2.0를 이용하여 경위도 좌표계의 좌표로 변환하여

 

Folium 및 PyQt5을 이용하여 마커로 표시하는 윈도우 앱입니다!

 

 

저는 윈도우 기준으로 작성을 하였지만 Folium에서 HTML로 export할 수 있기 때문에,

 

홈페이지를 운영하시는 분들도 참고하시면 유용하게 활용하실 것 같네요 ^-^

 

 

우선 휴일지킴약국 홈페이지(https://www.pharm114.or.kr/main.asp)에서의 검색 결과를 살펴보면,

 

각 약국마다 위치정보 및 특이사항의 입력 여부가 다른 등 데이터가 상이하기 때문에 tr 태그의 수가 일정하지 않습니다 ㅠㅠ

(하나의 약국 당 tr 태그 2~4개를 사용합니다.)

 

이런 점을 감안하여 누락되는 데이터 없이 모든 정보를 크롤링을 할 수 있도록 작성하였습니다~!

 

img 20210313 192653.png.jpg

 

 

그리고 휴일지킴이 약국 사이트는 대한약사회 측에서 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로 처리하였기 때문에 인근에 위치한 마커들을 군집시켜 클러스터로 표현하며,

 

특정 클러스터를 클릭하면 해당 위치를 확대하여 보여줍니다 ^^

 

img 20210313 222101.png.jpg

 

 

각 마커에 마우스오버하면 다음과 같이 약국 정보를 표시합니다 ^^

 

img 20210313 230000.png.jpg

 

 

위 소스에서 조금 더 보완해야 될 점에 대하여 살펴보면요 ^^

 

 

1. Folium에서 줌 설정

 

Folium에서의 줌과 관련하여 (i) zoom_start를 어떻게 설정할지, (ii) fit_bounds를 사용할지 등 문제가 있는데요~

 

이 부분에 대해서는 일단 제가 거주하는 지역에 최적화된 값을 넣기는 하였는데,

 

보다 universal한 목적으로 사용하려면 보완이 필요할 것 같네요 ^^

 

테스트해보니 타 지역(약국 간의 거리가 멀리 떨어진 지역)에서는 앱을 실행시킨 후에 줌 아웃을 해야 되는 경우가 발생합니다.

 

 

2. 비효율적인 부분

 

Folium에서 center를 맞추기 위하여 Geocoder API로 각 좌표들을 구하고 그 평균을 계산하는 과정이 들어가기 때문에,

 

불필요하게 Geocoder API를 두 번 호출하는 방식으로 작성되어 있습니다 ㅠㅠ

 

 

부디 위 소스가 주말에 급히 약국을 찾으시는 분들께 조금이나마 도움이 되시기를 기원합니다!

 

그럼 남은 주말 뜻깊게 보내시고, 일교차가 큰데 감기 조심하세요~ ^-^

 

제 허접한 소스를 읽어주셔서 감사합니다!!

 


Articles

1 2 3 4