Owl Life

파이썬으로 웹페이지 크롤링을 해보자. (1) Django 프로젝트 만들기 본문

Python

파이썬으로 웹페이지 크롤링을 해보자. (1) Django 프로젝트 만들기

Owl Life 2019. 11. 11. 19:14
반응형

들어가며...

  파이썬으로 웹페이지의 새로운 게시글을 크롤링하고, Django를 이용해서 DB에 저장하는 실습을 해보도록 하겠습니다.

crontab을 이용해서 주기적으로 크롤링을 실행되도록 하고, 새로운 글이 크롤링 되었을때 텔레그렘이나 슬랙으로 전송하는 기능과 VPS 에서 실행되도록 하는 실습을 진행해보겠습니다.

 

  이번 첫 시간에는 Django 프로젝트를 생성 해보겠습니다. Django 프로젝트와 앱을 만들고, Model을 통해 DB를 생성하고 크롤링 된 데이터를 저장 및 관리자 페이지를 섿업하는것까지 진행해보겠습니다. 만약, Django가 처음이라면 이 링크를 참고 하시면 많은 도움이 될 것입니다.

Python 3.7 버전 설치

Django 설치를 위한 파이썬 최소 버전은 3.7입니다. 만약, 본인의 개발 환경(우분투)에 3.7 버전이 설치되어 있지 않다면 아래 링크를 통하여 설치 후 진행을 권합니다. 만약, 본인이 초급자라면 venv도 설치를 권합니다. 설치 방법은 역시 링크에 포함되어 있습니다.

https://softwaree.tistory.com/85

 

Ubuntu 16.04에서 Python 3.7 설치

들어가며... django를 사용하려면 Python이 최소 3.7 버전이 설치 되어 있어야 합니다. 하지만 우분투 16.04에서 지원하는 버전은 3.5이기 때문에 추가로 설치를 하여야 합니다. 본인의 PC에 설치되어 있는 버전을..

softwaree.tistory.com

Django 설치하기

python 3.7버전 이상이 설치 되어 있다면 간단한 명령어로 Django를 설치 할 수 있습니다.

$ pip install django

 

Django 프로젝트 생성하기

  django가 성공적으로 설치되면 django-admin 이라는 명령어로 장고 프로젝트를 생성 할 수 있습니다.

이번 포스트에서는 제가 좋아하는 '클리앙' 이라는 커뮤니티의 크롤링을 위하여 ClienCrawlingDjango 라는 이름으로 프로젝트를 만들어보겠습니다.

$ django-admin startproject ClienCrawlingDjango

 

생성을 완료하면 아래와 같은 폴더 트리를 볼 수 있습니다. (IDE는 PyCharm CE)

 

장고 앱 생성하기

  Django는 프로젝트와 그 안의 앱으로 관리됩니다. 이 앱은 하나의 기능을 담당하는 컴포넌트로 보면 될것 같습니다. 앱은 manage.py 파일을 통해서  startapp이라는 명령어로 생성이 가능합니다. crawled_data라는 이름의 앱을 만들어보겠습니다.

$ python manage.py startapp crawled_data

정상적으로 생성이 되었다면 아래 스크린샷처럼 프로젝트의 구조를 볼 수 있습니다.

 

  생성만으로 끝난게 아니라 이 앱을 Django가 관리하도록 수정 작업을 해야 합니다.

ClienCrawlingDjango 폴더 안의 settings.py 파일의 INSTALLED_APPS에 추가 하여야 합니다.

# Application definition
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'crawled_data'
]

 

DB 첫 마이그레이션

  DB를 사용하기 위하여 최초 1회 마이그레이션을 진행 하여야 합니다. 명령어는 python manage.py migrate 입니다.

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying sessions.0001_initial... OK

 

