본문 바로가기

Python/Scrapy

0. 이해하기

보통 간편한 requests로 웹 크롤링을 시작한다. python을 잘 몰라도 HTTP에 대한 이해가 있고(브라우저의 개발자모드에서 요청과 응답을 확인할 수 있는 정도) requests와 BeautifulSoup의 사용법만 익히면 다른 고민 없이도 스크립트 작성이 가능하다. 복잡하지 않다면 로그인 세션을 생성하는 것도 가능하고(조금 복잡한 경우도 가능하다), 자신의 웹에 대한 이해도에 따라 활용도가 늘어나니 배우는 재미도 있다.

문제는 양. 1개의 URL을 5분에 한번씩 스크래핑해서 원하는 정보를 받는 건 requests로 충분하다. 그런데 100개라면? 예를 들어서 동일URL을 스크래핑하는데, 페이지 분할이 되어 있어서 URL에 page 파라미터를 1~100까지 변경시켜가면서 스크래핑 해야한다면? 실제로 100개의 URL 리스트를 순회하면서 requests를 실행하면 생각보다 시간이 걸릴 것이다. 만약 100개의 URL을 스크래핑하는데 1분이 걸리는데, 그 사이에 해당 페이지에서 정보 변경이 일어날 가능성이 있다면, 좀 더 빠른 방식이 필요하다. 말이 100개지, 자신이 손으로 할 수 없는 작업을 코딩으로 완성하고자 시도한다면 10000개도 우습다.

그럴때 자주 추천되는 게 scrapy다. requests도 멀티프로세싱이 가능하다고 하지만, 이를 능숙하게 구현하려고 노력할 시간에 scrapy를 익히는 게 오히려 효과적일 수 있다.

나도 scrapy의 고급 기능은 알지 못한다. requests로 오래 걸리는 작업을 scrapy로 하기 위해 시작했고, 해당 목적을 달성하기 위해 기초 튜토리얼만 익히고 응용했을 뿐이다. scrapy를 쓴다는 건 대량의 데이터를 확보하기 위함인데, 스크래핑에 성공한다면 대량의 데이터는 확보되니 남은 시간엔 그 데이터를 처리하는 데 쓰는 게 더 낫다.

scrapy는 아나콘나와 pip 둘다 설치가 가능하다. 보통 대량의 데이터를 처리하려고 아나콘다나 미니콘다를 시작하는데, 그럴 목적이 없다면 pip로 설치해도 무방하다. 단 pip로 설치할때는 파이썬 가상환경에서 진행하라고 권유하고 있다.

설치 뒤에는 커맨드라인에서 프로젝트를 만든다. 

scrapy startproject <프로젝트 이름>

그러면 프로젝트 이름과 동일한 폴더가 생성된다. 사실 requests만 쓰다가 scrapy를 처음 접할 때 거부감이 들었던 순간이다. 왜냐면, 초심자에게 requests가 편한 이유는 requests를 단순 import해서, 자신이 만드는 모듈이든 함수이든 그 안에 구현하는 도구라는 느낌이었는데, scrapy는 단순한 패키지가 아니라 프레임워크이기 때문이다. 그러니 생성된 프로젝트 안에서 정해진 대로 구현해야 실행이 되는데, 복잡해보였다. 

scrapy 공식문서에 나오는 위 폴더 구조를 보면 배울 게 한 두가지가 아닐 것 같아서 겁이 나는데, 결론부터 얘기하면 스크래핑 자체는 저 파일을 하나도 건들지 않는다. 그러니 일단 해볼만 하지 않을까. spider 폴더 안에 아무 파이썬 파일이나 만들고 공홈에 있는 예제 코드를 붙여 넣자.

import scrapy


class QuotesSpider(scrapy.Spider):
    name = 'quotes'
    start_urls = [
        'http://quotes.toscrape.com/tag/humor/',
    ]

    def parse(self, response):
        for quote in response.css('div.quote'):
            yield {
                'author': quote.xpath('span/small/text()').get(),
                'text': quote.css('span.text::text').get(),
            }

        next_page = response.css('li.next a::attr("href")').get()
        if next_page is not None:
            yield response.follow(next_page, self.parse)

requests의 기본 사용법 수준 외에는 아무것도 모른다고 가정하고 위 예시문을 이해해보자.

class QuotesSpider(scrapy.Spider):

class는 편하게 이해하면, 특정 역할을 하기 위한 변수와 함수를 하나로 모아놓은 개념으로 보면 된다. 캡슐화한다고도 한다. requests.session()을 써봤다면 이해가 빠르다. 보통 requests.session()을 하나의 변수에 선언해서 해당 세션 정보로 여러번 호출을 시도한다. 해당 변수에 session 클래스가 저장한 정보가 계속 남아있기 때문에 가능하다. 클래스를 몰라도 scrapy를 쓰는데 지장 없다.

scrapy.Spider는 첫 줄에 import한 scrapy의 Spider 클래스다. 인자로 들어간 이유는 우리가 작성하는 클래스가 저걸 상속하기 때문이다. 상속은 그 클래스를 부모로 모시고 기능을 갖다쓴다는 의미다. 직접 구현하지 않으므로 신경쓸 필요는 없지만, Spider만 상속하지는 않는다는 점만 기억하자. 가령, 스크래핑이 아니라 전방위적인 크롤링을 한다고 가정하면, scrapy.spiders.CrawlSpider라는 것을 상속하게 된다. 다만, 스크래핑이 아니라 크롤링을 하려는 이가 이런 문서를 읽고 있지는 않을 것이므로 알아만 두자. 

