이번 글에서는 [Python 재무제표 크롤링 #1] 요구사항, 재무제표 분석 및 설계에서 작성했던 코드를 다듬고, 실행 결과 파일 저장명을 기업명(또는 기업종목코드)와 해당 보고서명의 조합으로 저장하기 위한 코드를 추가했다.
또한 재무상태표의 표만을 엑셀에 저장하는 것이 아닌, 재무상태표의 속성정보(기재 날짜, 단위 등)를 표현할 수 있는 방법에 대해 고민해보았고, 이를 여러 번의 테스트 끝에 실제 코드로 구현에 성공했다.

분석 및 설계

  • 결과 파일의 이름을 [기업명(기업종목코드) 보고서명]으로 저장하기 위한 코드 수정

  • 재무상태표 상단에 재무상태표가 가지는 정보들 (단위 등)을 표기할 수 있는 방안 고찰
    1. 기존 Dataframe타입 변수에서 재무상태표 정보를 가져오는 테스트, 실패.
    2. 추가적인 파싱 작업을 통하여 재무상태표 정보를 새로이 가져오는 테스트, 성공!
    3. 엑셀 파일로 저장하기 전, Dataframe 최상단 행(row)에 재무상태표 정보를 추가하는 방안 테스트, 실패.
      일반적인 방법으로는 Dataframe 최상단 행(row)에 데이터를 추가하기 까다로웠으며, 서로 가지는 열(Column)이 뒤섞이는 문제 발생
    4. 재무상태표 정보를 담은 Dataframe과 재무상태표 Dataframe 2개로 나누어 한꺼번에 엑셀 파일로 저장하는 테스트, 실패. 행(row)이 뒤섞이거나, 불필요한 열(column)이 계속해서 표기 되는 문제 발생
    5. 재무상태표 정보 Dataframe과 재무상태표 Dataframe을 순차적으로 같은 파일에 저장하는 테스트 진행, 성공!
      엑셀 파일 저장에 사용되는 ‘to_csv()’ 함수의 인자로 불필요한 행(row)와 열(column) 표기를 제거하거나, 덮어쓰기와 이어쓰기 선택이 가능했음.
  • 재무상태표 상단에 재무상태표의 단위와 기타 속성 정보들을 표기할 수 있도록 코드 추가

  • 탐색 성공 여부와, 탐색 실패시 어떠한 영역에서 실패하였는지 출력하는 코드 추가

  • 연결재무제표와 개별재무제표를 하나의 엑셀 파일에 저장할 수 있는 방안 고찰
    반복사용되는 코드 영역을 개별 함수로 분리하고, 연결재무제표 페이지와 개별재무제표 페이지로 넘어가는 URL을 각각 구해서 분리한 함수에 인자로 넘겨줄 수 있도록 코드를 추가 및 수정함.

  • 추가 및 수정된 코드 테스트 진행. 성공적으로 저장되는 모습을 확인 함.

작성 코드

from bs4 import BeautifulSoup                            # for html parser
from urllib.request import urlopen                       # for html request/respone
import pandas as pd                                      # for DataFrame
from html_table_parser import parser_functions as parser #for parsing

API_KEY = "" # API 이용에 필요한 인증번호
COMPANY_CODE = input("기업종목코드 6자리 : ")

###########################################################################################################
# 기업종목번호 기반 전체 사업보고서 검색 함수                                                                #
# Input : NULL       /       Output : DataFrame                                                           #
###########################################################################################################
def searching_report() :                    
    SEARCH_URL = "http://dart.fss.or.kr/api/search.xml?auth=" + API_KEY + "&crp_cd=" + COMPANY_CODE
    SEARCH_URL = SEARCH_URL + "&start_dt=19990101&bsn_tp=A001&fin_rpt=Y"    #검색날짜 범위 / 사업보고서 / 최종보고서만
    XML_RESULT = BeautifulSoup(urlopen(SEARCH_URL).read(), 'html.parser')

    find_list = XML_RESULT.findAll("list")  # list태그를 모두 탐색
    data = pd.DataFrame()                   # 데이터를 저장할 프레임 선언
    for t in find_list :                    # 나열번호, 기업명, 기업번호, 보고서명, 보고서번호, 제출인,  접수일자, 비고
        temp = pd.DataFrame(([[t.crp_cls.string, t.crp_nm.string, t.crp_cd.string, t.rpt_nm.string,
                t.rcp_no.string, t.flr_nm.string, t.rcp_dt.string, t.rmk.string]]),
                columns = ["crp_cls", "crp_nm", "crp_cd", "rpt_nm", "rcp_no", "flr_nm", "rcp_dt", "rmk"])
        data = pd.concat([data, temp])
    
    if len(data) < 1 :                      # 검색 결과가 없을 시.
        return None

    del data['crp_cls']
    del data['crp_cd']                      # 나열번호, 기업번호 제거 (불필요)
    data = data.reset_index(drop=True)

    return data

###########################################################################################################
# 검색된 전체 사업보고서 기반 재무제표 페이지 이동 함수                                                       #
# Input : NULL       /       Output : NULL                                                                #
###########################################################################################################
def fs_page() :
    data = searching_report()
    if len(data) < 1 :
        print("!!!! 실패 : 사업보고서 검색 결과 없음.")
        return 0

    document_count = 0

    for i in range(len(data)) :
        MAIN_URL = "http://dart.fss.or.kr/dsaf001/main.do?rcpNo=" + data['rcp_no'][document_count]
        link_flag = 1
        indi_flag = 1

        page = BeautifulSoup(urlopen(MAIN_URL).read(), 'html.parser')
        body = str(page.find('head'))

        if len(body.split('연결재무제표",')) <= 1 :             # 연결재무제표 탐색 시작
            if len(body.split('연 결 재 무 제 표",')) >=2 : 
                body = body.split('연 결 재 무 제 표",')[1]     # "연 결 재 무 제 표" 로 발견
                link_page_1 = '연 결 재 무 제 표'
            else :                                             # 실패시 경고 문구
                print("!!!! 실패 : " + data['rpt_nm'][document_count] + " / 연결재무제표 페이지 탐색 실패함.")
                link_flag = 0         #연결 재무제표 없음
        else :
            body = body.split('연결재무제표",')[1]              # "연결재무제표" 로 발견
            link_page_1 = '연결재무제표'
        
        indi_body = body;        
        if len(indi_body.split('재무제표",')) <= 1:             # 개별재무제표 탐색 시작
            if len(indi_body.split('재 무 제 표",')) >= 2:
                indi_body = indi_body.split('재 무 제 표",')[1] # "재 무 제 표" 로 발견
                indi_page_1 = '재 무 제 표'
            else :                                             # 실패시 경고 문구
                print("!!!! 실패 : " + data['rpt_nm'][document_count] + " / 재무제표 페이지 탐색 실패함.")
                indi_flag = 0           #개별 재무제표 없음
        else :
            indi_body = indi_body.split('재무제표",')[1]        # "재무제표" 로 발견
            indi_page_1 = '재무제표'

        if(link_flag == 1) : fs_table(body, data, document_count, link_page_1)      # 연결 재무제표가 있으면
        if(indi_flag == 1) : fs_table(indi_body, data, document_count, indi_page_1) # 개별 재무제표가 있으면

        document_count += 1
    return

###########################################################################################################
# 목차를 통해 이동한 페이지에서 표 값들을 추출하는 함수                                                       #
# Input : string, DataFrame, int, string       /       Output : NULL                                      #
###########################################################################################################
def fs_table(body, data, document_count, page_1) :
    body = body.split('cnt++')[0].split('viewDoc(')[1].split(')')[0].split(', ')
    body = [body[i][1:-1] for i in range(len(body))]       # 찾아낸 재무제표 페이지로 이동하기 위한 url구성 번호 파싱
    VIEWER_URL = "http://dart.fss.or.kr/report/viewer.do?rcpNo=" + body[0] \
                + '&dcmNo=' + body[1] + '&eleId=' + body[2] + '&offset=' + body[3] \
                + '&length=' + body[4] + '&dtd=dart3.xsd'

    table_attribute = BeautifulSoup(urlopen(VIEWER_URL).read(), 'html.parser')
    table_attribute = table_attribute.find('table') # 가장 먼저 등장하는 table태그 기준 가져오기
    pt = parser.make2d(table_attribute)             # list로 변환저장 - 과정에서 다른 불필요한 태그들 자동 제거
    table_attribute = pd.DataFrame(pt)              # DataFrame으로 변환하여 다시저장
    
    table_attribute = table_attribute.applymap(lambda x: x.replace('\xa0','').replace('\xa9','')) #인코딩을 위해 공백을 의미하는 특수문자열 제거
    table_attribute.to_csv('C:\\Users\\admin\\Desktop\\Test_Result\\' + data['crp_nm'][0] + "(" + COMPANY_CODE
                + ") " + data['rpt_nm'][document_count] +'  .csv', encoding='cp949', header=False, index=False, mode='a')
                #header를 통해 column명 표기X, index를 통해 row명 표기X, mode w로 파일 덮어쓰기식 생성

    page = BeautifulSoup(urlopen(VIEWER_URL).read(), 'html.parser')
    if len(str(page.find('body')).split('재 무 상 태 표')) == 1 :   # 재무상태표 탐색 시작
        if len(str(page.find('body')).split('재무상태표')) <= 1 :   # 재무상태표를 찾아내지 못한다면 프로그램 종료
            print("### 연결 재무상태표 탐색 실패.")
            return 0
        else :
            body = str(page.find('body')).split('재무상태표')[1]    # "재무상태표" 로 발견
            page_2 = '연결 재무상태표'
    else : 
        body = str(page.find('body')).split('재 무 상 태 표')[1]    # "재 무 상 태 표" 로 발견
        page_2 = '연 결 재 무 상 태 표'
    
    body = BeautifulSoup(body, 'html.parser')                       # 찾아낸 재무상태표를 읽어내기 위해 파싱

    print("탐색 성공 : " + data['rpt_nm'][document_count] + " / " + page_1 + " / " + page_2)
    
    table = body.find_all('table')  # table 태그 탐색
    if len(table) <= 1 :            # 탐색 실패시 프로그램 종료
        print("- " + data['rpt_nm'][document_count] + " / " + page_1 + " / " + page_2 + "### 재무상태표 파싱 실패.")
        return 0

    p = parser.make2d(table[0])    
    table = pd.DataFrame(p[1:], columns = p[0])

    table.to_csv('C:\\Users\\admin\\Desktop\\Test_Result\\' + data['crp_nm'][0] + "(" + COMPANY_CODE
                + ") " + data['rpt_nm'][document_count] +'.csv', encoding='cp949', index=False, mode='a')
                #index를 통해 row명 표기X, mode a로 파일 이어쓰기식 열기
    return

print(fs_page())          # for testing.

기업종목번호 6자리를 입력 받고, API 키를 이용해 인증 후 재무제표가 포함된 연단위 사업보고서 전체를 검색하는 페이지 URL을 합성하는 과정은 동일하다. searching_report() 함수를 통해 사업보고서 전체를 검색하는 페이지 URL에서 각각 사업보고서의 페이지를 열기 위한 정보들을 모은 다음, 불필요한 정보는 제거한 뒤 fs_page() 함수로 이동한다.

fs_page() 함수로 이동 된 정보들은 사업보고서 복수 개의 정보들이므로, for문을 통해 반복 된다. 가져온 사업보고서의 정보들 중, 사업보고서 번호를 의미하는 ‘rcp_no’를 기본 URL에 첨가하여 사업보고서 페이지를 구성하는 URL을 합성한다. 합성된 URL에서 가져온 페이지 소스를 BeautifulSoup 모듈을 이용해 html형식으로 읽어들이고, 읽어들인 html형식 소스 중 가장 먼저 등장하는 <head> 태그를 찾아낸다.

찾아낸 <head> 태그 내부에 포함되는 모든 소스코드들 중에서, 연결재무제표연 결 재 무 제 표 두 단어를 찾아 탐색을 진행한다.

위와 같이 띄어쓰기를 포함한 두 가지 경우를 탐색하는 이유는, 사업보고서의 규격이 통일되기 이전인 2013~2015년도 까지는 각 기업들의 사업보고서 양식이 모두 상이했기 때문에 어떠한 양식을 지니고 있을지 가늠할 수 없었다. 때문에 최대한 많은 경우의 수를 가진 연결재무제표연 결 재 무 제 표를 기준 삼아 탐색을 진행하게 된다.
둘 중 하나라도 발견하게 된다면 그냥 지나가지만, 발견하지 못 할 경우 연결재무제표 탐색 성공/실패를 판별하는 link_flag의 값을 1에서 0으로 변경시킨다.

개별 재무제표 또한 연결재무제표와 같이 개별 재무제표에 관한 정보를 때어오며, 둘 중 하나라도 발견하지 못 할 경우 indi_flag의 값을 1에서 0으로 변경시킨다.

이후 link_flag와 indi_flag의 값을 확인하는 각각의 if조건문을 통해 각각의 값이 1인 경우 해당 정보를 가지고 fs_table() 함수로 이동하게 된다. 함수의 동작이 끝나면 다음 사업보고서로 다시 반복되게 된다.

fs_table() 함수에서는 각각 입력 받은 정보(인자)들을 통해 연결(개별)재무제표 페이지로 이동하기 위한 url을 합성한다.

합성된 url에서 가장 먼저 등장하는 <table> 태그를 기준으로 정보들을 색출해내는데, 이것은 재무상태표의 속성정보가 담긴 table의 태그이다. 색출한 정보들을 pandas DataFrame으로 변환하기 위해 parser모듈의 make2d 함수를 이용해 list타입으로 강제 변환시키며, 이 과정에서 다른 불필요한 태그들이 자동적으로 제거된다.
DataFrame타입으로 저장된 정보들에는 make2d함수의 강제 변환 과정에서 공백, 개행 문자들이 특수문자(\xa9, \xa0)로 변환되기 때문에 이를 핸들링하기 위해 공백으로 replace해준다.
핸들링이 완료된 DataFrame의 정보들을 csv파일로 내보낸다.

이후 다시 한 번 소스코드를 탐색하여 <body> 태그를 탐색하고, 찾아낸 <body> 태그 내부에서 재 무 상 태 표 또는 재무상태표를 탐색한다. 둘 중 하나라도 탐색에 성공한다면 탐색 성공을 알리고 재무상태표의 속성정보를 저장했던 csv파일을 열어 추가로 재무상태표 DataFrame의 값들을 저장하여 csv파일을 완성하고 함수가 종료된다. 만일 둘 모두 실패했을 경우 실패를 알리고 함수가 종료된다.

결과화면

기업종목코드 6자리 : ******
탐색 성공 : 사업보고서 (2018.12) / 연결재무제표 / 연결 재무상태표
탐색 성공 : 사업보고서 (2018.12) / 재무제표 / 연결 재무상태표
탐색 성공 : 사업보고서 (2017.12) / 연결재무제표 / 연결 재무상태표
탐색 성공 : 사업보고서 (2017.12) / 재무제표 / 연결 재무상태표
탐색 성공 : 사업보고서 (2016.12) / 연결재무제표 / 연결 재무상태표
탐색 성공 : 사업보고서 (2016.12) / 재무제표 / 연결 재무상태표
탐색 성공 : 사업보고서 (2015.12) / 연결재무제표 / 연결 재무상태표
탐색 성공 : 사업보고서 (2015.12) / 재무제표 / 연결 재무상태표
탐색 성공 : 사업보고서 (2014.12) / 연결재무제표 / 연결 재무상태표
탐색 성공 : 사업보고서 (2014.12) / 재무제표 / 연결 재무상태표
!!!! 실패 : [기재정정]사업보고서 (2013.12) / 연결재무제표 페이지 탐색 실패함.
!!!! 실패 : [기재정정]사업보고서 (2013.12) / 재무제표 페이지 탐색 실패함.
!!!! 실패 : 사업보고서 (2012.12) / 연결재무제표 페이지 탐색 실패함.
!!!! 실패 : 사업보고서 (2012.12) / 재무제표 페이지 탐색 실패함.

2018년 사업보고서부터 2014년 사업보고서까지는 연결 재무상태표와 개별 재무상태표(결과화면에 모두 연결 재무상태표 라고 나오는 건 테스트를 위함)에 탐색에 성공하였으나, 2013년 사업보고서부터는 탐색에 실패한 것을 확인할 수 있다. 2013~2015년 사이 사업보고서의 규격이 통일되기 전의 보고서 양식으로, 해당 양식에 대해서는 추가적인 핸들링을 필요로 할 것 같다.

folder_result excel_result

고찰 및 후기

이미 가져온 재무상태표 정보가 담긴 DataFrame의 최상단 행(row)에 추가적인 정보를 입력하려 했을 때, 지원하는 함수가 없었던 것인지 내가 못 찾은 것인지는 모르겠지만 추가 정보 입력이 어려웠다.
어찌저찌 추가에 성공을 하였으나 재무상태표 자체가 가지는 열(column)과 재무상태표 속성정보가 가지는 열(column)이 달랐기 때문에 이들이 서로 뒤엉키는 문제까지 발생하여, 완전히 다른 방법을 고안해 볼 수 밖에 없었다.

가장 빠르게 해결할 수 있었던 방안으로, 재무상태표 속성정보를 csv 파일로 우선 저장하고, 재무상태표 값들은 재무상태표 속성정보가 담긴 csv파일에 이어붙이기 식으로 저장하는 방법을 채택했다. 파일 open이 두 차례나 실시되어 코드 자체의 효용성은 굉장히 나쁘게 보이나, 막연히 하나의 문제에만 매달려 있을 수는 없으므로, 당장 동작이 성공적으로 이루어지는데에 의의를 두고 다른 기능들의 완성도를 높여가며 또 다른 해결방안에 대해 고민해봐야 하는 시간이었다.

C나 C++처럼 짜는 습관을 최대한 버리려고 노력은 해보았으나, 다 짜고나서 봤을 때 결국 C처럼 짜인 것 같아 기분이 오묘했다. 다음엔 조금 더 나아지겠지.
이번 포스팅은 여기서 끝!

댓글남기기