YOGYUI

웹크롤링 - DART 기업개황 업종별 기업 리스트 가져오기 (2) 본문

Data Analysis/Data Engineering

웹크롤링 - DART 기업개황 업종별 기업 리스트 가져오기 (2)

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

Get Corporations List Classified by Sectors from DART(fss)

[시리즈]

웹크롤링 - DART 기업개황 업종별 기업 리스트 가져오기 (1)
웹크롤링 - DART 기업개황 업종별 기업 리스트 가져오기 (2)
웹크롤링 - DART 기업개황 업종별 기업 리스트 가져오기 (3)
웹크롤링 - DART 기업개황 업종별 기업 리스트 가져오기 (Final)

4. 트리 노드 위계구조 파악

업종 정보 트리의 위계구조(hierarchy)를 함께 가져와야 나만의 서비스를 만들기 편하다

jsTree 노드 객체의 부모 노드의 정보는 객체 내부에 parents 속성으로 조회할 수 있다

예를 위해 '곡물 및 기타 식량작물 재배업' 최하위 노드에 접근해보자

var tree = $j("#businessTree").jstree(true);
var node_01110 = tree.get_node("01110");

parents 호출 시 노드의 id 문자열로 구성된 배열이 반환되며, 배열의 순서는 트리 위계구조의 역순인 것을 알 수 있고, 다음과 같이 부모 노드들의 디스플레이 문자열을 가져올 수 있다

node_01110_parents_name = [];
for (i = 0; i < node_01110.parents.length; i++) {
    node_id = node_01110.parents[node_01110.parents.length - 1 - i];
    node = tree.get_node(node_id);
    node_01110_parents_name.push({id: node_id, text: node.text});
}

노드 id가 '#'인 것만 제외해주면 될 것 같다 (id가 'all' 혹은 'all_anchor'가 시작 노드인 '전체')

 

앞서 구현했던 재귀함수를 조금 수정해보자

// jsTree 노드의 부모 노드들의 id와 text 어레이로 반환
function fnGetParents(treeObj, node) {
    parents = [];
    for (i = 0; i < node.parents.length - 1; i++) {
        parent_node_id = node.parents[i];
        parent_node = treeObj.get_node(parent_node_id);
        parents.push({id: parent_node_id, text: parent_node.text});
    }
    parents.reverse();
    
    return parents;
}

// jsTree 최하위노드 재귀검색
function fnTreeGetLeafNodes(treeObj, node, arrCollector) {
    var child_id_arr = node.children;    // 노드의 자식 노드들의 id 문자열이 담긴 어레이를 가져온다
    var arrCollector = (arrCollector) ? arrCollector : [];

    for (var i = 0; i < child_id_arr.length; ++i) {
        var node_id = child_id_arr[i];
        var nodeChild = treeObj.get_node(node_id);    // 트리의 노드 객체를 가져온다
        if (tree.is_leaf(node_id)) {
            // 잎노드일 경우 (최하위 노드)
            arrCollector.push({
                node_id: node_id,
                node_text: nodeChild.text,
                parents: fnGetParents(treeObj, nodeChild),
                corp_names: []
            });
        } else {
            // 최하위 노드가 아니면 재귀호출
            arrCollector = fnTreeGetLeafNodes(treeObj, nodeChild, arrCollector);
        }
    }

    return arrCollector;
}

주석을 허접하게나마 달아놓긴 했는데, 알고리즘 자체가 워낙에 간단하니 별로 어려운 코드는 아니다

결과 어레이에 푸쉬하는 딕셔너리에 corp_names 어레이를 추가해줘서 나중에 최하위 노드의 기업 리스트(테이블에서 크롤링)를 담을 수 있도록 추가해준게 주요 변경사항이다

 

