Scrapy CrawlSpider 기반 자동 페이지 탐색 및 크롤링 구현

CrawlSpider 클래스 개요

CrawlSpider는 Scrapy의 기본 Spider 클래스를 상속받아 확장된 클래스입니다. 기본 Spider는 start_urls에 정의된 초기 URL만 크롤링하는 데 적합하지만, CrawlSpider는 추출된 링크를 자동으로 따라가며 탐색하는 규칙(Rule) 기반 메커니즘을 제공합니다. 대규모 웹사이트나 페이지네이션이 있는 사이트를 크롤링할 때 매우 효율적입니다. 아래 명령어를 통해 CrawlSpider 템플릿 기반의 스파이더 파일을 생성할 수 있습니다:
scrapy genspider -t crawl tech_jobs techcorp.com

내부 동작 원리

CrawlSpider의 핵심 동작 방식을 이해하기 위해 내부 로직을 요약한 추상화 코드를 살펴보겠습니다. 원본 소스코드의 구조와 변수명을 변경하여 재구성한 코드는 다음과 같습니다:
class AutoCrawler(BaseSpider):
    navigation_rules = ()

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._build_rules()

    def start_parse(self, response):
        # start_urls의 응답을 처리하며, 초기 콜백과 링크 추후(follow=True)를 설정
        return self._analyze_response(response, self.initial_parse, follow=True)

    def initial_parse(self, response):
        return []

    def _fetch_links(self, response):
        if not isinstance(response, HtmlResponse):
            return
        visited = set()
        # 정의된 규칙들을 순회하며 링크 추출
        for idx, nav_rule in enumerate(self._active_rules):
            found_links = [lnk for lnk in nav_rule.extractor.get_links(response) if lnk not in visited]
            if found_links and nav_rule.link_filter:
                found_links = nav_rule.link_filter(found_links)
            for lnk in found_links:
                visited.add(lnk)
                req = Request(url=lnk.url, callback=self._download_handler)
                req.meta.update(rule_idx=idx, link_txt=lnk.text)
                yield nav_rule.req_filter(req)

    def _download_handler(self, response):
        active_rule = self._active_rules[response.meta['rule_idx']]
        return self._analyze_response(response, active_rule.handler, active_rule.handler_args, active_rule.track)

    def _analyze_response(self, response, handler, handler_args, track=True):
        # 콜백 함수가 있으면 실행하여 Item 또는 Request 반환
        if handler:
            output = handler(response, **handler_args) or ()
            for result in output:
                yield result
        # follow 설정이 True면 추가 링크 추출 및 요청 생성
        if track and self._auto_follow:
            for req in self._fetch_links(response):
                yield req

LinkExtractor

LinkExtractor는 Response 객체에서 링크를 추출하는 역할을 수행합니다. extract_links() 메서드를 통해 매칭된 링크들을 반환합니다.
class scrapy.linkextractors.LinkExtractor(
    allow = (),
    deny = (),
    allow_domains = (),
    deny_domains = (),
    deny_extensions = None,
    restrict_xpaths = (),
    tags = ('a','area'),
    attrs = ('href'),
    canonicalize = True,
    unique = True,
    process_value = None
)
주요 파라미터:
  • allow: 지정된 정규표현식과 매칭되는 URL만 추출합니다. 비어있으면 모든 URL이 매칭됩니다.
  • deny: 해당 정규표현식과 매칭되는 URL을 제외합니다.
  • allow_domains: 링크 추출을 허용할 도메인 리스트입니다.
  • deny_domains: 링크 추출을 차단할 도메인 리스트입니다.
  • restrict_xpaths: XPath 표현식을 사용하여 특정 영역 내의 링크만 추출하도록 제한합니다. allow와 함께 사용할 수 있습니다.

Rule 객체

CrawlSpider의 rules 속성은 Rule 객체의 튜플을 포함합니다. 각 Rule은 링크 추출 및 후속 동작을 정의합니다. 여러 Rule이 동일한 링크와 매칭될 경우, 리스트에 정의된 순서상 첫 번째 Rule이 적용됩니다.
class scrapy.spiders.Rule(
        link_extractor, 
        callback = None, 
        cb_kwargs = None, 
        follow = None, 
        process_links = None, 
        process_request = None
)
주요 파라미터:
  • link_extractor: LinkExtractor 인스턴스를 지정하여 추출 대상 링크를 정의합니다.
  • callback: 추출된 링크의 Response를 처리할 콜백 함수입니다. 주의: CrawlSpider의 기본 동작을 오버라이드하는 parse 메서드를 콜백으로 사용하면 스파이더가 정상 작동하지 않으므로 반드시 다른 이름을 사용해야 합니다.
  • follow: 추출된 링크에서 다시 링크를 추출하여 탐색할지 여부를 결정합니다. callback이 None이면 기본값은 True, 지정되어 있으면 False입니다.
  • process_links: LinkExtractor가 추출한 링크 리스트를 필터링 또는 변형할 때 호출되는 스파이더 내부 메서드입니다.
  • process_request: Rule에 의해 생성된 각 Request 객체를 필터링할 때 호출되는 메서드입니다.

실전 예제: 채용 정보 크롤링

# items.py
import scrapy

class JobPostItem(scrapy.Item):
    job_title = scrapy.Field()
    detail_url = scrapy.Field()
    category = scrapy.Field()
    vacancies = scrapy.Field()
    workplace = scrapy.Field()
    post_date = scrapy.Field()
# pipelines.py
import json

class JsonExportPipeline:
    def open_spider(self, spider):
        self.file_handler = open("job_results.json", "w")

    def process_item(self, item, spider):
        serialized = json.dumps(dict(item), ensure_ascii=False) + "\n"
        self.file_handler.write(serialized)
        return item

    def close_spider(self, spider):
        self.file_handler.close()
