이번 글에서는 뉴스 기사를 제공하는 포털인 Daum 에서 주기적으로 뉴스기사를 수집하는 크롤러를 구현해보겠습니다. 기본적인 아이디어는 HTML 문서를 파싱해서 필요한 부분만을 추출/가공해 MySQL Database 에 저장하는 것입니다. 이를 위해 다양한 Library 가 존재하지만 그 중에 Scrapy 라는 프레임워크를 사용하여 크롤러를 구현해보도록 하겠습니다.

  • 테스트 용 Docker Container 생성
$ sudo docker run -it -d --name daum_news_crawler ubuntu:20.04 /bin/bash

먼저 동작환경을 위한 우분투 컨테이너를 생성합니다.

  • Scrapy 및 기타 Library 설치
$ sudo docker exec -it daum_news_crawler /bin/bash

생성한 이미지 내부로 진입합니다.

$ apt-get update

먼저 apt-get 을 업데이트 합니다. ubuntu 공식이미지로 생성한 컨테이너에 진입시 기본적으로 root 로그인되므로 sudo 는 생략합니다.

$ apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev zlib1g-dev libffi-dev libssl-dev vim default-libmysqlclient-dev build-essential

기본적으로 필요한 dependencies 를 설치합니다.

$ pip install scrapy SQLAlchemy mysqlclient

scarpy 를 설치합니다. 또한 ORM 으로 사용할 SQLAlcehmy 를 설치합니다. 중간에 tzdata 설치시 region 정보를 적절히 입력해줍니다.

  • Scrapy 프로젝트 생성
$ mkdir -p /workspace/daum_news_crawler
$ cd /workspace/daum_news_crawler

크롤러 코드가 구현될 폴더를 생성 후 폴더 내부로 이동합니다.

$ scrapy startproject daum_news_crawler
$ cd ./daum_news_crawler

scrapy 명령어를 사용하여 Scrapy 프로젝트를 생성합니다. 그리고 생성된 프로젝트 폴더 내로 진입합니다.

  • Daum 뉴스 페이지 구조 분석 및 크롤링 전략 설계

이제 코드를 구현할 준비가 되었습니다. 코드를 구현하기에 앞서 먼저 크롤링할 Daum 뉴스 페이지의 구조를 먼저 살펴보도록 하겠습니다.

https://news.daum.net/ 에 가면 하단부에 위와 같은 메뉴가 있습니다. 이 중에 ‘언론사별 뉴스’ 를 클릭해 들어갑니다.

그럼 위와 같은 페이지가 뜹니다. 각 언론사(cp)별로 진입할 수 있는 페이지가 나타나고 각 언론사이름을 클릭하면 해당 언론사에서 작성한 뉴스기사 목록이 pagination 되어 표시됩니다.

이렇게 뉴스 목록과 언론사 정보, 날짜, 약간의 뉴스기사내용, 썸네일이미지 등이 표시됩니다. 이 때 브라우저의 url 을 확인해보면 다음과 같습니다.

https://news.daum.net/cp/4

path parameter 형식으로 되어 있고, cp 다음에 국민일보를 표현한 4라는 숫자가 붙어있습니다. 이를 이용하면 각 cp 별 뉴스목록 url 을 추출할 수 있습니다. 또한 우측 상단의 날짜 picker 를 조정하면 해당 날짜의 내용이 표시됩니다. 이때 url 에 Query parameter 형식으로 regDate 가 추가되어 아래와 같이 url 이 변경됩니다.

https://news.daum.net/cp/4

이를 통해 cp 별 특정 날짜의 뉴스목록 url 을 추출 할 수 있습니다.

또한 하단의 page 번호를 클릭하면 query parameter 에 page 가 추가되어 아래와 같이 url 이 변경됩니다.

https://news.daum.net/cp/45?page=2&regDate=20210624

