AI/정책 댓글 반응 NLP

[1] 커뮤니티 게시물 크롤러_1. MLBPARK_1. 데이터 적재

eraser 2020. 4. 3. 04:38
반응형

 이번 단계의 작업에서는 크롤러를 통해 MLBPARK 글과 댓글을 스크레이핑하고, 그것을 저장하는 함수를 만든다. 작업을 통해 얻고자 하는 결과물은 크롤러가 직접 MLBPARK 불펜에서 '주52시간'을 검색하여 찾아낸 마지막 페이지 범위까지의 게시물들 중, 커뮤니티 이름(MLBPARK), 글 제목, 글 작성 시간, 글 작성자, 추천 수, 조회 수, 댓글 수, 댓글 내용이다.

 

 

# 사용한 라이브러리

 

  • Requests
  • Selenium
  • time
  • BeautifulSoup
  • urllib.parse
  • csv

# 검색 결과 마지막 페이지 얻기

 

 네이버 크롤러에서와 달리, Selenium을 이용해 마지막 페이지에 갈 때까지 클릭한다. 마지막 페이지에 가면 '다음' 버튼이 나오지 않는다는 것을 활용했다.

 

 

 

 

개발자 도구를 활용해 '다음' 버튼을 검사한 결과

 

 

 '다음' 버튼은 class가 'right'인 'a' 태그이다. 네이버 크롤러와 달리, 명시적 대기를 이용해 '다음' 버튼이 나올 때까지 최대 10초 동안 기다려 버튼을 클릭하도록 했다. 명시적으로 10초를 기다린 후에도 버튼이 없다면, 에러(TimeoutException)가 난다. 따라서, try, except 절을 활용한다.

 

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.suppor.ui import WebDriverWait
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.support import expected_conditions as EC

# 변수 설정
QUERY = "주52시간"
search_QUERY = urlencode({'query' : QUERY}, encoding = 'utf-8')
URL = f"http://mlbpark.donga.com/mp/b.php?select=sct&m=search&b=bullpen&select=sct&{search_QUERY}&x=0&y=0"

driver = webdriver.Chrome('C:/Users/user/PycharmProjects/Scraping/chromedriver.exe')
wait = WebDriverWait(driver, 10) # 명시적 대기
driver.get(URL)

while True:
    try:
        element = wait.until(EC.element_to_be_clickable(By.CLASS_NAME, 'right'))
        element.click()
    except TimeoutException:
        print("no pages left!")
        break        

 

실행해 보니 마지막 페이지까지 웹 드라이버가 잘 넘어간다.

 

 그런데(..!) 코드를 여러 번 실행하다 보니, "원격 호스트에 의해 연결이 강제적으로 종료되었다"는 에러가 난다.

 

 

오류 싫어.... 뭐야 넌...

 

 처음에는 대량의 데이터를 크롤링하는 것을 사이트가 감지한 것이라고 생각했다. 그래서 fake_useragent 패키지를 활용해 랜덤으로 사용자 에이전트를 생성하고, 웹 드라이버에 user-agent 옵션을 변경해 봤다.

 

from selenium.webdriver.chrome.options import Options
from fake_useragent import UserAgent

options = Options()
ua = UserAgent()
userAgent = ua.random
print(userAgent) # 랜덤으로 생성한 사용자 에이전트
options.add_argument(f"user-agent={userAgent}") # 옵션에 추가

driver = webdriver.Chrome(chrome_options=options,
                          executable_path='C:/Users/user/PycharmProjects/Scraping/chromedriver.exe')
wait = WebDriverWait(driver, 10) 
driver.get(URL)


