[BOJ] 파이썬 크롤링으로 BOJ 문제 md 파일로 포맷팅하기

작성:    

업데이트:

카테고리:

태그: , , ,

설계 목적

앞으로 Python이나 JavaScript 등 언어를 공부하며 BOJ의 문제들을 풀게 될텐데, 나같은 경우 문제 풀이에 대한 다양한 정보와 풀이 과정을 모두 md파일로 저장해 기록할 예정이다. 그런데, SWEA에서도 많이 느꼈지만, 문제 정보와 입출력 예시, 제한 사항 등을 매번 copy&paste하는 것은 상당히 번거로운 일이다. 그래서 파이썬 웹 크롤러를 개발해 BOJ 및 solved.ac에서 문제 정보를 크롤링해 md파일 작성을 보다 쉽게 해보도록 하겠다. 본 md 파일의 형식은 minimal-mistakes 테마의 형식으로 작성한다.


1. 필요한 pip import

다양한 pip들을 사용하게 될 예정으로, py파일 상단에 pip들을 import 해주겠다.

from datetime import *
from html.parser import HTMLParser
import clipboard
import requests
from bs4 import BeautifulSoup


from datetime import *

md파일의 date 및 modified-at의 시간을 설정하기 위해 import한다.


from html.parser import HTMLParser

HTML문서에서 크롤링할 것이기 때문에 html.parser 패키지의 HTMLParser 모듈을 import한다.


import requests

url에서 정보를 요청(request)해서 받아올 것이기 때문에 requests 패키지를 import한다.


from bs4 import BeautifulSoup

HTML 문서를 가져와서 가공할 때 필요한 BeautifulSoup 모듈을 가진 bs4 패키지이다. BeautifulSoup가 핵심 모듈이므로 이것만 import한다.


import clipboard

전체 md파일의 내용을 담은 텍스트 변수를 copy해서 클립보드에 저장하기 위해 clipboard 패키지를 import한다. copy&paste가 가능하다.


2. 변수 초기화

result = ""
txt = ""

사실 굳이 필요는 없지만, 어떤 변수가 중요한 역할을 하는지, 문서 초반에 알 수 있기 때문에 핵심 변수를 입력해 초기화하였다. result는 전체 md 파일의 내용이 담길 텍스트 변수이고, txt는 각각의 과정에서 md파일의 일정 부분 내용이 담길 임시 텍스트 변수이다.


3. title

3.1. 문제 번호 입력

사용자가 BOJ 문제 중 몇 번 문제를 크롤링할 것인지 입력하는 단계이다.

input_problem_num = input("문제 번호 입력 : ")
url = f"https://www.acmicpc.net/problem/{input_problem_num}"

파이썬의 f-string 포맷을 사용하였다. 입력한 문제번호를 input_problem_num 변수에 입력하고, 이를 BOJ의 문제 url에 삽입하여 해당 번호의 문제의 화면에 대한 url을 저장하였다.


3.2. requests pip 사용

requests pip를 사용한다.

response = requests.get(url).text
data = BeautifulSoup(response, 'html.parser')

request.get(url)을 이용해 url의 정보를 가져오는데, 이때 뒤에 text를 붙이면 html의 텍스트 내용만 가져오게 된다. 물론 태그도 함께 저장한다.

그리고 이를 BeautifulSoup에 담아, html.parser 인자와 함께 등록한다. BeautifulSoup 모듈이 url의 html정보를 활용하기 좋게 처리할 것이다. 이를 data 변수에 저장한다.


3.3. 제목 추출

본격적으로 data에 저장된 내용에서 태그나 selector를 이용해 정보를 추출해본다. 처음으로는 문제의 제목을 추출한다.

problem_title = data.select_one('#problem_title').text