이를 조합하여 설계한 전체적인 크롤링 전략은 아래와 같습니다.

  1. 전체 cp 목록을 확보하고
  2. 각 cp 별 / 날짜 별 / 페이지 별 뉴스목록 url 을 방문하여
  3. 각 뉴스기사의 url 을 확보하고
  4. 각 뉴스기사 상세페이지로 진입하여 제목 / 작성자/ 생성일 / 수정일 / 기사내용 / 이미지 url 등 을 파싱하여
  5. 적절한 형태로 가공하고,
  6. MySQL Database 에 저장합니다.
  7. 위 작업을 더 이상 크롤링할 cp / 날짜 / 페이지 가 없을때 까지 반복합니다.
  • HTML 구조 분석

뉴스목록 페이지에서 각 뉴스기사의 url 을 추출하기 위해 html 내의 특정 element 를 추출해야 합니다. 해당 페이지에서 오른쪽 마우스를 클릭하여 Chrome 의 ‘페이지 소스 보기’ 를 통해 직접 원하는 element 의 XPath 를 찾아도 되고 Chrome 의 개발자 도구 기능을 통해 편리하게 XPath 를 추출할 수 도 있습니다. 저는 후자의 방법으로 진행하겠습니다. 파싱할 페이지에서 F12 키를 통해 개발자도구 를 실행합니다.

브라우저 우측에 위와 같이 개발자 도구 화면이 뜹니다.

왼쪽 상단의 select element 버튼을 클릭하여 마우스 포인터를 추출대상의 element 위에 놓아봅니다.

우측의 개발자도구 화면에 위와 같이 하이라이트 표시가 됩니다. 이 때 하이라이트 부분을 마우스 오른쪽 클릭을 합니다.

Copy 항목으로 들어가면 원하는 형태의 Path 를 추출이 가능합니다. 이번 글에서는 Scrapy 에서 지원하는 XPath 형태로 추출할 예정이므로 ‘Copy XPath’ 를 클릭하여 XPath 를 복사합니다. 복사한 내용은 아래와 같습니다.

//*[@id=”mArticle”]/div[2]/ul/li[15]/div/strong/a

이 string 을 잘 보관해둡니다. 마찬가지 방법으로 기사 상세 페이지로 들어가서 원하는 부분(제목, 작성자 등) 의 항목의 XPath 를 추출하여 보관해둡니다.

  • 크롤러 코드 구현

이제 크롤러 코드를 작성할 차례입니다. 코드는 python 으로 구현합니다. 구현된 코드는 이 링크에서 확인이 가능합니다.

먼저items.py 코드를 보도록 하겠습니다.

# -*- coding: utf-8 -*-

from scrapy import Item, Field


class DaumNewsCrawlerItem(Item):
id = Field()
source = Field()
category = Field()
title = Field()
editor = Field()
created_date = Field()
updated_date = Field()
article = Field()
images = Field()

def initialize(self, value):
for keys, _ in self.fields.items():
self[keys] = value

DaumNewsCrawlerItem은 일종의 VO(Value Object) 로 사용됩니다. html 페이지에서 파싱할 항목에 대응하며 initialize() 함수를 가지고 있습니다.

그 다음으로 models.py 코드를 보도록 하겠습니다.

from sqlalchemy import create_engine, Column, Integer, String, DateTime, UnicodeText, text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.engine.url import URL
import datetime
import os

DeclarativeBase = declarative_base()


def db_connect():
return create_engine(url=URL.create(drivername='mysql+mysqldb',
username=os.environ.get('DAUM_NEWS_CRAWLER_DB_USER'),
password=os.environ.get('DAUM_NEWS_CRAWLER_DB_PW'),
host=os.environ.get('DAUM_NEWS_CRAWLER_DB_HOST'),
port=os.environ.get('DAUM_NEWS_CRAWLER_DB_PORT'),
database=os.environ.get('DAUM_NEWS_CRAWLER_DB')),
connect_args={'charset': 'utf8mb4',
'use_unicode': 'True'})


def create_tables(engine):
DeclarativeBase.metadata.create_all(engine, checkfirst=True)


class Article(DeclarativeBase):
__tablename__ = 'article'

