[Python] 네이버 일기예보 크롤링 => 그누보드에 그래프 작성 > 그누보드5 팁자료실

그누보드5 팁자료실

[Python] 네이버 일기예보 크롤링 => 그누보드에 그래프 작성 정보

[Python] 네이버 일기예보 크롤링 => 그누보드에 그래프 작성

본문

안녕하세요? ^-^

장마 후 무더위가 이어지고 있는데 다들 건강히 주말 잘 보내고 계시는지요??

 

요새 SIR 회원님들께서도 일기예보에 관심이 많으실 것 같아서,

네이버에서 조회 가능한 4개의 기상예보기관을 크롤링하여 

(기상청, 아큐웨더, 웨더채널, 웨더뉴스)

일주일 동안의 기온 및 강수확률 예상값을 가져온 후에

그 평균값을 그래프로 표현하여 그누보드 게시글로 작성하는 스크립트를 작성했습니다 :)

 

크롤링에 관심이 있으신 SIR 회원님들도 많이 계신 것 같아서

이 글에서는 동적 웹페이지의 크롤링 방법에 대하여 간단히 설명드리겠습니다 ^^

 

 

1. 네이버 크롤링

네이버에서 일기예보를 확인하는 방법은 여러 가지가 있지만 

아래 사진과 같이 검색어 "OO 날씨"로 조회를 하면 바로 해당 지역을 일기예보가 제공됩니다.

 

990718968_1656839787.9261.png

 

 

위 화면에서 '예보제공사 선택'을 클릭하면 기상청 등 4개 기관의 일기예보를 열람할 수 있습니다.

'예보제공사 선택'을 클릭한 후 브라우저의 개발자도구를 확인해보면

아래와 같은 XHR을 통하여 어떤 데이터를 주고 받는다는 사실을 파악할 수 있습니다 ^^

 

990718968_1656840032.652.png

 

 

해당 XHR을 프리뷰로 보면 역시나 기상예보 데이터를 전송한다는 것을 확인할 수 있습니다!

 

990718968_1656840290.8611.png

 

 

해당 XHR을 열어보면 JSON 트리 구조가 잘 짜여 있어서 어렵지 않게 파싱할 수 있습니다.

XHR 주소에서 'weather', 'accuWeather', 'twcWeather', 'weatherNews'라는

마지막 파라미터를 바꿔주는 것만으로 기상관측기관 4개의 데이터를 모두 수집할 수 있습니다.

 

990718968_1656840421.58.jpg

(솔직히 저는 결코 크롤링이 쉽다고 생각하지는 않습니다 ㅠㅠ)

 

 

2. Pandas와 Matplotlib을 이용한 그래프 구현

위에서 설명한 방법으로 일주일치 기상예측값을 크롤링한 후

각 날짜마다 기온 및 강수확률의 평균값을 구하여 그래프로 구현하였습니다.

이 과정에서 그래프로 어떻게 표현하는 것이 한 눈에 잘 들어올지 고민을 많이 했습니다 ㅠㅠ

제가 머리를 쥐어짜내어 만든 그나마 최상의 결과물은 아래와 같습니다.
 

990718968_1656840509.2088.png

 

 

위 그래프에서 빨간색 선그래프는 '기온'을 나타내고,

파란색 바탕의 박스는 '강수확률'을 의미합니다.

그리고 강수확률이 50% 이상인 경우 파란색 글씨로 표기하였습니다.

참고로 'Temperature'는 굳이 영어로 표기하려고 한 것이 아니라,

Matplotlib에서 한글을 그냥 입력하면 외계어로 표시되는 버그가 있기 때문입니다 ㅠㅠ

그래프 작성에 관한 더 좋은 아이디어가 있으시면 댓글로 남겨주시면 감사하겠습니다 ^-^

 

 

3. 스크립트


import pandas as pd
import matplotlib.pyplot as plt
from PIL import Image
from bs4 import BeautifulSoup as bs
from datetime import datetime, timedelta
import requests, pymysql, ftputil, hashlib, os, sys

 
def file_type(x): # 그누보드의 bf_type 값을 반환하는 함수입니다.
    return {'gif' : '1', 'jpeg' : '2', 'jpg' : '2', 'png' : '3', 'swf' : '4', 'psd' : '5',
            'bmp' : '6', 'tif' : '7', 'tiff' : '7', 'jpc' : '9', 'jp2' : '10', 'jpx' : '11',
            'jb2' : '12', 'swc' : '13', 'iff' : '14', 'wbmp' : '15', 'xbm' : '16'}.get(x.lower(), '0')

 