crawled_data 앱의 모델 생성하기

  DB에 데이터를 저장할 모델을 만들어 보겠습니다. Django에서 모델은 앱 단위로 만들어지고 구성이 됩니다. 앞에서 만든 앱 단위인 crawled_data 안에 있는 models.py파일을 수정해줘야 합니다.

 

  이 모델 파일은 크롤링한 데이터를 필드별로 저장하는것이 목적입니다. 따라서 크롤링한 데이터의 추출할 부분을 미리 파악해서 객체로 만들어주어야 합니다. Django는 NoSQL이 아닌 SQL DB이기 때문에 스키마 정의를 해 주어야 하는데 이 스키마 정의를 별도의 object 클래스로 정의하여야 합니다.

 

  본 실습 프로젝트에서는 requests 라이브러리와 BeautifulSoup4(bs4) 라이브러리를 주로 활용할 계획입니다.

 

  DB에 저장할 데이터는 타이틀과 링크, 고유 ID로 구성하겠습니다. BoardData라는 이름의 Table을 DB에 만들도록 하겠습니다.

django models의 class는 DB의 Table로 변환이 됩니다.

from django.db import models


# Create your models here.
class BoardData(models.Model):
    title = models.CharField(max_length=300)
    link = models.URLField()
    specific_id = models.CharField(max_length=15)

  이렇게 만들면 title은 최대 300글자 제한의 CharField로, link는 URLField로, specific_id 는 15글자의 제한의 CharField로 생성됩니다. specific id는 클리앙 게시글에 부여되는 고유 ID로서 최신 데이터인지를 판단하는 기준으로 사용됩니다.

 

DB Migration

  새로운 DB가 추가되었으니 db migration을 진행하여야 합니다. 아래처럼 명령어를 입력하면 DB의 변경 정보를 정리하고, 실제 DB에 반영하는 과정을 진행하게 됩니다.

$ python manage.py makemigrations crawled_data
Migrations for 'crawled_data':
  crawled_data/migrations/0001_initial.py
    - Create model BoardData
(common_env) owllife in ~/PycharmProjects/a/ClienCrawlingDjango
$ python manage.py migrate crawled_data
Operations to perform:
  Apply all migrations: crawled_data
Running migrations:
  Applying crawled_data.0001_initial... OK

  마이그레이션을 할 때마다 crawled_data 앱 폴더의 하위에 있는 migrations 폴더를 보면 버전순으로 어떤점들이 변경되었는지를 볼 수 있습니다.

 

  최초로 DB 테이블을 생성하였기 때문에 _initial라는 suffix가 붙어 있습니다. 이 파일들은 makemigrations라는 명령어를 통해 생성이 되고, 실제로 DB에 적용을 할 때에는 migrate라는 명령어를 사용하게 됩니다. 바로 이전에 실행한 명령어들입니다.

 

클리앙 커뮤니티 크롤링 함수 구현

  클리앙의 "모두의 공원" 이라는 게시물에서 "쿠팡"이라는 키워드로 검색후, 새로운 키워드가 있는 경우에만 DB에 추가 해보도록 하겠습니다. (쿠팡 직원 아님)

해당 링크와 게시판을 스크린샷으로 살펴보겠습니다.

 

링크

https://www.clien.net/service/search/board/park?sk=title&sv=%EC%BF%A0%ED%8C%A1

 

스크린샷

 

이제 코드를 살펴보겠습니다.

필요한 라이브러리를 설치하겠습니다. 웹페이지의 http 통신을 위해 requests 와 웹페이지를 파싱하는데 유용한 라이브러리인 bs4를 설치하겠습니다.

pip install requests
pip install bs4

 

프로젝트의 root 디렉토리에 clien_crawler.py 파일을 생성 후 아래와 같이 코딩합니다.

import os
from urllib.parse import urlparse

import requests
from bs4 import BeautifulSoup