말이 나온 김에 스크래핑과 크롤링의 차이는 뭘까. 내 기준으로 스크래핑은 내가 브라우저에서 하는 일을 반복적으로 재현하는 것이다. 크롤링은 뭐가 나올지 모르는 것을 분석하고 파악하기 위해서 한다. 크롤링은 아무나 하는 게 아니고, 하는 방법보다는 왜 하는지가 명확해야 한다. 넘어가자.

name = 'quotes'

나중에 scrapy를 실행할때(커맨드라인에서 한다) 저 이름을 쓴다. 그러니 클래스 이름이나, 파일 이름을 뭘로 정해도 상관없다.

start_urls = [ 'http://quotes.toscrape.com/tag/humor/', ]

여기서부터 살짝 복잡하다. start_urls를 리스트로 선언해놓으면, scrapy는 알아서 해당 url들을 스크래핑한다. 앞서 말한 100페이지짜리 URL이라면,

start_urls = [f"https://example.com/page/{i}" for i in range(1, 101)]

식으로 만들어도 될 것이다. 

만약 저런 식이 아니라 좀 더 복잡하다면, class 안에 start_requests 함수를 직접 만들면 된다. start_urls는 해당 작업을 생략하는 변수다. 일단은 복잡하지 않은 걸 한다고 치고 따라가자.

def parse(self, response):

start_urls를 선언했다면 해당 url를 호출한 결과는 무조건 parse라는 함수로 전달된다(이게 싫거나 이해되지 않으면 start_requests를 써야 한다). 

self는 class 자체다. class 안에서 함수를 선언할 때는 첫번째 인자에 보통 self를 넣는다. response는 scrapy가 url를 호출한 결과물이다.

for quote in response.css('div.quote'):
            yield {
                'author': quote.xpath('span/small/text()').get(),
                'text': quote.css('span.text::text').get(),
            }

scrapy의 장점 중 하나는 BeautfulSoup으로 하는 일을 response에서 바로 할 수 있다는 것이다. response.css()가 BeautifulSoup.select()와 동일하다. 값을 얻는 방식은 셀렉터 안에 ::text로 지정하는 게 다르다. 

yield를 알기 위해서는 return을 먼저 아는 게 좋다. 함수 안에서 실행 종료의 결과물로 뭔가를 반환할 때가 많다. return을 쓰면 지정한 값을 돌려주고 함수가 종료된다. 반복문 안에서 써도 return을 하면 반복문 실행은 1번으로 끝난다. yield는 매 반복문마다 return한다. 그래서 return 처럼 특정한 값을 변수에 할당하는 게 아니라, 다시 반복시킬 수 있는 객체를 돌려준다. 그걸 제너레이터라고 하는데, 일반 함수에서는 yield로 함수를 작성하면 결과물을 다시 반복문으로 돌려서 얻어야 한다. 여러 의미와 효용성이 있지만, scrapy에서는 알아서 해주니 의미만 알자.

next_page = response.css('li.next a::attr("href")').get()
        if next_page is not None:
            yield response.follow(next_page, self.parse)

다음 페이지 링크가 있다면, 해당 링크로 다시 스크래핑을 하도록 하는 코드다. response.follow가 그런 기능을 하고, 인자인 self.parse는 해당 함수로 다시 호출한다는 의미다. 여기서도 yield를 썼다는 점을 알아두자.

실행은 프로젝트 폴더 루트에서 커맨드를 열어서 한다.

scrapy runspider quotes_spider.py -o quotes.jl

공식문서 첫 페이지에는 저렇게 나와 있는데, 

scrapy crawl quotes -o quotes.json

이게 더 편하다. 앞서 말한대로 파일명 대신 name을 쓰면 된다. -o 옵션은 파일 저장이다. jl이 익숙치 않으면 전통적인 json 파일을 쓰면 된다.

url이 많으면 많을 수록 경이적인 scrapy의 속도를 경험할 수 있다.

여기까지 하면 그런 생각이 들 수도 있겠다.

  • 너무 복잡하다. requests로 할 때 여러가지 다른 부분을 함께 구현할 때가 있었는데 여기서는 어디에다가 해야할지 모르겠다.
  • 꼭 파일 저장만 해야 하나. 결과를 가지고 스크립트 내에서 바로 처리할 수는 없나.
  • 크롤링을 아니지만 어떤 페이지를 스크랩할지 유동적이다. 예를 들어서 네이버뉴스 첫 화면에 실린 기사 링크를 스크래핑 하고 싶은데.

나도 원하는 결과물을 얻을 때까지 마찬가지의 생각을 했었다. 다만 scrapy를 처음 봤을 때는 다시 requests로 돌아가서 시간은 더 걸려도 자유롭게 작업하는 편을 택했는데, 결과적으로 시간이 오래걸릴 걸 알아서 시도하지 않는 작업을 하기 위해 scrapy로 돌아간 시행착오를 줄인다는데 이 글의 의미가 있다.

만만한 네이버를 상대로 저런 의문들을 해소할 글을 좀 더 써볼 생각이다.