# tech_jobs_spider.py
import scrapy
from scrapy.spiders import CrawlSpider, Rule
from scrapy.linkextractors import LinkExtractor
from myproject.items import JobPostItem

class TechJobCrawler(CrawlSpider):
    name = "tech_jobs"
    allowed_domains = ["hr.techcorp.com"]
    start_urls = ["http://hr.techcorp.com/positions.php?&start=0"]

    page_extractor = LinkExtractor(allow=(r"start=\d+"))

    rules = (
        Rule(page_extractor, callback="extract_job_data", follow=True),
    )

    def extract_job_data(self, response):
        for row in response.xpath("//tr[@class='even'] | //tr[@class='odd']"):
            item = JobPostItem()
            item['job_title'] = row.xpath("./td[1]/a/text()").get()
            item['detail_url'] = row.xpath("./td[1]/a/@href").get()
            item['category'] = row.xpath("./td[2]/text()").get()
            item['vacancies'] = row.xpath("./td[3]/text()").get()
            item['workplace'] = row.xpath("./td[4]/text()").get()
            item['post_date'] = row.xpath("./td[5]/text()").get()
            yield item

Scrapy 로깅 설정

settings.py 파일을 통해 크롤링 로그를 파일로 저장하고 레벨을 조정할 수 있습니다.
LOG_ENABLED = True
LOG_ENCODING = 'utf-8'
LOG_FILE = "scrapy_run.log"
LOG_LEVEL = "DEBUG"
LOG_STDOUT = False
로그 레벨은 다음 5단계로 구성됩니다:
  • CRITICAL: 심각한 오류
  • ERROR: 일반 오류
  • WARNING: 경고 메시지
  • INFO: 일반 정보 메시지
  • DEBUG: 디버깅용 상세 메시지

실전 예제: 민원 게시판 크롤링 (CrawlSpider vs Spider)

민원 게시판(http://wz.example.com/index.php/question/questionType?type=4&page=)의 모든 페이지에서 제목, 번호, 내용을 추출하는 두 가지 방식을 비교합니다.

데이터 모델 및 파이프라인

# items.py
class ComplaintItem(scrapy.Item):
    subject = scrapy.Field()
    ticket_id = scrapy.Field()
    body_text = scrapy.Field()
    source_link = scrapy.Field()
# pipelines.py
import codecs
import json

class UnicodeJsonPipeline:
    def open_spider(self, spider):
        self.file_stream = codecs.open("complaint_data.json", "w", encoding="utf-8")

    def process_item(self, item, spider):
        line = json.dumps(dict(item), ensure_ascii=False) + "\n"
        self.file_stream.write(line)
        return item

    def close_spider(self, spider):
        self.file_stream.close()

CrawlSpider 기반 구현

# civic_spider.py
import scrapy
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from myproject.items import ComplaintItem

class CivicForumCrawler(CrawlSpider):
    name = 'civic_crawl'
    allowed_domains = ['wz.example.com']
    start_urls = ['http://wz.example.com/index.php/question/type?type=4&page=']

    list_page_extractor = LinkExtractor(allow=(r"type=4"))
    detail_page_extractor = LinkExtractor(allow=(r"/html/question/\d+/\d+.shtml"))

    rules = (
        Rule(list_page_extractor, process_links="sanitize_links"),
        Rule(detail_page_extractor, callback="parse_complaint")
    )

    def sanitize_links(self, extracted_links):
        # 웹서버의 비정상 URL 파라미터 변형을 원래 형태로 치환
        for link in extracted_links:
            link.url = link.url.replace("?","&").replace("Type&","Type?")
        return extracted_links

    def parse_complaint(self, response):
        item = ComplaintItem()
        item['subject'] = response.xpath('//div[contains(@class, "pagecenter p3")]//strong/text()').get()
        item['ticket_id'] = item['subject'].split(' ')[-1].split(":")[-1]
        
        body = response.xpath('//div[@class="contentext"]/text()').getall()
        if not body:
            body = response.xpath('//div[@class="c1 text14_2"]/text()').getall()
            
        item['body_text'] = "".join(body).strip()
        item['source_link'] = response.url
        yield item

Spider 기반 수동 탐색 구현

# manual_spider.py
import scrapy
from myproject.items import ComplaintItem

class ManualForumCrawler(scrapy.Spider):
    name = 'manual_civic'
    allowed_domains = ['wz.example.com']
    base_url = 'http://wz.example.com/index.php/question/type?type=4&page='
    current_page = 0
    start_urls = [base_url + str(current_page)]

    def parse(self, response):
        post_links = response.xpath('//div[@class="greyframe"]/table//td/a[@class="news14"]/@href').getall()
        for url in post_links:
            yield scrapy.Request(url, callback=self.parse_complaint)

        if self.current_page <= 71160:
            self.current_page += 30
            yield scrapy.Request(self.base_url + str(self.current_page), callback=self.parse)

    def parse_complaint(self, response):
        item = ComplaintItem()
        item['subject'] = response.xpath('//div[contains(@class, "pagecenter p3")]//strong/text()').get()
        item['ticket_id'] = item['subject'].split(' ')[-1].split(":")[-1]
        
        body = response.xpath('//div[@class="contentext"]/text()').getall()
        if not body:
            body = response.xpath('//div[@class="c1 text14_2"]/text()').getall()
            
        item['body_text'] = "".join(body).strip()
        item['source_link'] = response.url
        yield item

태그: Scrapy crawl-spider web-scraping python link-extractor

8월 3일 04:47에 게시됨