YOGYUI

웹크롤링 - 한국환경공단(에어코리아) 측정소 정보 가져오기 본문

Data Analysis/Data Engineering

웹크롤링 - 한국환경공단(에어코리아) 측정소 정보 가져오기

요겨 2022. 1. 13. 20:22
반응형

앞서 공공데이터포털에서 대기오염정보를 조회하는 예시를 작성해봤는데(링크), API의 서비스 중 "측정소별 실시간 측정정보"를 호출하기 위해서는 '측정소 이름'을 정확히 기재해야 결과가 제대로 나왔다

API 명세서에 측정소 이름에 대한 정보는 없으며, 에어코리아 홈페이지의 "측정소 정보"에서 지역별 측정소의 이름과 주소 정보를 확인할 수 있다

URL: https://airkorea.or.kr/web/stationInfo?pMENU_NO=93

전체 조회 후 엑셀로 저장하는 방법(a.k.a. 노가다)이 가장 보편적이겠지만, 간단한 자바스크립트 및 파이썬으로 스마트하게 자동화할 수 있다

1. 웹페이지 소스 확인

브라우저의 개발자 도구에서 제어하고자 하는 컨트롤들에 해당하는 태그들을 찾아보자

(구글 크롬: 단축키 F12 혹은 Ctrl + Shift + I)

개발자 도구 오픈

1.1. 콤보박스 확인

측정소의 카테고리를 지정하는 콤보박스는 보통 <select> 태그로 구현한다

검색해보면 총 4개의 <select> 태그가 존재하는데, 그 중 우리가 원하는 태그 2개는 "mang_code", "district"를 id 속성으로 가진 것을 확인할 수 있다

 

HTML 위계 구조는 다음과 같다

<body>
  <div id="container" class="contentFrame">
    <div id="conts">
      <div id="cont_body">
        <form name="dataSearch" method="post">
          <div class="divi MgT30">
            <div class="search_head W440 H15 MgT0 divi_l">
              <select name="mang_code" id="mang_code" title="검색옵션" class="W110 MgT1">
              <select name="district" id="district" title="검색옵션" class="W110 MgT1">
              <a class="search MgL30" onclick="searchInfo2();return false;" href="">조회</a>
              <a class="xls " href="" onclick="excelDown();return false;">엑셀</a>

조회버튼을 클릭했을 때 호출되는 자바스크립트 함수명이 searchInfo2 인 것을 기억해두자

함수를 호출하면 페이지 전체가 refresh되는 것을 알 수 있다

 

class 속성이 "W110 MgT1"이므로 DOM을 통해 Javascript로 쉽게 접근할 수 있다

cmb_list = document.getElementsByClassName("W110 MgT1");

콤보박스의 내용물은 <option> 태그로 존재하므로, 다음과 같이 text와 value를 얻어올 수 있다

cmb_options = cmb_list[0].getElementsByTagName("option");
cmb_items1 = [];
for (i = 0; i < cmb_options.length; i++) {
    cmb_items1.push({
        "text": cmb_options[i].text,
        "value": cmb_options[i].getAttribute("value")
    });
}

cmb_options = cmb_list[1].getElementsByTagName("option");
cmb_items2 = [];
for (i = 0; i < cmb_options.length; i++) {
    cmb_items2.push({
        "text": cmb_options[i].text,
        "value": cmb_options[i].getAttribute("value")
    });
}

어차피 전국의 측정소 정보를 전부 가져올 것이기 때문에 두번째 콤보박스는 '전체'로 조회할 것이라, 첫번째 콤보박스의 '국가배경/교외대기/도시대기/도로변대기/항만' 5개 항목에 대한 value만 저장하고 있으면 된다

 

<select> 태그의 동적 아이템 선택은 다음과 같이 jQuery를 활용하면 된다

$("select[id='mang_code']").val('value').prop("selected", true);

콤보박스 선택 동적 변경 (jQuery)

1.2. 테이블 확인

측정소명과 측정소 주소 정보가 담겨있는 테이블은 <table> 태그로 구성되어 있다

HTML 위계 구조는 다음과 같다

<body>
  <div id="container" class="contentFrame">
    <div id="conts">
      <div id="cont_body">
        <form name="dataSearch" method="post">
          <div class="divi MgT30">
            <div class="divi_r W55p MgT10" style="height: 560px; overflow-y: scroll;">
              <table class="st_2">

class명이 "st_2"이므로 element를 쉽게 가져올 수 있다

table = document.getElementsByClassName("st_2")[0];

