[Python] '휴일지킴이 약국'을 크롤링하여 지도에 마커로 표시하기 > 그누보드5 팁자료실

그누보드5 팁자료실

[Python] '휴일지킴이 약국'을 크롤링하여 지도에 마커로 표시하기 정보

[Python] '휴일지킴이 약국'을 크롤링하여 지도에 마커로 표시하기

본문

 

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

 

여러모로 부족한 점이 많은 소스이지만, 혹시 필요로 하시는 분이 계실 수도 있을 것 같아서 올립니다.

 

 

저는 윈도우 기준으로 작성을 하였지만 소스에 한 줄만 추가하면 HTML로 export할 수 있기 때문에,

 

그누보드 홈페이지(특히 지역 커뮤니티)를 운영하시는 분들께서도 활용하실 수 있을 것이란 생각에서

 

여기 팁자료실 게시판에 올리게 되었습니다 ^^

 

이미 모바일 앱 방면에서는 이 스크립트와 유사한 방면으로 활용되고 있는 것 같더군요~

 

장차 PyQt가 모바일 환경에서도 원활하게 구동되는 날이 오기를 기원합니다!

 

 

이 스크립트는 대한약사회 측에서 운영하는 '휴일지킴이 약국' 사이트를 크롤링하여 데이터를 얻고