def file_upload(filename, bf_file): # FTP를 이용하여 파일을 업로드하는 함수입니다.
    with ftputil.FTPHost('FTP주소', 'FTP아이디', 'FTP비번') as fh:
        fh.chdir('/web/data/file/board')
        fh.upload(filename, bf_file, callback = None)
    return

 
def get_filename(filename): # 파일명을 변환하는 함수입니다.
    ms = datetime.now().microsecond
    encoded_name = filename.encode('utf-8')
    result = f'{ms}_{hashlib.sha1(encoded_name).hexdigest()}'
    return result

 
def board_write(board, subject, content, mb_id, nickname, file_list = None):
    # MySQL connection 및 cursor 생성
    conn = pymysql.connect(host = 'DB의URL을입력하세요',
                           user = 'DB의아이디를입력하세요',
                           password = 'DB의패스워드를입력하세요',
                           db = 'DB명을입력하세요',
                           charset = 'utf8')
    curs = conn.cursor()
 
    # 작성글 INSERT
    sql = f"select wr_num from g5_write_{board}"
   
    curs.execute(sql)
    wr_num = str(int(curs.fetchone()[0]) - 1)
    print(wr_num)
    now = datetime.today().strftime('%Y-%m-%d %H:%M:%S') # 그누보드의 날짜 형식 준수 (ex: 2021-04-05 23:45:15)
    sql = f"insert into g5_write_{board} set wr_num = {wr_num}, \
          wr_reply = '', wr_comment = 0, ca_name = '', wr_option = 'html1', wr_subject = '{subject}', \
          wr_content = '{content}', wr_link1 = '', wr_link2 = '', \
          wr_link1_hit = 0, wr_link2_hit = 0, wr_hit = 1, wr_good = 0, wr_nogood = 0, \
          mb_id = '{mb_id}', wr_password = '', wr_name = '{nickname}', wr_email = '', wr_homepage = '', \
          wr_datetime = '{now}', wr_last = '{now}', wr_ip = '111.111.111.111', \
          wr_1 = '', wr_2 = '', wr_3 = '', wr_4 = '', wr_5 = '', \
          wr_6 = '', wr_7 = '', wr_8 = '', wr_9 = '', wr_10 = '', \
          wr_comment_reply = '', wr_facebook_user = '', wr_twitter_user = '', \
          as_re_name = '', as_tag = '', as_map = '', as_icon = '', as_thumb = '', as_video = ''"
    curs.execute(sql)
 
    # 부모 아이디에 UPDATE
    sql = f"select wr_id from g5_write_{board}"
    curs.execute(sql)
    wr_id = str(curs.fetchall()[-1][0])
    print(f"wr_id : {wr_id}")
    sql = f"update g5_write_{board} set wr_parent = {wr_id} where wr_id = {wr_id}"
    curs.execute(sql)
 
    # 새글 INSERT
    sql = f"insert into g5_board_new ( bo_table, wr_id, wr_parent, bn_datetime, mb_id ) values \
          ( '{board}', '{wr_id}', '{wr_id}', '{now}', '{mb_id}' )"
    curs.execute(sql)
 
    # 게시글 1 증가
    sql = f"select bo_count_write from g5_board where bo_table = '{board}'"
    curs.execute(sql)
    bo_count_write = str(int(curs.fetchone()[0]))
    print(curs.fetchall())
    print(bo_count_write)
    sql = f"update g5_board set bo_count_write = {bo_count_write} + 1 where bo_table = '{board}'"
    curs.execute(sql)
 
    # 파일 업로드 및 관련 정보를 테이블에 저장
    if not file_list or file_list == [''] or file_list == []: # 첨부파일이 없는 경우에는 스크립트를 중단합니다.
        conn.close()
        sys.exit()
 
    file_count = len(file_list)
    for cnt, file in enumerate(file_list):
        ext = os.path.splitext(file)[1].lstrip('.')
        bf_file = f'{get_filename(file)}.{ext}'
        file_upload(file, bf_file)
        type = file_type(ext)
        if type != '0': # 이미지 파일의 경우 가로 및 세로를 구하고, 그 외의 경우에는 0을 대입합니다.
            im = Image.open(file)
            width, height = im.size
        else:
            width, height = 0, 0
        size = os.path.getsize(file)
        sql = f"insert into g5_board_file set bo_table = '{board}', wr_id = '{wr_id}', \
              bf_no = '{cnt}', bf_source = '{file}', bf_file = '{bf_file}', \
              bf_content = '', bf_download = 0, bf_filesize = '{size}', \
              bf_width = '{width}', bf_height = '{height}', bf_type = '{type}', bf_datetime = '{now}'"
        curs.execute(sql)
       
    # 파일의 개수를 게시물에 업데이트
    sql = f"update g5_write_board set wr_file = '{file_count}' where wr_id = '{wr_id}'"
    curs.execute(sql)
 
    # MySQL connection 닫기
    conn.close()
    return

 