테이블의 행(row)들은 <tbody>태그의 <tr> 태그들을 가져오면 된다

tbody = table.getElementsByTagName("tbody")[0];
tr_list = tbody.getElementsByTagName("tr");

각각의 <tr>은 <a> 태그 2개를 갖고 있으며, 각각 '측정소명', '측정소 주소' 정보를 담고 있고, <th> 태그의 id 속성으로 각 측정소의 고유번호를 확인할 수 있다

 

고유번호는 웹페이지 좌측 하단의 측정소 상세 정보 테이블을 그리는데 활용되는데 (vrmlSearch 함수), 상세정보래봐야 운영기관, 설치년도, 측정항목 3개가 고작 추가되는 것이기 때문에 일일히 클릭하면서 상세정보까지 가져오는 크롤링은 하지 말자 (id만 가져오도록 하자)

측정소 상세정보

2. 크롤링 스크립트 작성

5개 카테고리 (국가배경/교외대기/도시대기/도로변대기/항만)에 대해 '전체' 측정소에 대한 고유번호, 측정소명, 주소 3개 자료를 크롤링하는 코드를 작성해보자

 

카테고리 콤보박스 선택 후 조회 함수(searchInfo2) 함수를 호출한 후, 페이지가 렌더링되는 것을 기다려야하기 때문에 자바스크립트로 비동기 딜레이 및 비동기 함수 순차실행해야한다

(자세한 내용은 링크 참조)

 

앞서 분석한 웹페이지 소스를 토대로 스크립트를 작성해보자

[crawl_observatory_location.js]

/*
* 필요한 함수 선언
*/
// 카테고리 콤보박스(<select>)의 <option> 태그 정보 (text, value) 가져오기
function getCategory() {
    select = document.getElementsByClassName("W110 MgT1")[0];
    options = select.getElementsByTagName("option");
    option_list = []
    for (i = 0; i < options.length; i++) {
        option = options[i];
        option_list.push({
            "text": option.text,
            "value": option.getAttribute("value")
        });
    }
    return option_list;
}

// 비동기 delay
function sleep(ms) {
    return new Promise((r) => setTimeout(r, ms));
}

// 카테고리 선택 후 조회 함수 호출 후 테이블 크롤링
async function fnSearchInfo(category_info, arr_result, delay_ms) {
    value = category_info.value;
    $("select[id='mang_code']").val(value).prop("selected", true);
    searchInfo2();
    await sleep(delay_ms);
    
    console.log("test");
    table = document.getElementsByTagName("table")[0];
    tbody = table.getElementsByTagName("tbody")[0]; 
    tr_list = tbody.getElementsByTagName("tr");
    
    for (i = 0; i < tr_list.length; i++) {
        tr = tr_list[i];
        th = tr.getElementsByTagName("th")[0];
        id = th.getAttribute("id");
        a_tags = tr.getElementsByTagName("a");
        name = a_tags[0].text;
        addr = a_tags[1].text;
    }
}

// 호출 함수
async function fnProcess(arr_category, arr_result, delay_ms) {
    await arr_category.reduce((prevTask, currTask) => {
        return prevTask.then(() => fnSearchInfo(currTask, arr_result, delay_ms));
    }, Promise.resolve());
    
    console.log('Finished');
}

console.log('Start');
/*
* Step.1 카테고리 정보 가져오기
*/
caterories = getCategory();

/*
* Step.2 전체 카테고리 조회
*/
arr_result = []
fnProcess(caterories, arr_result, 1000);

안타깝게도, searchInfo2 함수는 

<script>
  function searchInfo2(){
    document.dataSearch.mang_code.value = $("#mang_code").val();
    document.dataSearch.districtnum.value = $("district").val();
    document.dataSearch.submit();
  }
<script>

와 같이 구현되어 있고, 

<form name="dataSearch" method="post">

POST 방식으로 <form> submit해서 페이지를 완전히 새로고침 하기때문에 자바스크립트 함수 및 변수 정보가 날아가버리므로 브라우저에서 테스트할 수가 없었다 ㅠㅠ

 

페이지 로드 완료 후 작성한 스크립트를 다시 로드할 수 있는 별도의 방법이 있긴 할텐데...어차피 자동화 프로그램은 별도로 만들거니 너무 시간을 쏟지는 말자

(실컷 짰는데 시간 아까워...)

3. 자동화 프로그램 작성 (Python)

Python을 이용해서 searchInfo2 함수 호출 후 페이지 새로고침 시 스크립트를 다시 로드하고 테이블을 크롤링하는 코드를 나누어서 작성해보자

 

