한라대학교 공지 알림 봇 제작기 (3) - 코드분석 (Requests, bs4)

'데이터 분석(크롤링)' 시리즈한라대학교 공지 알림 봇 제작기 (3) - 코드분석 (Requests, bs4)

mildsalmon

흔치않고, 진귀하다.

Sign in to view email

Intro

이 글에서는 Requests와 BeautifulSoup를 내 코드에 어떻게 적용시켰는지 알려준다.

웹페이지를 크롤링하기 위핵서는

  1. 크롤링할 웹의 주소
  2. 웹에서 F12(개발자 도구)를 누르면 나오는 소스 분석

이 두가지가 필요하다.

Requests

우선 웹의 주소를 가지고 크롤링할 웹의 데이터를 가져와보자.

# -*- 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" />
# ....
# ....

여기에서 크롤링할 주소는 한라대학교 일반공지 게시판이다.
위 코드를 실행하면 웹의 HTML이 넘어오는 것을 볼 수 있다.
.get은 URL이 가진 정보를 검색하기 위해 서버측에 요청하는 형태이다.
그래서 위에 결과처럼 웹의 HTML이 넘어온다.
.text 외에도 많은 기능들이 있는데 그건 requests 정리본을 참고하도록 하자.

BeautifulSoup

이제 이 HTML에서 내가 원하는 데이터를 뽑아내야한다.

BeautifulSoup4를 사용해서 데이터를 정제해보자.

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=
# ...

위 코드를 사용하면 우리가 원하는 게시글의 제목들과 주소들을 얻을 수 있다.
7번째 줄의 soup에는 'html.parser' 외에도 'xml.parser'도 사용할 수 있다.
parser의 종류에 따라 결과값이 달라질 수 있으니 잘 생각해보고 따라하도록하자.
BeautifulSoup는 CSS 선택자 표준을 지원한다.
때문에 href, body와 같은 태그를 검색하기 쉽다.
그리고 soup변수에 html 문서를 입력하면 유니코드로 변환된다.

개발자도구로 원하는 데이터 위치 찾기

캡쳐화면을 보면 빨간색 네모칸을 순서대로 내려가면 우리가 원하는 데이터가 나온다.

CSS selector 확인

그리고 위와 같이 css selector를 확인하면

#board > form:nth-child(3) > table > tbody > tr:nth-child(3) > td:nth-child(2) > a 로 나온다.

CSS select 간단히

이 selector를 좀 더 간단하게 만들어야 한다.
왜냐하면 CSS selector가 너무 자세하면 페이지 의존성이 높아져서 페이지가 조금만 수정되어도 코드를 수정해야하고,
지금 저 상태로 .selector을 실행하면 제대로 실행되지도 않는다.

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'))
# []

이건 제대로 실행되지 않는 모습이고

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=

이것도 우리가 원하는 데이터를 받아오는 것은 아니다.
tr과 td 뒤에 :nth-child(3), :nth-child(2)가 붙어있어서 특정 위치를 정확하게 표시하게 된다.

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'))

그래서 특정 위치를 가리키는 것을 지우면,
우리가 원하는 데이터가 나오게 된다.

그러면 좀 더 CSS Selector를 간단히 표시하려면 어떻게 해야할까?
바로 HTML을 뜯어서 최대한 공통점이 없는 부분을 뽑으면 된다.

tbody

tbody을 검색해보니 tbody은 이 페이지에서 딱 하나 뿐이다.
table을 지워주자

posts = soup.select("tbody > tr > td > a")

코드를 실행시켜보면 잘 작동한다.

tr

tr은 17개가 있어야한다. tr은 게시글 한줄을 의미하니 기본 게시글 15, 공지 2. 그런데 맨 위 번호, 제목, 작성자, 작성일 부분에도 tr이 있다.
따라서 tbody를 지워주지 않는 것이 맞을지도 모르지만, 일단 지우자
왜냐하면 번호, 제목 부분은 tr 하위에 td가 아닌 th가 있기 때문이다.

posts = soup.select("tr > td > a")

마찬가지로 잘 작동한다.

td

td는 좀 많다 102개,
하지만 게시글 한 줄에 td가 6개씩 들어가는 모양이다.
17줄 * 6 = 102
td가 tr 밖에 있는 것이 아니니 tr을 지워주자

posts = soup.select("td > a")

a

하지만 a는 348개가 나왔다.
td는 지우지말고 CSS selector를 간략화 하는 작업을 마치자.

이제 마지막 작업 하나만 마치면 정말로 내가 원하는 최신 게시글 데이터를 받을 수 있다.

공지 지우기

최신글과 공지글을 분리하는 작업을 해야한다.
td > a 는 15개의 게시글만 가져오는 것이 아니라, 2개의 공지글도 같이 가져온다.
따라서 2개의 공지글을 제거해주는 작업을 해야한다.

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은 이 글을 작성하다가 떠오른 아이디어로 작성한거고,
방법 2는 원래 사용하던 코드이다.

시간복잡도를 계산하지는 않았지만.
반복문이 하나 사라지면서 방법 1이 더 빠를 것이다.
코드도 더 짧아졌다.
이래서 코드 리뷰를 하는구나.

그리고 체크해줘야하는 부분이 하나 더 있다.
requests로 홈페이지에 접속할때 발생할 수 있는 응답 코드별 처리를 해줘야한다. 위치는 requests.get 이 실행된 다음줄에 .status_code를 사용해서 에러코드를 잡고 프로그램을 종료해야한다.
에러코드가 떳다면, request가 가지고 있는 html이 정상적이지 않을 것이다.
따라서 프로그램을 동작시키는 것보다 종료시키는 것이 안전한 선택이다.

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]


'데이터 분석(크롤링)' 시리즈
데이터 분석만 따로 모아둔 시리즈
작성된 댓글이 없습니다!
로그인된 사용자만 댓글을 작성할 수 있습니다.