# [한라대학교 공지 알림 봇] 코드분석 (Requests, bs4)

- Author: @mildsalmon
- Published: 2020-02-19
- Updated: 2021-09-14
- Source: http://blex.me/@mildsalmon/%ED%95%9C%EB%9D%BC%EB%8C%80%ED%95%99%EA%B5%90-%EA%B3%B5%EC%A7%80-%EC%95%8C%EB%A6%BC-%EB%B4%87-%EC%A0%9C%EC%9E%91%EA%B8%B0-3-%EC%BD%94%EB%93%9C%EB%B6%84%EC%84%9D
- Tags: python, 텔레그램봇, 한라대학교, 웹크롤링, 공지알림봇, 코드분석

---

# Intro

이 글에서는 Requests와 BeautifulSoup를 내 코드에 어떻게 적용시켰는지 알려준다.

웹페이지를 크롤링하기 위핵서는

1. 크롤링할 웹의 주소
2. 웹에서 F12(개발자 도구)를 누르면 나오는 소스 분석

이 두가지가 필요하다.

# Requests

우선 웹의 주소를 가지고 크롤링할 웹의 데이터를 가져와보자.

```python
# -*- coding: utf-8 -*-

import requests

req = requests.get('http://www.halla.ac.kr/mbs/kr/jsp/board/list.jsp?boardId=23401&mcategoryId=&id=kr_060101000000')

html = req.text

print(html)
```

```
# <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
# <html xmlns="http://www.w3.org/1999/xhtml" lang="ko" xml:lang="ko">
# <head>	
# <meta http-equiv="X-UA-Compatible" content="IE=edge;chrome=1" />
#     <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
#     <title>일반공지</title>
#     <link href="/mbs/kr/css/import.css" rel="stylesheet" type="text/css" />
# ....
# ....
```