웹페이지 javascript 구동을 위해 편리한 PyQt의 QtWebEngineView를 활용해봤다

코드가 길어서 조금 복잡해보이지만, 단순한 기능들을 순차적으로 호출하는 것이기 때문에 금방 흐름을 따라잡을 수 있을...지도? ㅎㅎ

 

일단 앞서 구현한 자바스크립트를 세개의 파일로 분리하자

[crawl_get_category.js]

// 카테고리 콤보박스(<select>)의 <option> 태그 정보 (text,     ) 가져오기
function getCategory() {
    select = document.getElementsByClassName("W110 MgT1")[0];
    options = select.getElementsByTagName("option");
    option_list = []
    for (i = 0; i < options.length; i++) {
        option = options[i];
        option_list.push({
            "text": option.text,
            "    ": option.getAttribute("    ")
        });
    }
    return option_list;
}

getCategory();

 

[crawl_get_table.js]

function getTable() {
    table = document.getElementsByTagName("table")[0];
    tbody = table.getElementsByTagName("tbody")[0]; 
    tr_list = tbody.getElementsByTagName("tr");

    result = [];
    for (i = 0; i < tr_list.length; i++) {
        tr = tr_list[i];
        th = tr.getElementsByTagName("th")[0];
        id = th.getAttribute("id");
        a_tags = tr.getElementsByTagName("a");
        name = a_tags[0].text;
        addr = a_tags[1].text;
        result.push({'id': id, 'name': name, 'addr': addr});
    }
    
    return result;
}

getTable();

 

그리고 Python으로 만든 UI 스크립트로 

  • 최초 페이지 로드 시: js로 콤보박스 카테고리 가져오기
  • START버튼 클릭 시 카테고리 리스트를 대상으로 쓰레드 구동
  • 쓰레드 내에서 각 카테고리별로 콤보박스 값(value) 선택 후 searchInfo2 함수 호출
  • 함수 호출 뒤 페이지 로드 완료 후 테이블 정보 가져오는 js 구동

기능을 수행할 수 있도록 프로토타이핑해보자

 

[crawl_observatory_location.py]

import os
import sys
from PyQt5.QtCore import QUrl, pyqtSignal, QThread
from PyQt5.QtGui import QShowEvent, QCloseEvent, QTextCursor
from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QPushButton, QTextEdit
from PyQt5.QtWidgets import QVBoxLayout, QHBoxLayout, QSizePolicy
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage

class CustomWebEnginePage(QWebEnginePage):
    sig_console_message = pyqtSignal(object, object, object, object)

    def __init__(self, parent=None):
        super().__init__(parent)

    def javaScriptConsoleMessage(self, level, message, line, source):
        self.sig_console_message.emit(level, message, line, source)

class ThreadSearch(QThread):
    sig_done = pyqtSignal(list)

    def __init__(self, page: QWebEnginePage, option_value_list: list, delay_ms: int):
        super().__init__()
        self._page = page
        self._option_value_list = option_value_list
        self._delay_ms = delay_ms
        script_path = os.path.abspath('./crawl_get_table.js')
        with open(script_path, 'r', encoding='utf-8') as fp:
            self._js_table_script = fp.read()
        self._obs_list = list()

    def run(self):
        for i, value in enumerate(self._option_value_list):
            # 콤보박스 선택하고 함수호출
            script = f"""
            $("select[id='mang_code']").val('{value}').prop("selected", true);
            $("select[id='district']").val('').prop("selected", true);
            searchInfo2();
            """
            self._page.runJavaScript(script)
            # 대기한다
            self.msleep(self._delay_ms)
            # 테이블 가져오기 스크립트를 수행한다
            self._page.runJavaScript(self._js_table_script, lambda x: self.callbackTable(x, i))
            self.msleep(100)

    def callbackTable(self, result: list, index: int):
        print(f'Thread({index}) - Table Record Count: {len(result)}')
        self._obs_list.extend(result)
        if index == len(self._option_value_list) - 1:
            self.sig_done.emit(self._obs_list)