(URL : https://www.pharm114.or.kr/main.asp)

 

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

 

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

 

 

아쉽게도 휴일지킴이 약국 사이트는 대한약사회 측에서 API를 제공하지 않습니다.

 

위 홈페이지에서의 검색 결과를 살펴보면(아래 첨부사진 참조),

 

각 약국마다 위치정보 및 특이사항의 입력 여부가 상이하기 때문에

 

tr 태그의 수가 각 약국마다 2~4개로 일정하지 않습니다 ㅠㅠ

 

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

 

img 20210313 192653.png.jpg

 

 

검색 첫 번째 페이지는 POST 전송을, 두 번째 페이지부터는 GET 전송을 해야되고,

 

전자의 formdata와 후자의 parameter가 상이하기 때문에 

 

부득이 두 개의 함수를 나누는 방향으로 작성했어요!

 

 

사실 크롤링 과정에서 가장 귀찮았던 점은 인코딩이었습니다 ㅠㅠ

 

크롬 개발자도구에서 POST 전송의 Formdata를 확인하면 (unable to decode value)라고 뜨더군요!

 

이 부분을 EUC-KR로 처리하지 않으면 제대로 된 응답을 받을 수 없습니다 ㅎㄷㄷ

 

이러한 점들을 반영하여 그럭저럭 무난하게 작동하는 스크립트는 다음과 같습니다 ^^

 

 


from PyQt5 import QtWidgets, QtWebEngineWidgets
from PyQt5.QtGui import QIcon
from requests_html import HTMLSession
from bs4 import BeautifulSoup as bs
import numpy as np
from datetime import datetime, timedelta
from tqdm import tqdm
import folium, json, sys, io
from folium.plugins import MarkerCluster

 
class info: # 주소값을 속성으로 갖는 클래스입니다.
    city = '서울특별시' # 시도를 입력합니다(홈페이지의 select 박스의 명칭대로 입력).
    district = '종로구' # 시군구를 입력합니다(홈페이지의 select 박스의 명칭대로 입력).
    street = '종로'   # 도로명, 건물번호, 건물명으로 검색할 경우에 입력합니다. 
                         # 만약 시군구 단위로 검색하려면 None으로 입력하면 됩니다.

 
def crawling_pharm114_1(addr1, addr2, addr3): # 휴일지킴이 약국 사이트의 첫 페이지를 크롤링하는 함수입니다.
    s = HTMLSession()
    html = s.get('https://www.pharm114.or.kr/common_files/sub1_page1.asp').content
    soup = bs(html, 'html5lib')
 
    ss_key = soup.find('input', {'name' : 'ss_key'})['value'] # formdata에 입력할 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': addr1.encode('euc-kr'), # 인코딩을 euc-kr로 변경하여야 합니다.
        'addr2': addr2.encode('euc-kr'), 
        'image1.x': '22', # 마우스 위치를 감지하여 대입하는 부분이며, 적절한 값을 입력하면 무방합니다.
        'image1.y': '11' 
    }
    if addr3:
        formdata['addr3'] = addr3.encode('euc-kr')
 
    html = s.post('https://www.pharm114.or.kr/search/search_result.asp', data = formdata).content
    soup = bs(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()
    result = parse(trs)
    return result, [s, ss_key, now, start_time, end_time]

 
def crawling_pharm114_2(addr1, addr2, addr3, data): # 휴일지킴이 약국 사이트의 첫 페이지를 크롤링하는 함수입니다.
    s, ss_key, now, start_time, end_time = data
    params = {
        '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': addr1.encode('euc-kr'), # 인코딩을 euc-kr로 변경하지 않으면 에러가 발생합니다.
        'addr2': addr2.encode('euc-kr'), 
        'tmp_c_name': '',
        's_type': '',
        'realtime_TF': '',
        'ss_key': ss_key,
        'OnlyDangBun_TF': ''
    }
    if addr3:
        params['addr3'] = addr3.encode('euc-kr')
 
    result = []
    for cnt in range(2, 100):
        params['page'] = cnt
        html = s.get('https://www.pharm114.or.kr/search/search_result.asp', params = params).content
        soup = bs(html, 'html5lib')
        try: 
            trs = soup.find('div', {'id' : 'printZone'}).find('table', {'style' : 'TABLE-LAYOUT: fixed'})\
                  .tbody.find_all('tr', recursive = False)
            result.extend(parse(trs))
        except: # 더 이상 tr 태그를 검색하지 못한 경우에 break합니다.
            break
    return result

 
def parse(trs): # table 태그에서 원하는 데이터를 추출하는 함수입니다.
    result = []
    for t in trs:
        if t.td.has_attr('width'): # 파싱하고자 하는 데이터는 width 속성을 지닌 td 태그에 담겨있습니다.
            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()
            if t.find('td', {'width' : '100'}).text: # '구분'에 입력된 값이 있는 경우를 처리합니다.
                clsf = t.find('td', {'width' : '100'}).text
            else:
                clsf = ''
            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([clsf, name, adr, phone, time, loc, remark])
                else: # '위치정보'가 있는 경우에 값을 추가합니다.
                    result.append([clsf, name, adr, phone, time, loc])
            else: # '위치정보' 및 '특이사항'이 없는 경우를 처리합니다.
                result.append([clsf, name, adr, phone, time])
    return result

 
def geocoding(address): # 국토교통부 Geocoder API를 이용하여 도로명주소를 좌표로 변경하는 함수입니다.
    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
    data = json.loads(r)
    if 'result' in data:
        return (-1, -1)
    x, y = data['EPSG_4326_Y'], data['EPSG_4326_X']
    return (x, y)

 
def list_processing(result): # 좌표값을 리스트에 추가하고 그 평균값 및 최소/최대값을 반환하는 함수입니다.
    result_add, temp_x, temp_y = [], [], []
    for r in tqdm(result, desc='Geocoding'):
        x, y = geocoding(r[2])
        if x == -1 and y == -1: # API에서 주소가 반환되지 않은 경우에 대한 에러 처리를 합니다.
            print('API에서 주소를 확인할 수 없는 약국이 있습니다.')
            continue
        temp_x.append(float(x))
        temp_y.append(float(y))
        temp_list = [r, (x, y)]
        result_add.append(temp_list)
    np_x = np.array(temp_x) # 각 약국의 경위도 값에 대한 Numpy 배열을 생성합니다.
    np_y = np.array(temp_y)
    avg_x = np_x.mean() # center를 구하기 위하여 평균값을 구합니다.
    avg_y = np_y.mean()
    return result_add, (avg_x, avg_y), (np_x.min(), np_y.min()), (np_x.max(), np_y.max()) # fit_bounds를 위한 최소/최대값 등을 반환합니다.

 
def folium_processing(result_add, center, min_xy, max_xy): # 지도를 생성하고 마커를 표시하는 함수입니다.
    m = folium.Map(location = center, zoom_start = 16) # 구 단위 검색이 아닌 경우, 줌값을 16로 하여 맵을 생성합니다.
    m_cluster = MarkerCluster().add_to(m)
    str_tag = '<strong><font style="color:blue" size="4">' # 약국명을 강조 처리하기 위한 HTML 태그입니다.
    for r in result_add:
        print(r[0])
        description = '</br>'.join(r[0][2:])
        if r[0][0]: # '구분' 값이 있는 경우를 red 색상으로 처리합니다.
            color = 'red'
            tooltip = str_tag + r[0][1] + '</font></strong>' + ' (' \
                      + r[0][0] + ')</br>' + description
        else: # '구분' 값이 없는 경우를 green 색상으로 처리합니다.
            color = 'green'
            tooltip = str_tag + r[0][1] + '</font></strong></br>' + description
        folium.Marker(
            location = r[1],
            tooltip = tooltip,
            icon=folium.Icon(color = color, icon = 'ok')
        ).add_to(m_cluster)
    print(f'총 {len(result_add)}개의 약국이 검색되었습니다.')
    if info.street is None: # 구 단위로 검색한 경우에, 모든 마커가 짤리지 않고 표시되는 최대의 줌으로 설정합니다.
        m.fit_bounds([min_xy, max_xy])
    data = io.BytesIO()
    m.save(data, close_file = False)
    m.save('map.html')
    return data

 
def app_exec(map): # PyQt5의 GUI를 구동하는 함수입니다.
    app = QtWidgets.QApplication(sys.argv)
    w = QtWebEngineWidgets.QWebEngineView()
    w.setWindowTitle('휴일지킴이 약국')
    w.setWindowIcon(QIcon('pharm.png')) # 아이콘 파일을 넣지 않더라도 에러가 발생하지는 않습니다.
    w.setHtml(map.getvalue().decode())
    w.resize(1024, 768) # 1024x768 사이즈로 설정합니다.
    w.show()
    sys.exit(app.exec_())
    return

 
def main():
    result, data = crawling_pharm114_1(info.city, info.district, info.street)
    if len(result) == 20:
        result2 = crawling_pharm114_2(info.city, info.district, info.street, data)
        result.extend(result2)
    result_add, center, min_xy, max_xy = list_processing(result)
    data = folium_processing(result_add, center, min_xy, max_xy)
    app_exec(data)
    return

 
if __name__ == "__main__":
    main()

 

 

저는 아직 200행이 넘는 스크립트를 깔끔하게 작성할 실력이 안 되는 것 같네요 ㅠㅠ

 

실행을 하면 결과는 다음과 같습니다!

 

MarkerCluster로 처리하였기 때문에 인근에 위치한 마커들을 군집시켜 클러스터로 표현하며,

 

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

 

연중 운영하는 약국을 비롯하여 '구분' 탭에 무엇인가 내용이 기재되어 있는 약국은 red 색상으로,

 

그렇지 않은 약국은 green 색상으로 마커가 생성됩니다!

 

3717522941_1615808229.2122.png

 

 

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

 

3717522941_1615808306.9947.png

 

 

이 스크립트의 사용에 있어 보완해야 될 부분은요~

 

1. 국토교통부 Geocoder API 관련 문제

 

국토교통부 API는 대체로 응답속도가 빠르지만, 도로명주소를 찾지 못하는 경우가 종종 발생하네요 ㅜㅜ

(위 API 버전 1.0 및 2.0에서 공통적으로 나타나는 현상입니다.)

 

대략 1/100 이상의 확률로 특정 도로명주소에 대한 정보가 누락되어 있더군요~

 

혹시 더 괜찮은 API를 알고 계시면 추천 부탁드려요 ^^

 

 

2. Folium의 줌 관련 문제

 

첫 화면의 줌을 어떻게 설정하느냐(zoom_start)의 문제입니다.

 

아쉽게도 Folium은 마커의 개수에 따라 인터랙티브하게 줌이 작동하는 기능을

 

아직 제공하지 않는다고 알고 있습니다 ㅜㅜ

 

그렇다고 해서 일률적으로 fit_bounds를 적용하는 방식으로는 이 문제를 해결할 수 없더군요~

 

테스트를 해보니 지역 및 시간대에 따라 적절한 초기값이 케바케던데

 

앞으로 시간적인 여유가 있으면 편의성 및 가독성을 향상시키는 방향으로 보완할게요!

 

 

늘 SIR에서 많은 것을 배워가서 감사한 마음에 허접한 스크립트를 올렸네요~

 

예전에 Dropbox를 그누보드 이미지 호스팅 용도로 활용하는 PHP 소스도 작성을 했는데

 

다음에 기회가 닿으면 이 스크립트도 올리도록 하겠습니다 ^^

 

그럼 편안한 저녁 되시고, 일교차가 큰데 감기 조심하세요!

 

항상 감사드립니다 :)

 