def fetch_clien_latest_data():
    result = []

    url = 'https://www.clien.net/service/search/board/park?sk=title&sv=%EC%BF%A0%ED%8C%A1'
    response = requests.get(url)
    html = response.text
    soup = BeautifulSoup(html, 'html.parser')

    web_page_link_root = "https://clien.net"
    list_items = soup.find_all("div", "list_item symph_row")

    for item in list_items:
        # title
        title = item.find("span", "subject_fixed")["title"]

        # link
        page_link_raw = web_page_link_root + item.find("div", "list_title").find("a")["href"]
        page_link_parts = urlparse(page_link_raw)
        normalized_page_link = page_link_parts.scheme + '://' + page_link_parts.hostname + page_link_parts.path

        # specific id
        specific_id = page_link_parts.path.split('/')[-1]

        item_obj = {
            'title': title,
            'link': normalized_page_link,
            'specific_id': specific_id,
        }

        print(title)
        result.append(item_obj)

    return result

 

  링크의 데이터를 soup에서 받아오고, 그 데이터를 파싱 후 리스로 만들고, 루프를 돌면서 title, link 및 specific id 등을 얻어옵니다. specific id는 link에서 파싱을 해서 얻어오는데 게시글에 부여되는 고유 id로 생각하시면 될것 같습니다. DB에 저장 후 계속해서 크롤링을 하게 될텐데 이미 저장된 데이터인지 판단하는 기준으로 사용됩니다.

 

  얻어온 데이터는 item_obj에 dictionary 형태로 저장하고, 리스트로 생성해줍니다. 생성된 리스트는 리턴해서 db에 저장 할 수 있는 상태로 만들어줍니다.

 

  파싱할 규칙은 크롬 브라우저의 '검사' 메뉴를 통해 소스를 볼 수 있으니 알맞은 규칙을 찾아서 파싱 할 수 있습니다. BeautifulSoup4에 대하여 자세히 알아보시려면 따로 구글링을 통해 찾을 수 있습니다.

 

파싱 메서드가 제대로 동작하는지 확인하려면 해당 메서드에 메인 메서드를 만들어서 호출해서 테스트 할 수 있습니다.

if __name__ == '__main__':
    fetch_clien_latest_data()

 

실행해보면 아래처럼 출력이 됩니다. 파싱 메서드에 타이틀을 출력하는 코드가 있습니다. '쿠팡' 이라는 키워드로 검색 후 나온 결과물을 그대로 보여준 것을 알 수 있습니다.

오늘 쿠팡이츠 알바뛸껄 그랬어요.ㅋㅋㅋㅋ
쿠팡맨 게임 ㅠㅠ
쿠팡, 더 힘들어 질 듯 합니다...
쿠팡에 이어팟(3.5파이버전) 16950원이네요
쿠팡 로켓배송은 제가 집 앞에 편의점 가는 것보다 빠르네요.
쿠팡 추천상품 보다가...
쿠팡 누적 적자가 3조라는군요
달려라 쿠팡맨 ost는 좋네요
어제 결제한 쿠팡 12% 캐시백 행사 아이폰이 왔습니다
전 이제 쿠팡없으면 못삽니다
자급제 아이폰11% 쿠팡 12% 캐시백에 24개월 무이자할부 떴네요
쿠팡 아이폰11취소했습니다.
미밴드4 정발(쿠팡판) 업데이트 되네요
쿠팡 아이폰 사전예약 무섭게 품절이네요
쿠팡에서 결국 맥북 질렀네요
쿠팡 아이폰이 왔는데
쿠팡 아이폰 내일 도착 확정!
쿠팡 새벽 아이폰프로맥스
쿠팡 로켓직구 쓰면서 불편했던 점
쿠팡이 판매자에게 돈이 바로 안가는지는 몰랐네요...
다른분들은 쿠팡 아이폰 일찍 받으신다는데 저는 그대로네요
쿠팡 아이뻐 내일 온데요
쿠팡 아이폰 제발 내일 ㅠㅠㅠㅠ
쿠팡 아이폰 새벽배송으로 오네요.
쿠팡아이폰이 예정보다 일찍 오네요
쿠팡 상품 가격 히스토리 / 설정가격 알림 / 상품추천 사이트를 제작해봤습니다
쿠팡 로켓프레시 배송은 사랑입니다.
쿠팡 리뷰 보다가 어떤 분이 영양제 테이블 사진을 찍어 놓았는데..
쿠팡 아이폰11 분실건 오늘 연락 안오네요 ㄷㄷ
쿠팡 로켓와우 멤버십이 또 무료체험이네요...?

 