class AirKoreaCrawlerWindow(QMainWindow):
    _first_loaded: bool = False  # 페이지 최초 로드 플래그
    _airkorea_url: str = "https://airkorea.or.kr/web/stationInfo?pMENU_NO=93"
    _thread = None

    def __init__(self):
        super().__init__()
        self._webview = QWebEngineView()
        self._category_list = []
        self._obs_list = []
        self._btnStartCrawl = QPushButton('START')
        self._btnGetResult = QPushButton('GET RESULT')
        self._editConsole = QTextEdit()
        self.initControl()
        self.initLayout()

    def initLayout(self):
        widget = QWidget()
        self.setCentralWidget(widget)
        vbox = QVBoxLayout(widget)
        vbox.setContentsMargins(0, 0, 0, 0)
        vbox.setSpacing(4)

        subwgt = QWidget()
        subwgt.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)
        hbox = QHBoxLayout(subwgt)
        hbox.setContentsMargins(2, 4, 2, 0)
        hbox.addWidget(self._btnStartCrawl)
        hbox.addWidget(self._btnGetResult)
        vbox.addWidget(subwgt)
        vbox.addWidget(self._webview)
        vbox.addWidget(self._editConsole)

    def initControl(self):
        self._btnStartCrawl.clicked.connect(self.startCrawl)
        self._btnGetResult.clicked.connect(self.getResult)
        webpage = CustomWebEnginePage(self._webview)
        webpage.sig_console_message.connect(self.onWebPageConsoleMessage)
        self._webview.setPage(webpage)
        self._webview.loadFinished.connect(self.onWebViewLoadFinished)
        self._editConsole.setReadOnly(True)
        self._editConsole.setFixedHeight(100)
        self._editConsole.setLineWrapColumnOrWidth(-1)
        self._editConsole.setLineWrapMode(QTextEdit.FixedPixelWidth)

    def startCrawl(self):
        self.startThread(2000)

    def startThread(self, delay_ms: int):
        if self._thread is None:
            self._obs_list.clear()
            option_value_list = [x.get('value') for x in self._category_list]
            self._thread = ThreadSearch(self._webview.page(), option_value_list, delay_ms)
            self._thread.sig_done.connect(self.onThreadDone)
            self._thread.start()

    def onThreadDone(self, obs_list: list):
        del self._thread
        self._thread = None
        print(f"Get Observatory List Count: {len(obs_list)}")
        self._obs_list.extend(obs_list)

    def getResult(self):
        pass

    def showEvent(self, a0: QShowEvent) -> None:
        # 창이 show되면 URL로 웹페이지 로드
        self._webview.load(QUrl(self._airkorea_url))

    def closeEvent(self, a0: QCloseEvent) -> None:
        self._webview.close()
        self.deleteLater()

    def addTextMessage(self, message: str):
        cursor = QTextCursor(self._editConsole.textCursor())
        cursor.movePosition(QTextCursor.End)
        cursor.insertText(message + '\n')
        vscroll = self._editConsole.verticalScrollBar()
        vscroll.setValue(vscroll.maximum())

    def onWebPageConsoleMessage(self, level, message, line, source):
        text = f'{message} (lv:{level}, line:{line})'
        self.addTextMessage(text)

    def onWebViewLoadFinished(self, result: bool):
        if not self._first_loaded:
            # 페이지 최초 로드 시 콤보박스 카테고리 option 리스트 가져오기
            self._category_list.clear()
            script_path = os.path.abspath('./crawl_get_category.js')
            with open(script_path, 'r', encoding='utf-8') as fp:
                script = fp.read()
                self._webview.page().runJavaScript(script, self.callbackCategoryList)
            self._first_loaded = True

    def callbackCategoryList(self, result: object):
        if isinstance(result, list):
            print(f'Get Category List: {result}')
            self._category_list.extend(result)

실행해보자

if __name__ == '__main__':
    app = QApplication(sys.argv)
    wnd = AirKoreaCrawlerWindow()
    wnd.resize(1000, 800)
    wnd.show()
    app.exec_()

START 버튼을 누르면 2초에 한번씩(테스트해보니 간격이 너무 짧으면 테이블이 모두 로드되기 전에 다음 페이지로 넘어가버린다) 카테고리들을 변경하면서 크롤링하게 된다

[콘솔 로그]

Get Category List: [{'text': '국가배경', 'value': '1'}, {'text': '교외대기', 'value': '2'}, {'text': '도시대기', 'value': '3'}, {'text': '도로변대기', 'value': '4'}, {'text': '항만', 'value': '23'}]
Thread(0) - Table Record Count: 11
Thread(1) - Table Record Count: 27
Thread(2) - Table Record Count: 506
Thread(3) - Table Record Count: 56
Thread(4) - Table Record Count: 15
Get Observatory List Count: 615

2022년 1월 13일 기준 총 615개 (국가배경 11, 교외대기 27, 도시대기 506, 도로변대기 56, 항만 15)의 관측소 정보를 가져올 수 있다!