while True:
    try:
        element = wait.until(EC.element_to_be_clickable(By.CLASS_NAME, 'right'))
        element.click()
    except TimeoutException:
        print("no pages left!")
        break        

 

 최대한 사람처럼 보이기 위한 노력이었는데, 생각해 보니 "어차피 많이 돌아봐야 10번 정도 클릭하고 끝일 텐데, 이 정도의 크롤링도 안 되는 건가" 하는 의문이 들었다. 역시나, 이렇게 코드를 수정해도 똑같은 오류가 난다.

 

 ConnectionAbortionError를 열심히 찾아봐도, 컴린이(...)인 나는 알 길이 없다. 소켓 에러의 일종인 것 같은데, 소켓 에러가 뭔지 모르니 알 수가 없다. 약 1시간 가량 헤매며 구글링을 통해 알아본 결과, 일단 강제 연결 중단을 방지하기 위해 코드에 time.sleep()을 넣으면 해결된다고 하는 게시물이 많다.

 

사실 regedit, 네트워크 변경 등 시도해보라는 건 많았는데 괜히 내가 건들었다가 컴퓨터 이상해질까봐... 공부할 거 참 많다.

 

 

코드 실행을 3초 동안 중지시키는 부분을 추가했다. 다행히 마지막 게시판 페이지 번호가 출력된다.


 

위의 과정을 모두 함수화하면 다음과 같다. 

  • 혹시 몰라, fake_useragent를 사용했다.
  • 마지막 페이지를 반환하는 함수는 네이버 크롤러의 함수와 유사하다. 다만, 마지막 페이지를 얻는 함수 안에서 검색 결과의 마지막 페이지까지 이동하는 함수를 호출했다.

 

mlbpark_다음 버튼 클릭_오류없음.mp4
4.44MB

 

더보기

 

import requests
from bs4 import BeautifulSoup
from urllib.parse import urlencode
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from fake_useragent import UserAgent
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.support import expected_conditions as EC
import time

# 변수 설정
QUERY = "주52시간"
search_QUERY = urlencode({'query' : QUERY}, encoding = 'utf-8')
URL = f"http://mlbpark.donga.com/mp/b.php?select=sct&m=search&b=bullpen&select=sct&{search_QUERY}&x=0&y=0"

# 마지막 페이지까지 클릭
def go_to_last_page(url):
    options = Options()
    ua = UserAgent()
    userAgent = ua.random
    print(userAgent)
    options.add_argument(f'user-agent={userAgent}')
    driver = webdriver.Chrome(chrome_options=options,
                              executable_path='C:/Users/user/PycharmProjects/Scraping/chromedriver.exe')
    driver.get(url)
    wait = WebDriverWait(driver, 10)
    while True:
        # class가 right인 버튼이 없을 때까지 계속 클릭
        try:
            time.sleep(3)
            element = wait.until(EC.element_to_be_clickable((By.CLASS_NAME, 'right')))
            element.click()
            time.sleep(3)
        except TimeoutException:
            print("no pages left")
            break
    html = driver.page_source
    soup = BeautifulSoup(html, 'lxml')
    driver.quit()
    return soup

# 마지막 페이지 번호 알아내기
def get_last_page(url):
    soup = go_to_last_page(url)
    pagination = soup.find('div', {'class' : 'page'})
    pages = pagination.find_all("a")
    page_list = []
    for page in pages[1:]:
        page_list.append(int(page.get_text(strip=True)))
    max_page = page_list[-1]
    print(f"총 {max_page}개의 페이지가 있습니다.")
    return max_page

max_page = get_last_page(URL)
print(max_page)

 


# 게시물 페이지 구조 확인

 

 이후 크롤러는 네이버 크롤러 때와 크게 달라진 것이 없다. 다만, 게시물 페이지에서 내가 얻을 정보가 어디에 있는지 확인하는 과정을 거쳤다. 댓글이 동적으로 생성되기 때문에, Selenium을 활용해 크롤링한다.

 

 페이지에 위치한 정보는 다음과 같다. MLBPARK 사이트의 경우, 비추천 수는 없다.

 

 