웹페이지(https://dart.fss.or.kr/dsae001/main.do#none)를 reload한 뒤 함수를 호출해보자

var tree = $j("#businessTree").jstree(true);
top_node = tree.get_node('all');
arr_leaf_nodes = fnTreeGetLeafNodes(tree, top_node);

의도했던대로 제대로 동작한다!

노드마다 (엄청 중복되긴 하지만...) 부모 노드들의 위계구조 정보가 담겨있으므로 크롤링 결과물을 나만의 트리 구조로 구성하는데도 문제가 없을 것 같다

5. 최하위 노드의 기업 리스트 가져오기

앞서 테이블 페이지를 딜레이를 줘가면서 순차 선택 (search 함수 호출)하며 테이블의 기업 리스트를 크롤링하는 테스트를 진행했는데, 제대로 되었기에 하나의 함수로 만들어주자

(비동기 딜레이, 비동기 함수 순차호출 기법)

시퀀스는 「트리 노드 선택」 - 「딜레이」 - (「테이블 페이지 선택」 - 「딜레이」) > 반복

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

// search 함수 호출 후 <table>에서 기업 정보 가져오기
async function fnGetCorpNamesFromTableSearch(index, corp_name_arr, delay_ms) {
    var corp_name_arr = (corp_name_arr) ? corp_name_arr : [];
    var table = document.getElementsByClassName("tb")[0];
    var tbody = table.getElementsByTagName("tbody")[0];
    var tr_list = tbody.getElementsByTagName("tr");
        
    search(index);
    await sleep(delay_ms);
    
    for (i = 0; i < tr_list.length; i++) {
        tr = tr_list[i];
        td = tr.getElementsByTagName("td")[0];
        span = td.getElementsByTagName("span")[0];
        a = span.getElementsByTagName("a")[0];
        corp_name = a.text;
        corp_name = corp_name.replace(/(\r\n|\n|\r|\t)/gm,"");
        corp_name_arr.push(corp_name);
    }
    
    return corp_name_arr;
}

// 페이지 여러개에 대한 비동기 순차 테이블 조회
async function fnGetCorpNamesFromTableAll(corp_name_arr, delay_ms) {
    var pageInfo = document.getElementsByClassName("pageInfo");
    var pageSkip = document.getElementsByClassName("pageSkip")[0];
    var li_tags = pageSkip.getElementsByTagName("li");
    var arrIndex = [];
    for (i = 0; i < li_tags.length; i++) {
        arrIndex.push(i + 1);
    }
    
    await arrIndex.reduce((prevTask, currTask) => {
        return prevTask.then(() => fnGetCorpNamesFromTableSearch(currTask, corp_name_arr, delay_ms));
    }, Promise.resolve());
}

그리고 앞서 트리에서 추출한 최하위 노드 딕셔너리를 인자로 받아 딕셔너리 내의 corp_names 어레이에 테이블 기업명 리스트를 덧붙이는 작업을 해주는 함수도 만들어주자

// 트리 노드 선택 후 테이블에서 기업 정보 가져오기
async function fnSelectNodeAndGetCorpNamesFromTableAll(leaf_node_dict, treeObj, delay_ms) {
    // 트리 노드 선택 - 딜레이
    var node_id = leaf_node_dict.node_id;
    treeObj.select_node(node_id);
    await sleep(delay_ms);

    // 테이블에서 모든 기업 정보 가져오기
    var corp_name_arr = []
    await fnGetCorpNamesFromTableAll(corp_name_arr, delay_ms);
    // 기업 정보를 가져온 뒤 결과를 딕셔너리 내의 어레이에 concat
    leaf_node_dict.corp_names = leaf_node_dict.corp_names.concat(corp_name_arr);
    
    // 트리 노드 선택 해제
    treeObj.deselect_node(node_id);
}

마지막으로 위에서 구현한 함수들을 한번에 동작시켜줄 호출 함수를 만들어주자

// 호출 함수
async function fnProcess(leaf_node_arr, treeObj, delay_ms) {
	await leaf_node_arr.reduce((prevTask, currTask) => {
		return prevTask.then(() => fnSelectNodeAndGetCorpNamesFromTableAll(currTask, tree, delay_ms));
	}, Promise.resolve());
}

이제 동작을 확인해보자 (테스트를 위해 잎노드 5개만 확인해보자)

페이지 렌더링 시간 확보를 위해 노드 선택 후 딜레이 및 테이블 페이지 조회 딜레이는 넉넉하게 1초로 설정~

fnProcess(arr_leaf_nodes.slice(0, 5), tree, 1000);

 

의도한 대로 1초 간격으로 노드를 선택한 뒤 테이블에 기입된 기업 목록이 arr_leaf_nodes[0:5] 각 요소의 corp_names에 제대로 추가된 것을 확인할 수 있다

6. Python으로 가져오기 (자동화)

자바스크립트에 비해 파이썬은 크롤링 자동화 코드를 만들기가 훨씬 수월하다 

(웹브라우저를 이용하는건 동일한데, 파이썬은 chromium을 백그라운드에서 돌리고 자바스크립트를 구동할 수 있는 솔루션이 많다)

 

우선 PyQt5의 QtWebEngine을 이용해서 그럴듯하게 만들어보자

이때까지 구현한 자바스크립트 전체 코드는 다음과 같고, 이걸 파일로 저장해주자

(파일명은 run_code.js)

/* 
* 필요한 함수 선언 
*/
// jsTree 노드의 부모 노드들의 id와 text 어레이로 반환
function fnGetParents(treeObj, node) {
    parents = [];
    for (i = 0; i < node.parents.length - 1; i++) {
        parent_node_id = node.parents[i];
        parent_node = treeObj.get_node(parent_node_id);
        parents.push({id: parent_node_id, text: parent_node.text});
    }
    parents.reverse();
    
    return parents;
}

// jsTree 최하위노드 재귀검색
function fnTreeGetLeafNodes(treeObj, node, arrCollector) {
    var child_id_arr = node.children;    // 노드의 자식 노드들의 id 문자열이 담긴 어레이를 가져온다
    var arrCollector = (arrCollector) ? arrCollector : [];

    for (var i = 0; i < child_id_arr.length; ++i) {
        var node_id = child_id_arr[i];
        var nodeChild = treeObj.get_node(node_id);    // 트리의 노드 객체를 가져온다
        if (tree.is_leaf(node_id)) {
            // 잎노드일 경우 (최하위 노드)
            arrCollector.push({
                node_id: node_id,
                node_text: nodeChild.text,
                parents: fnGetParents(treeObj, nodeChild),
                corp_names: []
            });
        } else {
            // 최하위 노드가 아니면 재귀호출
            arrCollector = fnTreeGetLeafNodes(treeObj, nodeChild, arrCollector);
        }
    }

    return arrCollector;
}

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

// search 함수 호출 후 <table>에서 기업 정보 가져오기
async function fnGetCorpNamesFromTableSearch(index, corp_name_arr, delay_ms) {
    var corp_name_arr = (corp_name_arr) ? corp_name_arr : [];
    var table = document.getElementsByClassName("tb")[0];
    var tbody = table.getElementsByTagName("tbody")[0];
    var tr_list = tbody.getElementsByTagName("tr");
        
    search(index);
    await sleep(delay_ms);
    
    for (i = 0; i < tr_list.length; i++) {
        tr = tr_list[i];
        td = tr.getElementsByTagName("td")[0];
        span = td.getElementsByTagName("span")[0];
        a = span.getElementsByTagName("a")[0];
        corp_name = a.text;
        corp_name = corp_name.replace(/(\r\n|\n|\r|\t)/gm,"");
        corp_name_arr.push(corp_name);
    }
    
    return corp_name_arr;
}

// 페이지 여러개에 대한 비동기 순차 테이블 조회
async function fnGetCorpNamesFromTableAll(corp_name_arr, delay_ms) {
    var pageInfo = document.getElementsByClassName("pageInfo");
    var pageSkip = document.getElementsByClassName("pageSkip")[0];
    var li_tags = pageSkip.getElementsByTagName("li");
    var arrIndex = [];
    for (i = 0; i < li_tags.length; i++) {
        arrIndex.push(i + 1);
    }
    
    await arrIndex.reduce((prevTask, currTask) => {
        return prevTask.then(() => fnGetCorpNamesFromTableSearch(currTask, corp_name_arr, delay_ms));
    }, Promise.resolve());
}

// 트리 노드 선택 후 테이블에서 기업 정보 가져오기
async function fnSelectNodeAndGetCorpNamesFromTableAll(leaf_node_dict, treeObj, delay_ms) {
    // 트리 노드 선택 - 딜레이
    var node_id = leaf_node_dict.node_id;
    treeObj.select_node(node_id);
    
    await sleep(delay_ms);

    // 테이블에서 모든 기업 정보 가져오기
    var corp_name_arr = []
    await fnGetCorpNamesFromTableAll(corp_name_arr, delay_ms);
    // 기업 정보를 가져온 뒤 결과를 딕셔너리 내의 어레이에 concat
    leaf_node_dict.corp_names = leaf_node_dict.corp_names.concat(corp_name_arr);
    
    // 트리 노드 선택 해제
    treeObj.deselect_node(node_id);
}

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

console.log('Start');
fn_toggleTab("business");  // 크롤링 시각화 위해 탭 변경 (선택사항)

/* 
* Step.1 트리의 모든 최하위 노드 가져오기 
*/
var tree = $j("#businessTree").jstree(true);
top_node = tree.get_node('all');
arr_leaf_nodes = fnTreeGetLeafNodes(tree, top_node);

/*
* Step.2 모든 최하위 노드들을 순차 조회하며 테이블에서 기업 정보 가져오기
*/
// fnProcess(arr_leaf_nodes, tree, 1000);
fnProcess(arr_leaf_nodes.slice(0, 3), tree, 1000);

참고로 fn_toggleTab("business")를 호출하면 URL 최초 로딩 후 '업종별' 탭을 활성화해준다

 

파이썬 코드는 다음과 같이 구현해봤다

import os.path
import sys
from PyQt5.QtCore import QUrl, pyqtSignal
from PyQt5.QtGui import QShowEvent, QCloseEvent
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 DartCrawlerWindow(QMainWindow):
    _dart_url: str = "https://dart.fss.or.kr/dsae001/main.do"

    def __init__(self):
        super().__init__()
        self._webview = QWebEngineView()
        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._editConsole.setReadOnly(True)
        self._editConsole.setFixedHeight(100)
        self._editConsole.setLineWrapColumnOrWidth(-1)
        self._editConsole.setLineWrapMode(QTextEdit.FixedPixelWidth)

    def startCrawl(self):
        script_path = os.path.abspath('./run_code.js')
        with open(script_path, 'r', encoding='utf-8') as fp:
            script = fp.read()
            self._editConsole.clear()
            self._webview.page().runJavaScript(script, self.callbackJavascript)

    def getResult(self):
        self._webview.page().runJavaScript("arr_leaf_nodes;", self.callbackResult)

    def showEvent(self, a0: QShowEvent) -> None:
        self._webview.load(QUrl(self._dart_url))

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

    def onWebPageConsoleMessage(self, level, message, line, source):
        text = self._editConsole.toPlainText()
        text += f'{message} (lv:{level}, line:{line})\n'
        self._editConsole.setText(text)

    def callbackJavascript(self, result: object):
        text = self._editConsole.toPlainText()
        text += f'>> {result}\n'
        self._editConsole.setText(text)

    def callbackResult(self, result: object):
        if isinstance(result, list):
            self._editConsole.clear()
            for i in range(3):
                text = self._editConsole.toPlainText()
                text += f'{result[i]}\n'
                self._editConsole.setText(text)

            def parse_dict(obj: dict):
                """
                [dict 구조]
                node_id: str = jstree 노드 아이디
                node_text: str = jstree 노드 텍스트 = 업종분류
                parents: list of dict, dict element = {'id': 노드 아이디, 'text': 노드 텍스트 = 업종분류}
                corp_names: list of str, str element = 해당 기업명
                """
                pass

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

비동기 트리 노드 조회 결과에 대한 콜백 처리를 하기가 힘들어서 우선은 함수 호출 시작, 종료에 대한 콘솔 로그를 남기고, 모두 종료되면 arr_leaf_nodes 값을 가져오는 자바스크립트만 호출(getResult)하는 버튼을 따로 만들었다

그리고 어레이 결과물에 대한 파싱 (위계구조 구축)은 다음 구현으로 넘기고 일단 텍스트박스에 결과만 출력하게 만들어봤다

(Qt 기반으로 하니 여러가지 제약이 좀 있네;; ㅎㅎ)

텍스트박스 출력결과

{'corp_names': ['농업회사법인동주물산', '농업회사법인보령스마트에너지', '농업회사법인어석', '농업회사법인위더그린', '농업회사법인장지', '농업회사법인제희', '농업회사법인현대서산농장', '대원영농조합법인', '대한영농영림', '베이스제일차', '신명알앤디', '아라미주팜', '이그린글로벌', '키스비케이제삼차', '현대서산천지인큰농장영농조합', '효원'], 'node_id': '01110', 'node_text': '곡물 및 기타 식량작물 재배업', 'parents': [{'id': 'all', 'text': '전체'}, {'id': 'root0103', 'text': '농업, 임업 및 어업'}, {'id': '01', 'text': '농업'}, {'id': '011', 'text': '작물 재배업'}, {'id': '0111', 'text': '곡물 및 기타 식량작물 재배업'}]}
{'corp_names': ['농업회사법인만나씨이에이', '농업회사법인만나타운', '농업회사법인아그로닉스', '농업회사법인재서농원', '농업회사법인진도청정푸드밸리', '농업회사법인팜잇', '농업회사법인팜잇2호', '동부팜아그로', '한서울'], 'node_id': '01121', 'node_text': '채소작물 재배업', 'parents': [{'id': 'all', 'text': '전체'}, {'id': 'root0103', 'text': '농업, 임업 및 어업'}, {'id': '01', 'text': '농업'}, {'id': '011', 'text': '작물 재배업'}, {'id': '0112', 'text': '채소, 화훼작물 및 종묘 재배업'}]}
{'corp_names': ['농헙회사법인한송'], 'node_id': '01122', 'node_text': '화훼작물 재배업', 'parents': [{'id': 'all', 'text': '전체'}, {'id': 'root0103', 'text': '농업, 임업 및 어업'}, {'id': '01', 'text': '농업'}, {'id': '011', 'text': '작물 재배업'}, {'id': '0112', 'text': '채소, 화훼작물 및 종묘 재배업'}]}

의도한대로 동작하긴 하는데...

완료 콜백이 없으니 사용자가 완료 메시지를 확인한 후  별도로 리스트를 가져와야하는게 너무 멍청해보인다 (자동화가 아예 안된다)


이래저래 짱구를 굴리다보니 좀 지친다 ㅠㅠ

남은 작업들은 다음 포스팅에서 마무리짓도록 하자...

 

다음 글 내용

(1) 반환 결과 위계 구조 파싱 및 트리뷰 표현 (QTreeWidget)

(2) 비동기 함수 호출 완료에 대한 콜백 구현 (Python에서 JS 콜백 처리하는 방법)

(3) Database에 업종별 기업 리스트 저장 (SQL)

 

[시리즈]

웹크롤링 - DART 기업개황 업종별 기업 리스트 가져오기 (1)
웹크롤링 - DART 기업개황 업종별 기업 리스트 가져오기 (2)
웹크롤링 - DART 기업개황 업종별 기업 리스트 가져오기 (3)
웹크롤링 - DART 기업개황 업종별 기업 리스트 가져오기 (Final)

반응형