BOJ는 필요한 각 영역에 친절하게도 id를 설정해두어 select_one(#id).text를 사용하기 편하게 만들어두었다. select_one()은 한 개만 추출하는 것이고, 우습지만 한 개로만 구분할 수 있는 정보만 담아야한다. 그래서 유일한 클래스(.class)나 아이디(#id)를 인자로 담는다. 뒤에 .text를 붙이는 것은 해당 인자로 선택한 텍스트는 태그를 함께 추출하기 때문에 태그를 제거하기 위해서 .text 속성을 붙인다.


3.4. 문제 번호 폼

md 파일의 제목에 4자리 수의 문제 번호라면 #01234, 5자리 수의 문제 번호라면 #12345 등으로 입력될 수 있도록 if 조건문을 이용해 폼을 설정한다.

if int(input_problem_num) < 10000:
    problem_num = "0" + input_problem_num
else:
    problem_num = input_problem_num

입력한 4자리, 혹은 5자리 숫자를 일정한 5자리의 숫자로 폼을 재설정해주어 problem_num 변수에 저장하였다.


3.5. solved.ac에서 문제번호로 문제레벨 검색

solved.ac는 BOJ 문제의 난이도(레벨)을 확인하고 레벨별로 문제를 풀거나 다양한 티어를 설정하고 경쟁할 수 있는 BOJ의 파트너 사이트이다. BOJ의 설정에서 문제에 대한 난이도를 보이게 하는 데에도 solved.ac 사이트의 역할이 지배적이다. default로는 문제의 난이도가 보이지 않게 되어있다.

우선 BOJ에서 문제에 해당하는 난이도 img의 src를 추출해보려 했으나, 여러 문제로 해결이 되지 않았기 때문에 solved.ac 사이트를 이용하게 되었다. 이것이 신의 한수였던 것은 solved.ac에서 문제번호를 검색하면 출력되는 문제의 난이도(레벨/티어) img는 alt에 난이도의 클래스(Bronze, Silver, Diamond 등)와 티어(I, II, III 등)의 정보를 두었기 때문에 이를 추출해서 활용하면 좋을 것 같다고 생각했다.


3.5.1. requests pip 사용

위에서와 마찬가지로 requests pip를 활용해 solved.ac 사이트의 특정 문제의 레벨을 찾는 url2를 설정해 BeautifulSoup로 data2를 추출해보겠다.

url2 = f"https://solved.ac/search?query={input_problem_num}"
response2 = requests.get(url2).text
data2 = BeautifulSoup(response2, 'html.parser')


3.5.2. 문제 레벨 img 추출

위의 data에서 img 태그를 찾으면 첫 번째로 나오는 img 태그의 정보가 바로 문제의 난이도이다. 이를 html 속성과 함께 img_info 변수에 저장해보겠다.

img_info = data2.select('img')[0]


3.5.3. 문제 레벨 img에서 alt 추출

위에서 저장한 img_info 변수에서 alt에 해당하는 정보를 추출해 problem_level 변수에 저장해보겠다.

problem_level = img_info['alt']

위의 코드를 통해 problem_level에는 ‘Gold II’와 같은 형식의 난이도 텍스트가 저장된다.


3.5.4. 문제 클래스와 티어 구분

문제의 클래스와 티어가 함게 problem_level에 위치하기 때문에 이를 .split() 함수를 이용해 구분해 list에 저장하겠다.

problem_level_list = problem_level.split(' ')

공백을 분할 기준으로 분할해 리스트에 저장하는 과정이다. 이 과정을 거치면 problem_level_list에는 [‘Gold’, ‘II’]와 같은 형식으로 정보가 저장된다. 그리고 이를 각각 클래스, 티어를 의미하는 problem_level_class, problem_level_tier 변수에 담아 저장한다. 리스트의 인덱스를 이용한다.

# Bronze, Silver, Gold 등
problem_level_class = problem_level_list[0]

# I, II, III 등
problem_level_tier = problem_level_list[1]


3.5.5. 문제 클래스와 티어 dictionary 지정

md파일의 title에 난이도를 [🟡2]와 같은 방식으로 입력하기 위해 problem_level 변수에 담기는 텍스트들을 각각 key로 하는 key: value dictionary를 만들어준다. 후에 problem_level_class 변수와 problem_level_tier 변수로 value를 찾아 md파일에 입력해줄 것이다.

problem_level_class_dict = {
    'Bronze': '🟤',
    'Silver': '⚪',
    'Gold': '🟡',
    'Platinum': '🔘',
    'Diamond': '💎',
    'Ruby': '🚨'
}

problem_level_tier_dict = {
    'I': 1,
    'II': 2,
    'III': 3,
    'IV': 4,
    'V': 5
}


3.5.6. dict에 대해 value 추출해서 변수에 지정

위에서 설명한대로 사전에서 각각의 class와 tier 정보를 key로 하기 때문에 이를 검색해서 value를 추출한 것을 새로운 변수에 담는다.

problem_class = problem_level_class_dict[problem_level_class]
problem_tier = problem_level_tier_dict[problem_level_tier]

이를 이용해 title에 입력해 넣을 것이다.


3.6. 입력

위에서 ““으로 초기화한 txt 라는 임시 텍스트 변수에 아래의 f-string 포맷을 입력한다.

txt = f"""---
title: "[BOJ][{problem_class}{problem_tier}][백준#{problem_num}] {problem_title}"
excerpt: "BAEKJOON Online Judge 문제 풀이"

categories:
  - BOJ

tags:
  - [BOJ, ProblemSolving, Python, {problem_level_class}]
"""

이전에 정의된 변수명을 그대로 입력해 넣기 때문에 보다 편리하다. ‘”’ 쌍따옴표 3개로 여러 행의 문자열을 감싸면 여러 행의 문자열이 행 간격을 유지한 채 저장된다.

문제번호, 난이도, 제목과 함께 tags에 문제 난이도의 클래스를 따로 저장할 수 있도록 설정하였다.


3.7. result에 붙이기

최종 출력 텍스트 변수인 result에 txt를 지정한다.

result = f"{txt}"

이후 result에 계속 txt 텍스트 변수를 이어붙일 것이다.


4. 입력 시간/수정 시간

4.1. datetime pip 이용

날짜, 시간 등의 정보를 이용하기 위해 datatime pip를 import해주었다. 이를 사용해보자.

now = str(datetime.now())

오늘 날짜와 현재시각을 now 변수에 담는 것이다.


4.2. 데이터 슬라이싱

위의 now 데이터는 원치 않는 형식의 원치 않는 데이터까지 저장한다. milisecond 단위까지 저장하니 말이다. 하지만 date는 현재 시각의 분까지만 저장하면 되고 원하는 포맷도 따로 있다. 이를 슬라이싱을 이용해 설정해보자.

date_now = f"{now[:10]}T{now[11:16]}"

결과는 2022-01-16T01:00처럼 나온다. 사실 슬라이싱이 아니라 %를 이용한 방법이 있지만, 슬라이싱으로 쉽게 해결할 수 있기 때문에 굳이 활용하지는 않았다. 아무튼 이 정보를 date_now 변수에 저장한다.


4.3. 입력

md파일에 header에 들어가야할 date와 last-modified-at 에 대한 정보를 변수를 통해 입력한다.

txt = f"""
date: {date_now}
last_modified_at: {date_now}

author_profile: true

toc: true

toc_label: "목차"
toc_icon: "bars"
toc_sticky: true
---
"""

이후 내용은 변수에 대해 변동 없이 텍스트로 입력하므로 이 단계에서 그냥 같이 넣어주었다.


4.4. result에 붙이기

result = f"{result}\n{txt}"

기존의 result에 새로 만든 txt를 붙이기 전, 행 간격을 설정하기 위해 \n을 삽입해주었다.


5. 문제 출처

본격적으로 header에 대한 내용보다 body에 대한 내용에 대해 다루기 시작한다.

문제 출처를 적으며, 해당 문제의 BOJ 링크를 삽입한다. 역시 txt에 다행 텍스트를 저장하고, 이 과정에서 위에서 저장한 input_problem_num 변수와 url 변수를 입력해 넣는다.

txt = f"""
## 문제 출처

[BAEKJOON Online Judge #{input_problem_num}]({url})

<br>
"""

result = f"{result}\n{txt}"


6. 문제

6.1. 문제 추출

언급했듯이 BOJ에서는 각각의 text 정보를 담는 태그에 id를 설정해 크롤링에 편리하게 구성해두었다. 뭐 정보를 관리하기 쉽게 하기 위한 그들만의 방식이었겠지만 말이다.

id가 설정되어 있으므로 select_one() 함수를 사용해보자.

problem_description = data.select_one('#problem_description').text


6.2. 입력

저장한 변수를 포함하여 txt 변수를 설정한다. 역시 다행 텍스트 변수이다.

txt = f"""
## 문제

{problem_description}

<br>
"""


6.3. result에 붙이기

이를 result 변수에 이어 붙였다.

result = f"{result}\n{txt}"


7. 입력

문제에서 요구하는 프로그램에 입력을 넣으면 출력이 어떻게 나온다는 것을 의미할지에 대해 설명한다.


7.1. 입력 추출

입력에 대한 내용을 추출하는 과정이다.

problem_input = data.select_one('#problem_input').text

if problem_input == "":
    problem_input == "없음"

대부분 입력이 없더라도 입력에 대한 설명은 있기 때문에 공란일 수는 없지만 혹시나 공란(““)이라면 “없음”을 표시할 수 있도록 if 조건문을 삽입하였다.


7.2. 입력

txt에 입력 내용을 저장한다.

txt = f"""
## 입력

{problem_input}

<br>
"""


7.3. result에 붙이기

result = f"{result}\n{txt}"


8. 출력

입력과 마찬가지로 문제에서 요구하는 프로그램에 입력을 넣으면 출력이 어떻게 나온다는 것을 의미할지에 대해 설명한다.


8.1. 출력 추출

출력에 대한 내용을 추출하는 과정이다.

problem_output = data.select_one('#problem_output').text

if problem_output == "":
    problem_output == "없음"

출력은 대부분 공란일 수 없지만 혹시나 공란(““)이라면 “없음”을 표시할 수 있도록 if 조건문을 삽입하였다.


8.2. 입력

txt에 출력 내용을 저장한다.

txt = f"""
## 출력

{problem_input}

<br>
"""


8.3. result에 붙이기

result = f"{result}\n{txt}"


9. 제한

문제에서 사용되는 변수의 범위를 제한하거나 기타 여러 제한 사항에 대한 정보를 제공하기 위해 사용된다. 역시 #problem_limit 이라는 id로 내용을 저장한다.


9.1. 제한 추출

위의 id를 이용해 제한에 대한 내용을 추출해보자.

problem_limit = data.select_one('#problem_limit').text


9.2. 입력

이 제한에 대한 내용을 txt에 저장하기 전에 확인해야 할 것이다. 제한 조건의 경우 문제에 제한이 없다면 problem_limit 즉, 제한 조건의 HTML에서 추출한 텍스트의 길이는 1이다. 따라서 제한 조건이 있을 경우에만 md 파일에 표현하기 위해 if 조건문을 사용해 txt를 구분하겠다.

if len(problem_limit) == 1:
    txt = ""
else:
    txt = f"""

## 제한

{problem_limit}

<br>
"""


9.3. result에 붙이기

마찬가지로 result에 txt를 붙이게 된다.

result = f"{result}{txt}"


10. 예제

input과 output에 대한 예제로 문제에서 요구하는 데이터의 변환을 보다 직관적으로 쉽게 보여주는 예시 구간이다. 예제 입력과 출력으로 구분되며, 번호를 이용해 여러 개의 예제 입출력을 보여준다. 때문에 for문을 이용해보자.


10.1. 예제 추출

입출력 내용을 담는 태그는 모두 .sampledata라는 클래스명을 가지고 있다. 때문에 이를 가리지 않고 select() 함수를 이용해 list에 담겠다. 하지만 이 과정에서 for문을 사용할 때, 해당하는 인덱스의 ‘.sampledata’가 없다면 IndexError가 발생할 수 있으니, for 반복문 내에 try ~ except ~ else 문을 사용하여 list로 정보를 저장하겠다.

sampledata_list = []
cnt = 0

for cnt in range(0, 20):
    try:
        sampledata = data.select('.sampledata')[cnt].text

    except:
        break

    else:
        sampledata_list.append(sampledata)

예시 코드는 최대 10개까지 받을 수 있게 설정해두었으며, 이는 충분한 크기이다. select(인자)[idx]를 이용하여 for문에서 idx를 반복이 바뀔 때마다 커지는 반복인자로 설정하여 리스트에 각각의 정보를 저장한다.


10.2. 예제 분리

리스트에 입출력 예제를 담았을 때, 인덱스는 0부터 시작하므로 짝수 인덱스에는 입력 데이터가, 홀수 인덱스에는 출력 데이터가 저장된다. 이를 리스트 내포를 이용해 input 데이터의 리스트와 output 데이터의 리스트로 구분하여 저장하겠다.

# input 데이터 리스트 저장
sampledata_list_input = [
    item for item in sampledata_list if sampledata_list.index(item) % 2 == 0]

# output 데이터 리스트 저장
sampledata_list_output = [
    item for item in sampledata_list if sampledata_list.index(item) % 2 == 1]


10.3. 예제 이어붙이기

반복에 따라서 리스트 들을 md 파일 형식에 맞게 저장하기 위해 사용한다. txt에 붙여넣기도 전에 a로 임시 텍스트 저장 변수를 만들어 sampletxt에 이어붙였고, 이는 예제의 개수만큼 반복되어 진행한 뒤 마친다.

sampletxt = ""

for i in range(0, len(sampledata_list_input)):
    if sampledata_list_input[i] == "":
        sampledata_list_input[i] = "없음"

    inout_ex = f"""
입력

```python
{sampledata_list_input[i]}
```

<br>

출력

```python
{sampledata_list_output[i]}
```

<br>

"""

    if len(sampledata_list_input) == 1:
        a = f"""
{inout_ex}
"""

    else:
        a = f"""
### 예제 {i + 1}

{inout_ex}
"""

    sampletxt = f"{sampletxt}{a}"

만약 예제가 1개라면, ### 예제1 없이 ## 예제 로만 헤딩을 하는 방식이다.


10.4. txt에 입력

txt 텍스트 변수에 sampletxt 내용에 대한 정보를 담는다.

txt = f"""
## 예제
{sampletxt}
"""


10.5. result에 붙이기

늘 하던대로.

result = f"{result}\n{txt}"


11. etc

11.1. 나머지 문자 입력

화면에서 내용을 크롤링해와서 매번 변하는 내용에 대한 코드는 모두 마쳤다. 이후는 내가 코드를 입력하기 위한 칸이나, 결과를 적는 란, 틀렸다면 모범 답안을 적는 란 등으로 구성해 txt에 저장하였다.

txt = """
## My Sol

```python
# empty
```

<br>

## 결과

결과 이미지

<br>

## 모범답안

```python
# empty
```
"""


11.2. result에 붙이기

역시나 늘 하던대로…

result = f"{result}\n{txt}"


12. result를 클립보드에 copy

이를 목표로 하는 md 파일에 쉽게 붙여넣을 수 있도록, import해두었던 clipboard pip를 이용해 copy한다. 그리고 정상적으로 코드가 실행되었음을 의미하도록 복사가 완료되었다는 메시지를 출력한다.

clipboard.copy(result)
print("복사가 완료되었습니다!")

댓글남기기