def alp2num(string): # 스트링에서 숫자를 제외한 문자를 삭제하는 함수
    return int(''.join([x for x in string if x.isdecimal()]))

 
def parse_naver_weather(elements): # 네이버날씨를 파싱하여 오전/오후의 기온 및 강수확률을 반환하는 함수
    data = []
    for e in elements:
        inners = e.findAll('span', {'class':'weather_inner'})
        rainAM = inners[0].find('span', {'class':'rainfall'}).text
        rainPM = inners[1].find('span', {'class':'rainfall'}).text
        tempAM = e.find('span', {'class':'lowest'}).find(text=True, recursive=False)
        tempPM = e.find('span', {'class':'highest'}).find(text=True, recursive=False)
        foo = [rainAM, tempAM, rainPM, tempPM]
        foo = list(map(alp2num, foo)) # 각 값에서 숫자를 제외한 문자열을 제거합니다.
        data.append(foo[:2]) # 오전과 오후의 데이터를 리스트에서 별개의 요소로 처리합니다.
        data.append(foo[2:])
    return data

 
def main():
    # 네이버 날씨를 크롤링합니다.
    html = requests.get('https://search.naver.com/search.naver?where=nexearch&sm=top_hty&fbm=0&ie=utf8&query=%EC%88%98%EC%9B%90+%EB%82%A0%EC%94%A8')
    base_url = 'https://m.search.naver.com/p/csearch/content/nqapirender.nhn?where=nexearch&pkid=227&u1=02111129&key='
    sources = {'weather', 'accuWeather', 'twcWeather', 'weatherNews'} # URL 마지막에 붙는 파라미터입니다.
    urls = {base_url + x for x in sources}
    for cnt, url in enumerate(urls, 1):
        r = requests.get(url)
        html = str(r.json()['weekly'])
        soup = bs(html, 'html5lib')
        lis = soup.findAll('li')
        df = pd.DataFrame(parse_naver_weather(lis))
        globals()['df{}'.format(cnt)] = df # 동적으로 변수를 선언하여 결과값을 대입합니다.
 
    # 각 DataFrame의 평균을 구하는 등 적절히 조작합니다.
    result = (df1 + df2 + df3 + df4) / 4
    result = result.dropna().round().astype(int)
    result.columns = ['Rain', 'Temp']
 
    # 그래프 X축으로 사용할 날짜를 구합니다(오늘 포함 총 8일).
    dt = datetime.now().date()
    dates8 = [(dt + timedelta(days=i)).strftime('%m/%d') for i in range(8)]
    x_axis = [x for pair in zip(dates8, dates8) for x in pair] # 오전/오후이기 때문에 두 번씩 넣어줍니다.
 
    # Matplotlib를 이용하여 그래프로 표현합니다.
    plt.style.use('ggplot')
    fig = plt.figure()
    ax = fig.add_subplot()
    ax.xaxis.set_ticks(range(16))
    ax.set_xticklabels(x_axis)
    for i, tick in enumerate(ax.xaxis.get_ticklabels()): # X축의 날짜를 한 칸씩 건너서 표시합니다.
        if i % 2 != 0:
            tick.set_visible(False)
    plt.plot(result['Temp'], marker='o', markersize=10, color='red', label='Temperature')
    bbox = dict(boxstyle='round, pad=0.2', fc='skyblue', alpha=0.5)
    for x, txt in enumerate(result['Rain']):
        color = 'blue' if result['Rain'][x] >= 50 else 'white' # 강수확률 50% 이상이면 파란색 글씨로 표기합니다.
        ax.annotate(f'{txt}%', xy=(x, result['Temp'][x]), xytext=(5, -8),
                    color=color, textcoords='offset points', bbox=bbox,
                    horizontalalignment='left', verticalalignment='top')
    plt.legend(loc='best')
    plt.xticks(rotation=75)
    plt.savefig('forecast.png')
 
    # 그누보드에 게시글을 작성합니다.
    today = dt.strftime('%Y-%m-%d')
    board = 'board'
    subject = f'{today}자 기상관측 4개 기관 일기예보 분석'
    content = '기상청/아큐웨더/웨더뉴스/웨더채널의 데이터를 수집하여 이를 토대로 작성한 예보입니다.'
    mb_id = '그누보드아이디를입력하세요'
    nickname = '그누보드닉네임을입력하세요'
    file_list = ['forecast.png']
    board_write(board, subject, content, mb_id, nickname, file_list)

 