추천
6

댓글 19개

예 말씀하신대로 퍼포먼스보다 생산성이 중요한 업무에 파이썬이 많이 사용되는 추세인 것 같습니다. 그럼 일교차가 큰데 건강하세요! 감사합니다 ^^
아직 부족한 점이 많은데 좋게 봐주셔서 감사드리고, 앞으로 더욱 열심히 파이썬을 공부하겠습니다! :) 즐거운 주말 되세요~ ^-^
감사합니다.
파이썬을 알아야 적용할 수 있는 소스인가요?
파이썬은 전혀 몰라서요.
유용할 것 같은데, 아직 사용할 줄을 모르겠습니다. ^^
안녕하세요? ^-^
위 소스를 그냥 윈도우에서 사용하시거나, 일단 HTML 형식으로 export 하신 후에 그누보드에 적용하는 것은 Python과는 별다른 관련성이 없을 것 같습니다 :)
아무쪼록 잘 사용하시면 좋겠습니다!
그럼 즐겁고 뜻깊은 한 주 되시기를 기원합니다~
감사합니다 ^^
전체 2,411 |RSS
그누보드5 팁자료실 내용 검색

회원로그인

(주)에스아이알소프트 / 대표:홍석명 / (06211) 서울특별시 강남구 역삼동 707-34 한신인터밸리24 서관 1404호 / E-Mail: admin@sir.kr
사업자등록번호: 217-81-36347 / 통신판매업신고번호:2014-서울강남-02098호 / 개인정보보호책임자:김민섭(minsup@sir.kr)
© SIRSOFT