여기에서 크롤링할 주소는 한라대학교 일반공지 게시판이다. <br>
위 코드를 실행하면 웹의 HTML이 넘어오는 것을 볼 수 있다. <br>
`.get`은 URL이 가진 정보를 검색하기 위해 서버측에 요청하는 형태이다.<br>
그래서 위에 결과처럼 웹의 HTML이 넘어온다.<br>
`.text` 외에도 많은 기능들이 있는데 그건 [requests 정리본](https://blex.me/@mildsalmon/requests)을 참고하도록 하자.


# BeautifulSoup

이제 이 HTML에서 내가 원하는 데이터를 뽑아내야한다.

BeautifulSoup4를 사용해서 데이터를 정제해보자.

```python
import requests
from bs4 import BeautifulSoup

req = requests.get('http://www.halla.ac.kr/mbs/kr/jsp/board/list.jsp?boardId=23401&mcategoryId=&id=kr_060101000000')

html = req.text
soup = BeautifulSoup(html, "html.parser")
posts = soup.select("table > tbody > tr > td > a")

for post in posts:
    print(post.text)
    print(post.get('href'))
```

```
# 2020학년도 예비 신입생 캠프 취소 안내
# view.jsp?spage=1&boardId=23401&boardSeq=7685414&id=kr_060101000000&column=&search=
#  [총무과] 2020학년도 1학기 등록금 납부 안내  
# view.jsp?spage=1&boardId=23401&boardSeq=7681627&id=kr_060101000000&column=&search=
# 2020학년도 신입생 공지시항 안내(학번, 통학버스, 학생...
# view.jsp?spage=1&boardId=23401&boardSeq=7733808&mcategoryId=&id=kr_060101000000&column=&search=
# [전기전자공학과] 조교 채용 공고
# view.jsp?spage=1&boardId=23401&boardSeq=7732439&mcategoryId=&id=kr_060101000000&column=&search=
# 한라대학교 입학홍보처 계약직원 채용
# view.jsp?spage=1&boardId=23401&boardSeq=7730551&mcategoryId=&id=kr_060101000000&column=&search=
# 코로나19 관련 긴급 조치사항
# view.jsp?spage=1&boardId=23401&boardSeq=7728561&mcategoryId=&id=kr_060101000000&column=&search=
# e-Learning 동영상강좌 응원선물 이벤트 만족도 조사...
# view.jsp?spage=1&boardId=23401&boardSeq=7725984&mcategoryId=&id=kr_060101000000&column=&search=
# 신종 코로나바이러스감염증 관련 여행 최소화 및 여행시 유의...
# view.jsp?spage=1&boardId=23401&boardSeq=7725256&mcategoryId=&id=kr_060101000000&column=&search=
# 20년 공군 학사사관 후보생 모집(제145기)
# view.jsp?spage=1&boardId=23401&boardSeq=7720838&mcategoryId=&id=kr_060101000000&column=&search=
# ...
```

위 코드를 사용하면 우리가 원하는 게시글의 제목들과 주소들을 얻을 수 있다.<br>
7번째 줄의 soup에는 'html.parser' 외에도 'xml.parser'도 사용할 수 있다.<br>
parser의 종류에 따라 결과값이 달라질 수 있으니 잘 생각해보고 따라하도록하자.<br>
BeautifulSoup는 CSS 선택자 표준을 지원한다.<br>
때문에 href, body와 같은 태그를 검색하기 쉽다.<br>
그리고 soup변수에 html 문서를 입력하면 유니코드로 변환된다.<br>

![개발자도구로 원하는 데이터 위치 찾기](https://static.blex.me/images/content/2020/2/20/yxG2PB8851zj9wh37wte.png "개발자도구로 원하는 데이터 위치 찾기")

캡쳐화면을 보면 빨간색 네모칸을 순서대로 내려가면 우리가 원하는 데이터가 나온다.

![CSS selector 확인](https://static.blex.me/images/content/2020/2/20/7SrCklU9ndVMKim6Gao3.png "CSS selector 확인")

그리고 위와 같이 css selector를 확인하면

`#board > form:nth-child(3) > table > tbody > tr:nth-child(3) > td:nth-child(2) > a` 로 나온다.

### CSS select 간단히

이 selector를 좀 더 간단하게 만들어야 한다.<br>
왜냐하면 CSS selector가 너무 자세하면 페이지 의존성이 높아져서 페이지가 조금만 수정되어도 코드를 수정해야하고,<br>
지금 저 상태로 `.selector`을 실행하면 제대로 실행되지도 않는다.<br>

```python
import requests
from bs4 import BeautifulSoup

req = requests.get('http://www.halla.ac.kr/mbs/kr/jsp/board/list.jsp?boardId=23401&mcategoryId=&id=kr_060101000000')

html = req.text
soup = BeautifulSoup(html, "html.parser")
posts = soup.select("board > form > table > tbody > tr:nth-child(3) > td:nth-child(2) > a")

print(posts)

for post in posts:
    print(post.text)
    print(post.get('href'))
```

```
# []
```

이건 제대로 실행되지 않는 모습이고

```python
import requests
from bs4 import BeautifulSoup

req = requests.get('http://www.halla.ac.kr/mbs/kr/jsp/board/list.jsp?boardId=23401&mcategoryId=&id=kr_060101000000')

html = req.text
soup = BeautifulSoup(html, "html.parser")
posts = soup.select("table > tbody > tr:nth-child(3) > td:nth-child(2) > a")

print(posts)

for post in posts:
    print(post.text)
    print(post.get('href'))
```

```
# [<a href="view.jsp?spage=1&amp;boardId=23401&amp;boardSeq=7734255&amp;mcategoryId=&amp;id=kr_060101000000&amp;column=&amp;search=">'올바른 마스크 선택 및 사용법'</a>]
# '올바른 마스크 선택 및 사용법'
# view.jsp?spage=1&boardId=23401&boardSeq=7734255&mcategoryId=&id=kr_060101000000&column=&search=
```

이것도 우리가 원하는 데이터를 받아오는 것은 아니다.<br>
tr과 td 뒤에 :nth-child(3), :nth-child(2)가 붙어있어서 특정 위치를 정확하게 표시하게 된다.<br>

```python
import requests
from bs4 import BeautifulSoup

req = requests.get('http://www.halla.ac.kr/mbs/kr/jsp/board/list.jsp?boardId=23401&mcategoryId=&id=kr_060101000000')

html = req.text
soup = BeautifulSoup(html, "html.parser")
posts = soup.select("table > tbody > tr > td > a")

print(posts)

for post in posts:
    print(post.text)
    print(post.get('href'))
```

그래서 특정 위치를 가리키는 것을 지우면,<br>
우리가 원하는 데이터가 나오게 된다.<br>

그러면 좀 더 CSS Selector를 간단히 표시하려면 어떻게 해야할까?<br>
바로 HTML을 뜯어서 최대한 공통점이 없는 부분을 뽑으면 된다.<br>

![tbody](https://static.blex.me/images/content/2020/2/20/crUenz6dnE2Xk55mtBL7.png "tbody")

tbody을 검색해보니 tbody은 이 페이지에서 딱 하나 뿐이다.<br>
table을 지워주자<br>

```python
posts = soup.select("tbody > tr > td > a")
```

코드를 실행시켜보면 잘 작동한다.

![tr](https://static.blex.me/images/content/2020/2/20/efnp8rdVHEsKcNoltFvu.png "tr")

tr은 17개가 있어야한다. tr은 게시글 한줄을 의미하니 기본 게시글 15, 공지 2.
그런데 맨 위 번호, 제목, 작성자, 작성일 부분에도 tr이 있다.<br>
따라서 tbody를 지워주지 않는 것이 맞을지도 모르지만, 일단 지우자<br>
왜냐하면 번호, 제목 부분은 tr 하위에 td가 아닌 th가 있기 때문이다.<br>

```python
posts = soup.select("tr > td > a")
```

마찬가지로 잘 작동한다.

![td](https://static.blex.me/images/content/2020/2/20/4zcmFPdoAJ9UpLi8HHRx.png "td")

td는 좀 많다 102개,<br>
하지만 게시글 한 줄에 td가 6개씩 들어가는 모양이다.<br>
17줄 * 6 = 102<br>
td가 tr 밖에 있는 것이 아니니 tr을 지워주자<br>

```python
posts = soup.select("td > a")
```

![a](https://static.blex.me/images/content/2020/2/20/fX2HOk2fSzomrs3AVPgP.png "a")

하지만 a는 348개가 나왔다.<br>
td는 지우지말고 CSS selector를 간략화 하는 작업을 마치자.<br>

이제 마지막 작업 하나만 마치면 정말로 내가 원하는 `최신 게시글` 데이터를 받을 수 있다.<br>

### 공지 지우기

최신글과 공지글을 분리하는 작업을 해야한다.<br>
td > a 는 15개의 게시글만 가져오는 것이 아니라, 2개의 공지글도 같이 가져온다.<br>
따라서 2개의 공지글을 제거해주는 작업을 해야한다.<br>


```python
import requests
from bs4 import BeautifulSoup

req = requests.get('http://www.halla.ac.kr/mbs/kr/jsp/board/list.jsp?boardId=23401&mcategoryId=&id=kr_060101000000')

html = req.text
soup = BeautifulSoup(html, "html.parser")
posts = soup.select("td > a")

# 방법 1

num = soup.find_all(title='공지')

for i in range(len(num)):           # 공지로 위로 올라간 게시글 제외한 최신 게시글 분류
    del posts[0]

print(posts)

# 방법 2

count_page_num = 0
count_notice_num = 0

for post in posts:
    post_href = post.get('href')
    if 'mcategoryId' in post_href:
        count_notice_num = count_page_num
        break
    count_page_num = count_page_num + 1

for i in range(count_notice_num):           # 공지로 위로 올라간 게시글 제외한 최신 게시글 분류
    del posts[0]

print(posts)
```

```
# [<a href="view.jsp?spage=1&amp;boardId=23401&amp;boardSeq=7734255&amp;mcategoryId=&amp;id=kr_060101000000&amp;column=&amp;search=">'올바른 마스크 선택 및 사용법'</a>, <a href="view.jsp?spage=1&amp;boardId=23401&amp;boardSeq=7734059&amp;mcategoryId=&amp;id=kr_060101000000&amp;column=&amp;search=">2020년 울산인재평생교육진흥원 상반기 장학생 선발 안내</a>, <a href="view.jsp?spage=1&amp;boardId=23401&amp;boardSeq=7733808&amp;mcategoryId=&amp;id=kr_060101000000&amp;column=&amp;search=">2020학년도 신입생 공지시항 안내(학번, 통학버스, 학생...</a>, <a href="view.jsp?
....]
# [<a href="view.jsp?spage=1&amp;boardId=23401&amp;boardSeq=7734255&amp;mcategoryId=&amp;id=kr_060101000000&amp;column=&amp;search=">'올바른 마스크 선택 및 사용법'</a>, <a href="view.jsp?spage=1&amp;boardId=23401&amp;boardSeq=7734059&amp;mcategoryId=&amp;id=kr_060101000000&amp;column=&amp;search=">2020년 울산인재평생교육진흥원 상반기 장학생 선발 안내</a>, <a href="view.jsp?spage=1&amp;boardId=23401&amp;boardSeq=7733808&amp;mcategoryId=&amp;id=kr_060101000000&amp;column=&amp;search=">2020학년도 신입생 공지시항 안내(학번, 통학버스, 학생...</a>, <a href="view.jsp?
....]
```

방법 1은 이 글을 작성하다가 떠오른 아이디어로 작성한거고,<br>
방법 2는 원래 사용하던 코드이다.<br>

시간복잡도를 계산하지는 않았지만.<br>
반복문이 하나 사라지면서 방법 1이 더 빠를 것이다.<br>
코드도 더 짧아졌다.<br>
`이래서 코드 리뷰를 하는구나.`

그리고 체크해줘야하는 부분이 하나 더 있다.<br>
requests로 홈페이지에 접속할때 발생할 수 있는 응답 코드별 처리를 해줘야한다.
위치는 `requests.get` 이 실행된 다음줄에 `.status_code`를 사용해서 에러코드를 잡고 프로그램을 종료해야한다.<br>
에러코드가 떳다면, request가 가지고 있는 html이 정상적이지 않을 것이다.<br>
따라서 프로그램을 동작시키는 것보다 종료시키는 것이 안전한 선택이다.<br>

```python
import requests
from bs4 import BeautifulSoup
import sys

req = requests.get('http://www.halla.ac.kr/mbs/kr/jsp/board/list.jsp?boardId=23401&mcategoryId=&id=kr_060101000000')

client_errors = [400, 401, 403, 404, 408]
server_errors = [500,502, 503, 504]

if req.status_code in client_errors:
    print("클라이언트 에러")
    sys.exit(1)
elif req.status_code in server_errors:
    print("서버 에러")
    sys.exit(1)

html = req.text
soup = BeautifulSoup(html, "html.parser")
posts = soup.select("td > a")


num = soup.find_all(title='공지')

for i in range(len(num)):           # 공지로 위로 올라간 게시글 제외한 최신 게시글 분류
    del posts[0]
```

일단 자주 발생하는 에러 위주로 리스트에 넣었다.

# 다음시간에

다음 챕터에서는 telegram-bot 연동과 open을 이용해서 최신 게시글인지 확인하는 부분을 설명할것이다.

# 참고문헌
> beautifulsoup4, "https://blex.me/@mildsalmon/beautifulsoup", [mildsalmon]
>
> Requests, "https://blex.me/@mildsalmon/requests", [mildsalmon]
>
> notice_alarm, "https://github.com/mildsalmon/notice_alarm", [mildsalmon]