각각의 정보들이 위치한 태그를 찾기 위해 개발자 도구로 검사를 진행했다.

  • 사이트 이름 : class가 'logo'인 'h1' 태그의 'a' 태그 아래 'img'의 'title' 속성.

  • 글 제목 : class가 'titles'인 'div' 태그의 text.

  • 작성자 : class가 'nick'인 'span' 태그의 text.

  • 글 내용 : id가 'contentDetail'인 'div' 태그의 text.

  • 조회 수 : class가 'text2'인 'div' 태그의 'a' 태그 아래 'span' 태그의 text.

  • 추천 수 : id가 'likeCnt'인 'span' 태그의 text.

  • 댓글 수 : id가 'replyCnt'인 'span' 태그의 text.

  • 댓글 내용: class가 're_txt'인 'span' 태그 각각의 text.

 

 테스트를 진행했다.

 

from selenium import webdriver
from bs4 import BeautifulSoup

test_url = "http://mlbpark.donga.com/mp/b.php?m=search&p=1171&b=bullpen&id=201807090020262626&select=sct&query=%EC%A3%BC52%EC%8B%9C%EA%B0%84&user=&site=donga.com&reply=&source=&sig=hgjRSY-ggh6RKfX2hgj9SY-Yhhlq"

driver = webdriver.Chrome("C:/Users/user/PycharmProjects/Scraping/chromedriver.exe")
driver.get(test_url)
html = driver.page_source
soup = BeautifulSoup(html, 'lxml')

site = soup.find('h1', {'class': 'logo'}).find('a').find('img')['title'].strip()
title = soup.find('div', {'class': 'titles'}).get_text(strip=True)
user_id = soup.find('span', {'class': 'nick'}).get_text(strip=True)
post = soup.find('div', {'id': 'contentDetail'}).get_text(strip=True)
view_cnt = soup.find('div', {'class': 'text2'}).find('a').find('span').get_text(strip=True)
recomm_cnt = soup.find('span', {'id': 'likeCnt'}).get_text(strip=True)
reply_cnt = soup.find('span', {'id': 'replyCnt'}).get_text(strip=True)
replies = soup.find_all('span', {'class': 're_txt'})

 

 콘솔 창에 출력해 보면, 원하는 내용이 잘 나오는 것을 확인할 수 있다.

 


 

위의 모든 과정을 정리하면 다음과 같다.

 

  • 조회 수, 추천 수, 댓글 수의 경우, 필요 없는 문자('\n', '\r', '\t', ',')를 제거하고 정수형으로 만들었다.
  • 댓글 내용의 경우, 필요 없는 문자를 제거한 뒤, 리스트로 저장했다.
  • 전체 자료 형태는 dict 형태로 만들었다.
더보기

 

# 한 페이지에서 정보 가져오기
def extract_info(url, wait_time=3, delay_time=1):

    driver = webdriver.Chrome("C:/Users/user/PycharmProjects/Scraping/chromedriver.exe")

    print(url)

    driver.implicitly_wait(wait_time)
    driver.get(url)
    html = driver.page_source
    soup = BeautifulSoup(html, 'lxml')

    time.sleep(delay_time) # 강제 연결 종료 방지

    site = soup.find('h1', {'class': 'logo'}).find('a').find('img')['title'].strip()
    title = soup.find('div', {'class': 'titles'}).get_text(strip=True)
    user_id = soup.find('span', {'class': 'nick'}).get_text(strip=True)
    post = soup.find('div', {'id': 'contentDetail'}).get_text(strip=True)
    view_cnt = int(soup.find('div', {'class': 'text2'}).find('a').find('span').get_text(strip=True).replace('\n', '').replace('\r', '').replace(',', ''))
    recomm_cnt = int(soup.find('span', {'id': 'likeCnt'}).get_text(strip=True).replace('\n', '').replace('\r', '').replace(',', ''))
    reply_cnt = int(soup.find('span', {'id': 'replyCnt'}).get_text(strip=True).replace('\n', '').replace('\r', '').replace(',', ''))

    time.sleep(delay_time)  # 강제 연결 종료 방지

    reply_content = []
    if reply_cnt != 0:
        replies = soup.find_all('span', {'class': 're_txt'})
        for reply in replies:
            reply_content.append(reply.get_text().replace('\n', '').replace('\r', ''))

    print("완료")

    return {'site': site, 'title': title, 'user_id': user_id, 'post': post, 'view_cnt': view_cnt, 'recomm_cnt': recomm_cnt, 'reply_cnt': reply_cnt, 'reply_content': reply_content}

 