if __name__ == "__main__":
    main()

 

위 스크립트에서 조회를 원하시는 지역의 URL을 적절히 대입하여 사용하시면 됩니다 ^^

참고로 스크립트에서 동적 변수 선언을 하였기 때문에, IDE에 따라서는 실행하기 전에 에러가 있는 것처럼 표시될 수 있습니다.

위 스트립트를 실행하면 아래와 같이 그누보드에 게시글이 자동으로 작성됩니다.

 

990718968_1656841284.7287.png

 

 

4. 개선할 점

(1) 앞서 말씀드린대로 그래프를 보다 직관적으로 표현할 수 있으면 좋겠습니다.

(2) 장기적으로 예측값을 실제 날씨와 비교하여, 기상관측 4개 기관의 산술평균이 아닌 가중평균을 사용하면 보다 적중률이 높아질 것 같습니다 ^^

(ex. 아큐웨더*0.3 + 웨더뉴스*0.3 + 기상청*0.2 + 웨더채널*0.2)

 

 

5. 마치며

Python이 웹크롤링과 데이터 분석에 강점이 있기 때문에, 다행히도 무탈히 토이프로젝트를 완성할 수 있었습니다.

제 설명이 많이 부족하였지만 크롤링에 관심이 있는 분들께 조금이나마 도움이 되셨으면 좋겠네요 ^^

다음에는 그누보드와 Tistory에 동시에 게시글을 올리는 스크립트를 작성하도록 하겠습니다!

그럼 남은 주말 뜻깊게 잘 마무리하시고,

SIR 회원님들께서도 무더위와 열대야 속에서도 건강이 늘 함께 하시기를 기원합니다.

990718968_1656842999.435.png

추천
20
  • 복사

댓글 21개

오오~ sinbi 님 안녕하세요?? ^-^
저는 단지 파이썬을 조금 할 줄 아는 것뿐이에요~! ㅠㅠ
sinbi 님께서는 '신비'로운 나라에서 오신 것 아닌가요?? ㅎㄷㄷ
그럼 sinbi 님께서도 편안한 밤 되시고 이번주도 홧팅홧팅이에요!!
안녕하세요?
추천과 댓글 그리고 스크랩 진심으로 감사드립니다! ^-^
오늘 낮에 폭염이 예상된다는데 부디 항상 건강하시길 기원합니다 :)
제가 설명을 하는 재주가 없어서 설명이 부족했는데 좋게 봐주셔서 감사드립니다! ^^
다음에는 조금 더 이해하기 쉽게 작성하겠습니다 :)
그럼 오늘도 좋은 하루 되세요~!
오오~ 우성짱 님 안녕하세요? ^-^
솔직히 저는 파이썬을 조금 다룰 줄 아는 수준인데 과찬이시네요 :)
우성짱 님이야말로 NAS와 Docker의 대가 아니신가요?? ㅎㄷㄷ
그럼 오늘도 좋은 하루 되시고 항상 건강하세요~!
다시 한 번 감사드립니다!!
안녕하세요? ^-^
허접한 글을 읽어주셔서 저야말로 감사드립니다!
무더위와 장마에도 가정에 건강이 늘 함께 하시기를 기원합니다 :)
비타주리 님 안녕하세요? ^-^
비타주리 님께서 자바스크립트를 다루시거나 철학을 논하시는 것에 비교하면 매우 부끄러운 수준일 따름입니다 ㅠㅠ
그럼 비타주리 님께서도 점심식사 맛있게 드시고 때 이른 무더위에도 항상 건강하시기를 기원합니다!
무와보 님 안녕하세요 ^^
댓글과 추천 남겨주셔서 감사드립니다!
고수님들 앞에서 허접한 팁을 올리니 되려 죄송하네요 ㅠㅠ
그럼 점심식사 맛있게 드시고 좋은 오후 되세요 :)
안녕하세요? ^-^
조금이나마 도움이 되셨다니 제가 더 감사할 따름입니다~!
그럼 저녁식사 맛있게 드시고 편안한 저녁 되세요 :)
요새 업무 때문에 경황이 없어 답글이 늦어 죄송합니다.
참고가 되신다니 제가 더 감사합니다 :)
그럼 좋은 주말 되시고, 폭염 속에서도 항상 건강하시기를 기원합니다 ^^
© SIRSOFT
현재 페이지 제일 처음으로