id = Column('id', String(50), primary_key=True)
source = Column('source', String(50), nullable=False, server_default='')
category = Column('category', String(50), nullable=False, server_default='')
title = Column('title', String(1000), nullable=False, server_default='')
editor = Column('editor', String(50), nullable=False, server_default='')
article = Column('article', UnicodeText, nullable=False)
created_dt = Column('created_dt', DateTime, nullable=False, server_default=str(datetime.datetime.min))
updated_dt = Column('updated_dt', DateTime, nullable=False, server_default=str(datetime.datetime.min))
row_created_dt = Column('row_created_dt', DateTime, nullable=False, server_default=text('CURRENT_TIMESTAMP'))
row_updated_dt = Column('row_updated_dt', DateTime, nullable=False,
server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'))

def __init__(self):
self.id = ''
self.source = ''
self.category = ''
self.title = ''
self.editor = ''
self.article = ''
self.created_dt = datetime.datetime.min
self.updated_dt = datetime.datetime.min


class Image(DeclarativeBase):
__tablename__ = 'image'

id = Column('id', String(500), primary_key=True)
article_id = Column('article_id', String(50), nullable=False, server_default='')
row_created_dt = Column('row_created_dt', DateTime, nullable=False, server_default=text('CURRENT_TIMESTAMP'))
row_updated_dt = Column('row_updated_dt', DateTime, nullable=False,
server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'))

def __init__(self):
self.id = ''
self.article_id = ''

db_connect() 함수에서는 환경변수에 저장된 db 접속정보를 가지고 와서 mySQL DB 에 접속합니다. create_tables() 함수에서는 Scrapy ORM 에서 제공하는 편리한 기능을 사용해 table 이 존재하지 않을 경우 아래 의 Article, Image 모델 클래스에 정의된 Column 을 기반으로 table 을 생성합니다. row_created_dt, row_updated_dt 는 사용자가 레벨이 아닌 database 레벨에서 자동으로 관리되도록 설정합니다. 여기서 주의할 점은 Article, Image 클래스 내에 Column() 을 통해 Default Value 를 설정했다 하더라도 Article, Image 인스턴스 변수값이 그 Default Value 로 초기화되지는 않습니다. 그래서 각 모델클래스에 __init__() 함수를 추가로 구현해줍니다. 어떤 이유인지는 모르겠지만 이 부분은 Scrapy 개발팀에서 대응을 해주면 좋을 듯 합니다.

그 다음으로 spider.py 코드를 보도록 하겠습니다.

# -*- coding: utf-8 -*-
import scrapy
import datetime
from daum_news_crawler.items import DaumNewsCrawlerItem
import sys
import logging

logger = logging.getLogger('daum_news_crawler')


class DaumNewsSpider(scrapy.Spider):
name = 'daum_news_crawler'

def start_requests(self):
try:
press_list = [11, 45, 396, 21, 190, 15, 33, 38, 2, 200, 8, 17, 49, 7, 5, 3589, 129, 90, 6, 216, 139, 3, 19,
35, 3272, 359, 43, 157, 10, 4, 47, 12, 244, 310, 327, 75, 98, 60, 73, 189, 23, 318, 317, 134,
313, 296, 58, 249, 56, 532, 67, 242, 57, 80, 231, 86, 169, 434, 112, 676, 37, 331, 132, 188,
13, 176, 210, 320, 144, 284, 555, 301, 234, 302, 291, 181, 326, 180, 70, 285, 724, 246, 110,
321, 214, 119, 287, 293, 226, 85, 297, 94, 219, 82, 294, 278, 306, 163, 261, 59, 260, 165,
250, 269, 131, 178, 170, 227, 314, 268, 255, 162, 254, 303, 221, 259, 224, 29, 228, 3437, 256,
323, 298, 305, 251, 252, 18, 77, 220, 175, 62, 3130, 14, 68, 3510, 22, 295, 95, 122, 79, 65]

num_of_days_to_crawl = 1
num_of_pages_to_crawl = 100

for cp in press_list:
for time_delta in range(0, num_of_days_to_crawl):
for page in range(1, num_of_pages_to_crawl + 1, 1):
reg_date = (datetime.datetime.now() + datetime.timedelta(days=time_delta)).strftime('%Y%m%d')
yield scrapy.Request(
url="http://media.daum.net/cp/{0}?page={1}&regDate={2}".format(cp, page, reg_date),
callback=self.parse_url)

except Exception as e:
logger.error('[start_requests] error occurred')
logger.error(str(e))

def parse_url(self, response):
try:
for sel in response.xpath('//*[@id="mArticle"]/div[2]/ul/li/div'):
yield scrapy.Request(
url=sel.xpath('strong[@class="tit_thumb"]/a/@href').extract()[0],
callback=self.parse)

except Exception as e:
logger.error('[parse_url] error occurred')
logger.error(str(e))

def parse(self, response):
try:
item = DaumNewsCrawlerItem()
item.initialize('')

item['id'] = response.url.split("/")[-1]
item['source'] = response.xpath('//*[@id="cSub"]/div[1]/em/a/img/@alt').get()
item['category'] = response.xpath('//*[@id="kakaoBody"]/text()').get()
item['title'] = response.xpath('//*[@id="cSub"]/div[1]/h3/text()').get()
item['article'] = response.xpath(
'//*[@id="harmonyContainer"]/section/div[contains(@dmcf-ptype, "general")]/text()').getall() \
+ response.xpath(
'//*[@id="harmonyContainer"]/section/p[contains(@dmcf-ptype, "general")]/text()').getall()
item['images'] = response.xpath('//*[@id="harmonyContainer"]/section/figure/p/img/@src').getall()

element1 = response.xpath('//*[@id="cSub"]/div[1]/span/span[1][@class="txt_info"]/text()').getall()
num_date1 = response.xpath('//*[@id="cSub"]/div[1]/span/span[1]/span[@class="num_date"]/text()').get()
element2 = response.xpath('//*[@id="cSub"]/div[1]/span/span[2][@class="txt_info"]/text()').getall()
num_date2 = response.xpath('//*[@id="cSub"]/div[1]/span/span[2]/span[@class="num_date"]/text()').get()
element3 = response.xpath('//*[@id="cSub"]/div[1]/span/span[3][@class="txt_info"]/text()').getall()
num_date3 = response.xpath('//*[@id="cSub"]/div[1]/span/span[3]/span[@class="num_date"]/text()').get()

if len(element1) != 0:
if element1[0][:2] == '입력':
item['created_date'] = num_date1
elif element1[0][:2] == '수정':
item['updated_date'] = num_date1
else:
item['editor'] = element1[0]

if len(element2) != 0:
if element2[0][:2] == '입력':
item['created_date'] = num_date2
elif element2[0][:2] == '수정':
item['updated_date'] = num_date2
else:
item['editor'] = element2[0]

if len(element3) != 0:
if element3[0][:2] == '입력':
item['created_date'] = num_date3
elif element3[0][:2] == '수정':
item['updated_date'] = num_date3
else:
item['editor'] = element3[0]

logger.info('[parse_news] : ' + response.url)

except Exception as e:
logger.error('[parse_news] error occurred')
logger.error(str(e))

yield item

DaumNewsSpider 클래스는 scrapy.Spider 클래스를 상속받아 작성합니다. Add-On 성격의 Middleware 단에서 추가적인 구현이 없다면, start_request() 함수가 크롤링의 시작점이 됩니다. Daum 뉴스제공자 페이지에서 html 을 통해 각 cp 들을 나타내는 숫자를 긁어와 press_list 에 저장합니다. 크롤링할 날 수(num_of_days_to_crawl), 크롤링할 페이지수(num_of_pages_to_crawl) 에 원하는 숫자을 입력하고, for 문을 돌며 press_list, num_of_days_to_crawl, num_of_pages_to_crawl 값을 순차적으로 증가시켜 url 을 구성하고 호출한 뒤 parse_url() 함수를 콜백으로 넘겨줍니다.

parse_url() 에서는 위에서 보관했던 XPath 를 통해 뉴스기사 상세페이지 url 을 추출한 후 호출하고 Scrapy 의 Default Callback 함수인 Parse() 함수를 넘겨줍니다.

이제 parse() 함수에서는 각 항목(제목, 작성자, 기사내용, 날짜 등)을 XPath 기반으로 파싱하여 DaumNewsCrawlerItem 인스턴스에 저장하고 반환합니다. html 페이지를 분석해보니 created_date 와 updated_date, editor 가 불규칙적으로 표시되므로 그에 따른 가공처리를 해줍니다.

그 다음으로 settings.py 코드를 살펴보겠습니다.

# Scrapy settings for daum_news_crawler project

BOT_NAME = 'daum_news_crawler'
SPIDER_MODULES = ['daum_news_crawler.spiders']
NEWSPIDER_MODULE = 'daum_news_crawler.spiders'
USER_AGENT = 'daum_news_crawler'

ROBOTSTXT_OBEY = False
CONCURRENT_REQUESTS = 16
CONCURRENT_REQUESTS_PER_DOMAIN = 8
CONCURRENT_REQUESTS_PER_IP = 0
DOWNLOAD_DELAY = 0
RANDOMIZE_DOWNLOAD_DELAY = False

ITEM_PIPELINES = {
'daum_news_crawler.pipelines.DaumNewsCrawlerPipeline': 300,
}

LOG_ENABLED = True
LOG_ENCODING = 'utf-8'
LOG_FILE = None
LOG_LEVEL = 'INFO'

settings.py 파일이 존재하면 Scrapy 는 자동으로 이 파일의 내용을 참조합니다. Scrapy 의 CONCURRENT_XXX, DOWNLOAD_DELAY 등의 변수를 통해 요청량을 적절하게 조절하고 ITEM_PIPELINES 에 Pipeline 으로 사용할 클래스를 명시해줍니다.

그 다음으로 pipelines.py 코드를 살펴보겠습니다.

# -*- coding: utf-8 -*-

from sqlalchemy.orm import sessionmaker
from .models import Article, Image, db_connect, create_tables
import logging

logger = logging.getLogger('daum_news_crawler')


class DaumNewsCrawlerPipeline(object):
def __init__(self):
engine = db_connect()
create_tables(engine)
self.Session = sessionmaker(bind=engine)

def process_item(self, item, spider):
session = self.Session()
article = Article()
article.id = item['id'] if item['id'] else ''
article.source = item['source'] if item['source'] else ''
article.category = item['category'] if item['category'] else ''
article.title = item['title'].replace("'", "''") if item['title'] else ''
article.editor = item['editor'] if item['editor'] else ''

for i in item['article']:
article.article += ('\n\n' + i.replace("'", "''"))

if len(item["created_date"]) == 19:
article.created_dt = item["created_date"][0:4] \
+ '-' \
+ item["created_date"][6:8] \
+ '-' \
+ item["created_date"][10:12] \
+ ' ' \
+ item["created_date"][14:16] \
+ ':' \
+ item["created_date"][17:19]

if len(item["updated_date"]) == 19:
article.updated_dt = item["updated_date"][0:4] \
+ '-' \
+ item["updated_date"][6:8] \
+ '-' \
+ item["updated_date"][10:12] \
+ ' ' \
+ item["updated_date"][14:16] \
+ ':' \
+ item["updated_date"][17:19]

try:
session.merge(article)

for i in item['images']:
image = Image()
image.id = i
image.article_id = article.id
session.merge(image)

session.commit()
except Exception as e:
logger.error('[process_item] error occurred')
logger.error(str(e))
session.rollback()
raise
finally:
session.close()

return item

DaumNewsCrawlerPipeline 클래는 우선 __init__() 함수에서 db 에 연결을 하고 table 이 없을 경우 table 을 생성하고 session 을 생성합니다. Pipeline 클래스에서 반드시 구현해야 하는 process_item() 함수에서는 spider.py 의 parse() 에서 넘겨받은 DaumNewsCrawlerItem 인스턴스의 내용을 가공하여 ORM 의 Model(Article, Image 클래스) 인스턴스에 저장합니다. 그 후 session.merge(), session.commit()을 통해 database 에 저장합니다. session.add() 함수 대신 session.merge() 함수를 사용하는 이유는 반복적으로 크롤링이 수행되는 경우 PK 중복으로 인한 오류를 피하기 위함과 동시에 뉴스기사의 특성상 최초 작성 된 후 수정이 빈번하게 일어나기 때문에 최종 수정본을 DB에 업데이트 하기 위함입니다. 참고로 SqlAlchemy Core 에서는 on_conflict_do_update() 함수를 통해 upsert 기능을 제공하고 SqlAlchemy ORM 에서는 session.merge() 기능을 통해 유사한 기능을 제공합니다. 단 session.merge() 함수는 PK 의 중복인 경우에만 동작하는 점은 유의해야 합니다.

  • 크롤러 실행

이제 크롤러 코드 구현이 완료되었습니다. docker container 내부로 진입합니다.

$ export DAUM_NEWS_CRAWLER_DB_USER={DB ID}
$ export DAUM_NEWS_CRAWLER_DB_PW={DB PASSWORD}
$ export DAUM_NEWS_CRAWLER_DB={DB Name}
$ export DAUM_NEWS_CRAWLER_DB_HOST={DB HOST NAME}
$ export DAUM_NEWS_CRAWLER_DB_PORT={DB PORT}

위와 같이 환경변수에 DB 접속정보를 저장합니다. {} 안에는 실제 값을 넣도록 합니다.

$ cd /workspace/daum_news_crawler/daum_news_crawler

코드가 구현된 폴더 내부로 이동합니다.

$ scrapy crawl daum_news_crawler

scrapy crawl 명령어를 통해 크롤링을 시작합니다. 하드웨어 스펙 및 구동환경에 따라 완료되는 시간은 다를 수 있습니다.

......
2021-06-29 11:21:12 [daum_news_crawler] INFO: [parse_news] : https://news.v.daum.net/v/20210629103548312
2021-06-29 11:21:15 [scrapy.core.engine] INFO: Closing spider (finished)
2021-06-29 11:21:15 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 9354842,
'downloader/request_count': 38762,
'downloader/request_method_count/GET': 38762,
'downloader/response_bytes': 389780237,
'downloader/response_count': 38762,
'downloader/response_status_count/200': 24654,
'downloader/response_status_count/302': 14101,
'downloader/response_status_count/503': 7,
'dupefilter/filtered': 31,
'elapsed_time_seconds': 2094.853904,
'finish_reason': 'finished',
'finish_time': datetime.datetime(2021, 6, 29, 2, 21, 15, 685520),
'httpcompression/response_bytes': 1977403111,
'httpcompression/response_count': 24654,
'item_scraped_count': 10553,
'log_count/ERROR': 3,
'log_count/INFO': 10598,
'memusage/max': 164306944,
'memusage/startup': 70832128,
'request_depth_max': 1,
'response_received_count': 24654,
'retry/count': 7,
'retry/reason_count/503 Service Unavailable': 7,
'scheduler/dequeued': 38762,
'scheduler/dequeued/memory': 38762,
'scheduler/enqueued': 38762,
'scheduler/enqueued/memory': 38762,
'start_time': datetime.datetime(2021, 6, 29, 1, 46, 20, 831616)}
2021-06-29 11:21:15 [scrapy.core.engine] INFO: Spider closed (finished)
root@a22f399a5a4d:/workspace/daum_news_crawler/daum_news_crawler#

총 10,553개의 기사가 크롤리 되었고 2,094 초가 소요되었습니다.

Error "1038 Out of sort memory, consider increasing server sort buffer size

만약 DB GUI 툴을 통해 테이블을 조회할 경우 위와 같은 에러가 발생할 수 있습니다. 이는 mySQL 의 sort buffer size 가 작게 할당되어 있어서 나는 오류입니다.

show variables where Variable_Name like ‘%sort_buffer%’;

위 쿼리를 통해 현재의 sort buffer size 를 확인하고 너무 작게 되어 있으면 아래의 쿼리를 통해 적절하게 늘려줍니다.

SET sort_buffer_size = 1024*1024*1;

그리고 DB GUI 툴을 통해 다시 확인해봅니다.

DB 에도 뉴스기사 내용이 정상적으로 적재되었음을 확인할 수 있습니다.

  • 마무리

이번 글에서는 Docker 컨테이너를 하나 만들어서 그 위해 Scrapy 기반의 daum news crawler 구현하여 MySQL DB 에 적재하는 과정을 진행해보았습니다. 이렇게 할 경우 Docker 컨테이너를 수동을 관리해야 하고 코드 수정시 수정된 내용을 수동으로 배포해야 한다는 불편함이 있습니다. 다음 글에서는 Kubernetes Cluster 상에 TLS 기반 Docker Private Repository 를 구축해 보도록 하겠습니다.

--

--