# 데이터 저장

 

 모든 과정을 함수로 만들어 실행했다. 위에서 소개한 것 외에 다른 부분은 네이버 크롤러와 달라진 것이 없다. 함수 실행 결과, 다음의 데이터를 얻을 수 있었다.

 

 

MLBPARK_주52시간_49pages.zip
1.25MB

 

 


 

더보기
# module import
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlencode
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from fake_useragent import UserAgent
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.support import expected_conditions as EC
import time

# 변수 설정
QUERY = "주52시간"
search_QUERY = urlencode({'query' : QUERY}, encoding = 'utf-8')
URL = f"http://mlbpark.donga.com/mp/b.php?select=sct&m=search&b=bullpen&select=sct&{search_QUERY}&x=0&y=0"

# 드라이버 설정
driver = webdriver.Chrome("C:/Users/user/PycharmProjects/Scraping/chromedriver.exe")

# 마지막 페이지까지 클릭
def go_to_last_page(url):
    options = Options()
    ua = UserAgent()
    userAgent = ua.random
    print(userAgent)
    options.add_argument(f'user-agent={userAgent}')
    driver = webdriver.Chrome(chrome_options=options,
                              executable_path='C:/Users/user/PycharmProjects/Scraping/chromedriver.exe')
    driver.get(url)
    wait = WebDriverWait(driver, 10)
    while True:
        # class가 right인 버튼이 없을 때까지 계속 클릭
        try:
            time.sleep(3)
            element = wait.until(EC.element_to_be_clickable(By.CLASS_NAME, 'right'))
            element.click()
            time.sleep(3)
        except TimeoutException:
            print("no pages left")
            break
    html = driver.page_source
    soup = BeautifulSoup(html, 'lxml')
    driver.quit()
    return soup

# 마지막 페이지 번호 알아내기
def get_last_page(url):
    soup = go_to_last_page(url)
    pagination = soup.find('div', {'class' : 'page'})
    pages = pagination.find_all("a")
    page_list = []
    for page in pages[1:]:
        page_list.append(int(page.get_text(strip=True)))
    max_page = page_list[-1]
    print(f"총 {max_page}개의 페이지가 있습니다.")
    return max_page

# 게시판 링크 모두 가져오기
def get_boards(page_num):
    boards = []
    for page in range(page_num):
        boards.append(f"http://mlbpark.donga.com/mp/b.php?p={30*page+1}&m=search&b=bullpen&{search_QUERY}&select=sct&user=")

    return boards

# 게시글 링크 가져오기
def get_posts():
    board_links = get_boards(PAGES)
    posts = []
    for board_link in board_links:
        # print(f"게시판 링크는 {board_link}")
        req = requests.get(board_link)
        print(req.status_code) # 50개 나와야 함
        soup = BeautifulSoup(req.text, 'lxml')
        tds = soup.find_all('td', {'class': 't_left'})
        for td in tds:
            post = td.find('a', {'class': 'bullpenbox'})
            if post is not None:
                posts.append(post['href'])
    print(f"총 {len(posts)}개의 글 링크를 찾았습니다.")
    return posts