하지만, 이 함수는 아직 Django에 저장하는 기능을 갖고 있지 않습니다. 따라서 코드를 조금 더 추가 해 주어야 합니다.

 

실행시 Trouble shooting
문자 encoding 이슈

Django 환경 불러오기

import os
from urllib.parse import urlparse

import requests
from bs4 import BeautifulSoup

os.environ.setdefault('DJANGO_SETTINGS_MODULE', "ClienCrawlingDjango.settings")
import django

django.setup()

os 를 import하고, environ을 설정 후 django를 불러와서 섿업을 하였습니다.

이렇게 추가 후 컴파일 에러가 없다면 정상적으로 Django DB에 저장할 준비가 되었습니다.

 

Django ORM으로 데이터 저장

  이제 models에서 우리가 만들었던 BoardData를 import해 보겠습니다.

django.setup()
from crawled_data.models import BoardData

  한가지 주의해야 할점은 django.setup() 이후에 호출하여야 합니다. 그렇지 않으면 django가 초기화 되지 않은 상황에서 model을 불러오려고 해서 에러가 발생합니다.

 

  이제 DB 에 저장하는 코드를 살펴보겠습니다.

def add_new_items(crawled_items):
    last_inserted_items = BoardData.objects.last()
    if last_inserted_items is None:
        last_inserted_specific_id = ""
    else:
        last_inserted_specific_id = getattr(last_inserted_items, 'specific_id')

    items_to_insert_into_db = []
    for item in crawled_items:
        if item['specific_id'] == last_inserted_specific_id:
            break
        items_to_insert_into_db.append(item)
    items_to_insert_into_db.reverse()

    for item in items_to_insert_into_db:
        print("new item added!! : " + item['title'])

        BoardData(specific_id=item['specific_id'],
                  title=item['title'],
                  link=item['link']).save()

    return items_to_insert_into_db

  크롤링한 리스트 데이터를 인자로 넘겨 받는 add_new_items라는 메서드입니다. 세 부분으로 나뉘어서 설명해보도록 하겠습니다.

 

    last_inserted_items = BoardData.objects.last()
    if last_inserted_items is None:
        last_inserted_specific_id = ""
    else:
        last_inserted_specific_id = getattr(last_inserted_items, 'specific_id')

 

  처음에 BoardData 로부터 마지막에 추가된 항목을 불러옵니다. 아무런 데이터가 없다면 "" 로 초기화를 시켜주고, 그렇지 않다면 'specific_id'를 가져옵니다.

 

    items_to_insert_into_db = []
    for item in crawled_items:
        if item['specific_id'] == last_inserted_specific_id:
            break
        items_to_insert_into_db.append(item)
    items_to_insert_into_db.reverse()

  크롤링한 리스트 데이터의 루프를 돌면서 만약 DB에 추가된 specific_id와 동일한 id를 가졌다면 바로 break를 시켜서 루프를 나옵니다. 그렇지 않다면 새로운 항목이므로 DB에 추가할 리스트에 추가를 합니다. 모든 체크가 끝난 이후에 reverse()로 리스트를 뒤집습니다. 오래된 항목부터 DB에 추가하여야 하기 때문에 오래된 항목을 리스트의 가장 앞쪽에 위치시킵니다. DB의 가장 최신 row에는 항상 최신 데이터가 오도록 합니다.

 

    for item in items_to_insert_into_db:
        print("new item added!! : " + item['title'])

        BoardData(specific_id=item['specific_id'],
                  title=item['title'],
                  link=item['link']).save()