4. DB로 저장

온라인 DBMS(MySQL, MongoDB)를 사용할까 하다가, python 사용자들은 sqlite도 많이 쓰길래 로컬에 db를 남기는 코드를 추가해봤다

앞서 구현한 프로토타이핑 코드에 GET RESULT 버튼을 클릭하면 로컬에 db 파일을 저장하도록 추가 구현

import sqlite3

class AirKoreaCrawlerWindow(QMainWindow):
    def getResult(self):
        dbpath = './airkorea_obs_list.db'
        if os.path.isfile(dbpath):
            os.remove(dbpath)
        conn = sqlite3.connect(dbpath)
        cursor = conn.cursor()
        cursor.execute("CREATE TABLE airkoreaobs( \
            고유번호 VARCHAR PRIMARY KEY,\
            이름     VARCHAR,\
            주소     VARCHAR);")
        sql = "INSERT INTO airkoreaobs values(?, ?, ?);"
        sqldata = [(x.get('id'), x.get('name'), x.get('addr')) for x in self._obs_list]
        cursor.executemany(sql, sqldata)
        conn.commit()
        conn.close()

크롤링 완료 후 GET RESULT 버튼을 클릭하면 로컬에 

84KB의 파일이 생성된다

(쿼리 오류가 발생하지 않는 걸 보면, 측정소 id는 중복되는 항목이 역시 없는 것 같다~ ㅎㅎ)

 

테스트해보고 싶은 사람을 위해 파일 공유~

airkorea_obs_list.db
0.08MB

 

sqlite 샘플 스크립트를 만들어서 쿼리해보자

import sqlite3

dbpath = './airkorea_obs_list.db'
conn = sqlite3.connect(dbpath)
cursor = conn.cursor()
sql = "SELECT * FROM airkoreaobs LIMIT 10;"
cursor.execute(sql)
records = cursor.fetchall()
for elem in records:
    print(elem)

sql = "SELECT * FROM airkoreaobs;"
cursor.execute(sql)
records = cursor.fetchall()
print(f"총 레코드 개수: {len(records)}")
conn.close()
('336523', '가거도', '전남 신안군 흑산면 가거도리 산 4 가거도 등대부지 내')
('534464', '격렬비열도', '충남 태안군 근흥면 가의도길 44-71번지 서해종합기상관측소 옥상')
('339312', '고산리', '제주 제주시 한경면 고산리 3762')
('735126', '말도', '전라북도 군산시 옥도면 말도2길 29 말도 항로표지관리소')
('831492', '백령도', '인천 옹진군 백령면 연화리 산241-2')
('336462', '안마도', '전남 영광군 낙월면 영외리 산 118 안마도 측정소')
('831494', '연평도', '인천 옹진군 연평면 연평리 631 하나회관 옥상')
('534484', '외연도', '충남 보령시 오천면 외연도리 산64 헬기장 옆부지')
('831495', '울도', '인천 옹진군 덕적면 울도리 85번지 사유지, 이형노')
('437541', '태하리', '경북 울릉군 서면 태하리 산99-3 (울릉군 항로표지관리소)')
총 레코드 개수: 615

굳!!!

 

이제 에어코리아에 등록된 모든 측정소의 측정소명과 주소를 가져왔는데, 지도 시각화를 위해서는 위도경도값이 필요하다

 

위 예시를 보면 알겠지만 "가거도 등지부내 내", "하나회관 목장", "헬기장 옆부지" 등 사소한 주소 정보들이 함께 기입되어 있는데, 주소 문자열을 통해 위도와 경도를 얻기 위해서는 아마 다 잘라내야하지 않을까 싶다... 다음 글에서는 측정소의 주소를 토대로 위도 경도값을 얻어온 뒤, 지도에 측정소 위치들을 표시하여 시각화하는 방법을 알아보자

5. GitHub repository 등록

https://github.com/YOGYUI/AirKoreaObservatoryWebCrawler

 

GitHub - YOGYUI/AirKoreaObservatoryWebCrawler

Contribute to YOGYUI/AirKoreaObservatoryWebCrawler development by creating an account on GitHub.

github.com

귀찮지만, 코드 관리를 위해서라도 블로그에 올리는 글들을 꼬박꼬박 깃헙에 올리고있다 ㅎㅎ

 

끝~!

 

[참고]

https://androman.tistory.com/43

https://stackoverflow.com/questions/596314/jquery-ids-with-spaces

반응형