# 한 페이지에서 정보 가져오기
def extract_info(url, wait_time=3, delay_time=1):    

    print(url)

    driver.implicitly_wait(wait_time)
    driver.get(url)
    html = driver.page_source
    soup = BeautifulSoup(html, 'lxml')

    time.sleep(delay_time) # 강제 연결 종료 방지

    site = soup.find('h1', {'class': 'logo'}).find('a').find('img')['title'].strip()
    title = soup.find('div', {'class': 'titles'}).get_text(strip=True)
    user_id = soup.find('span', {'class': 'nick'}).get_text(strip=True)
    post = soup.find('div', {'id': 'contentDetail'}).get_text(strip=True)
    view_cnt = int(soup.find('div', {'class': 'text2'}).find('a').find('span').get_text(strip=True).replace('\n', '').replace('\r', '').replace(',', ''))
    recomm_cnt = int(soup.find('span', {'id': 'likeCnt'}).get_text(strip=True).replace('\n', '').replace('\r', '').replace(',', ''))
    reply_cnt = int(soup.find('span', {'id': 'replyCnt'}).get_text(strip=True).replace('\n', '').replace('\r', '').replace(',', ''))

    time.sleep(delay_time)  # 강제 연결 종료 방지

    reply_content = []
    if reply_cnt != 0:
        replies = soup.find_all('span', {'class': 're_txt'})
        for reply in replies:
            reply_content.append(reply.get_text().replace('\n', '').replace('\r', ''))

    print("완료")

    return {'site': site, 'title': title, 'user_id': user_id, 'post': post, 'view_cnt': view_cnt, 'recomm_cnt': recomm_cnt, 'reply_cnt': reply_cnt, 'reply_content': reply_content}

# 모든 게시물 링크에 대해 정보 가져오는 함수 호출
def get_contents():
    post_links = get_posts()
    contents = []
    for post_link in post_links:
        content = extract_info(post_link)
        contents.append(content)
    return contents

# 저장하는 함수
def save_to_file(lst):
    global QUERY
    global PAGES
    file = open(f"MLBPARK_{QUERY}_{PAGES}pages_checkiferror.csv", mode='w', encoding='utf-8')
    writer = csv.writer(file)
    writer.writerow(['site', 'title', 'user_id', 'post', 'view_cnt', 'recomm_cnt', 'reply_cnt', 'reply_content'])
    for result in lst:
        writer.writerow(list(result.values()))
    file.close()
    return

# 함수 실행
PAGES = get_last_page(URL)
mlbpark_results = get_contents()
driver.quit()
save_to_file(mlbpark_results)

 


# 데이터 살펴 보기_문제점 발견

 

 적재한 데이터를 확인했다.

 

 

 우선, 데이터가 전부 다 잘 들어왔는지 확인한다. 눈으로 보기에는 잘 들어온 것 같다. 그렇지만, MLBPARK BULLPEN 검색 결과, 48페이지까지 30개의 게시물이 표시되고, 49페이지만 22개의 게시물이 표시된다. 즉, 48X30+22 = 1462개의 데이터가 있어야 하는데, 1372개 뿐이다. 코드를 수정하여 디버깅할 수 있는 부분을 추가하고, 어디서 데이터가 누락되었는지 확인하자.

 둘째, 수치형 데이터의 요약 통계량을 확인했다. 조회 수와 추천 수의 통계량이 놀라우리만치(...) 일치한다. 오류다. 변수명을 잘못 써서 하나의 데이터를 중복저장하지는 않았는지 확인하자.

 

 

 

 

 셋째, 댓글 내용을 확인했다.

  아무래도 보기가 힘들다. 계속 보면서 어떻게 댓글 내용을 저장하는 것이 좋을까 고민하다가, 초기값을 빈 string("")으로 주고, 댓글을 string에 추가하는 방식을 생각했다. 나중에 댓글을 구별할 필요가 있으므로, 새로운 댓글을 추가할 때는 new line이나 tab으로 구분하면 좋을 것이라 보인다. 코드를 수정해서 댓글 데이터 저장 형식을 바꿔 보자.

  또한, 전처리가 필요하다. '[리플수정]'이 있는 경우에는 제거하고 그 다음부터, 답글인 경우 '//' 다음부터 내용을 받아 오도록 하자.

 

반응형