BoardData라는 ORM에 데이터를 추가하는 코드입니다. save() 호출을 하면 정상적으로 DB에 추가가 됩니다.

 

이제 한번 실행해보겠습니다.

$ python clien_crawler.py

크롤링한 데이터의 제목과 "new item added!! : [title]" 이 쭈욱 보여지면 정상적으로 DB에 추가가 된 상태입니다.

 

 

저장된 데이터를 Django Admin에서 확인하기

관리자계정 만들기

Django는 Django Admin이라는 강력한 기능을 제공합니다.

우선 Admin 계정을 하나 생성해야 합니다. createsuperuser 명령어로 생성 할 수 있습니다.

$ python manage.py createsuperuser
Username (leave blank to use 'root'): owllife
Email address: 
Password: 
Password (again): 
Superuser created successfully.

ID 입력 후 email은 입력하지 않아도 됩니다. 비밀번호 두번 입력 완료하면 정상적으로 계정이 생성됩니다.

 

앱에 Admin 등록하기

Django가 어떤 앱을 admin에서 관리하도록 하려면 앱 폴더(crawled_data) 안에 있는 admin.py 파일을 수정해 주어야 합니다.

from django.contrib import admin

# Register your models here.
from crawled_data.models import BoardData

admin.site.register(BoardData)

Django 서버 실행하기

이제 manage.py 가 있는 위치에서 runserver 명령어로 장고 개발 서버를 실행해 보겠습니다.

$ python manage.py runserver

만약, 특정 port 번호로 실행시키려면 아래처럼 IP:PORT 를 마지막에 추가로 입력하면 됩니다.

$ python manage.py runserver 127.0.0.1:8080

 

 

아래와 같이 로그가 출력된다면 정상적으로 서버가 실행된 상태입니다.

$ python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
November 10, 2019 - 15:48:21
Django version 2.2.7, using settings 'ClienCrawlingDjango.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

 

이제 http://localhost:8000/admin/ 으로 들어가 보겠습니다.

 

로그인을 하면 아래와 같은 화면을 볼 수 있습니다.

 

  이전에 생성한 crawled_data 라는 앱 모듈이 보이고 그 아래에 위치한 BoardData라는 테이블이 보입니다.  Board datas라고 적힌 테이블을 선택해서 들어가보면 아래와 같이 이미 추가된 항목들을 볼 수 있습니다.

 

  리스트가 보이고 가장 최근에 추가된 항목을 선택하면 아래처럼 추가된 데이터를 확인 할 수 있습니다. BoardData 라는 model에서 추가한 속성들의 값을 여기에서 모두 확인 할 수 있습니다.

 

TIP

  BoardData의 리스트를 보면 이름이 BoardData object (숫자) 와 같이 보여지기 때문에 가독성이 매우 떨어집니다. BoardData model 파일을 조금만 수정하면 아래와 같이 보기 좋게 바꿀수 있습니다.

 

코드 수정

from django.db import models


# Create your models here.
class BoardData(models.Model):
    title = models.CharField(max_length=300)
    link = models.URLField()
    specific_id = models.CharField(max_length=15)

    def __str__(self):
        return self.title

  models.py 파일을 열어서 위와 같이 __str__ 메서드를 오버라이딩 해줍니다. __str__ 메서드의 디폴트는 ClassName + object라는 값을 반환하기 때문에 위와 같이 재정의를 통하여 좀 더 보기좋게 관리자 페이지에서 읽을 수 있습니다.

 

  참고로, models.py 파일을 수정하였지만 DB 테이블 구조가 변경되는 사항이 아니기 때문에 makemigrations나 migrate를 해 줄 필요가 없습니다.

반응형
Comments