데이터 분석, 시장 동향 파악, 특정 분야의 발전 양상 이해 등 다양한 목적으로 대량의 데이터를 수집해야 할 때가 있습니다. 수동으로 데이터를 모으는 것은 시간이 많이 들고 비효율적이며, 데이터의 정확성과 완전성을 보장하기 어렵습니다. 이러한 대규모 데이터 수집 작업을 빠르고 효율적으로 수행할 수 있는 방법은 없을까요?
파이썬은 풍부한 서드파티 라이브러리와 도구를 제공하며, 그중 웹 스크레이핑 라이브러리는 특히 강력합니다. 본 문서에서는 파이썬 웹 스크레이핑을 활용하여 하루 만에 수백만 건의 데이터를 수집하는 방법에 대해 설명합니다.
데이터 소스 정의
데이터 수집을 시작하기 전에 데이터 소스를 명확히 정의해야 합니다. 데이터 소스는 웹사이트, API, 데이터베이스 등 다양할 수 있습니다. 여기서는 웹사이트를 예로 들어 설명합니다.
예를 들어, 특정 전자상거래 웹사이트에서 상품명, 가격, 판매량, 리뷰 등 상품 정보를 수집한다고 가정해 봅시다. 먼저 해당 웹사이트의 URL과 페이지 구조를 파악해야 합니다. 웹 페이지 소스 코드를 분석하여 상품 정보가 HTML 태그 내에 저장되어 있으며, 각 상품이 고유한 URL을 가지고 있음을 확인할 수 있습니다. 따라서 HTML 태그와 URL 링크를 파싱하여 상품 정보를 수집할 수 있습니다.
기본 스크레이퍼 프로그램 구현
데이터 소스를 정의했다면 이제 스크레이퍼 프로그램을 작성할 차례입니다. 스크레이퍼 프로그램은 주로 다음 단계로 구성됩니다.
HTTP 요청 전송: 파이썬
requests라이브러리를 사용하여 HTTP 요청을 보내고 웹 페이지의 HTML 소스를 가져옵니다.import requests target_url = 'https://www.example.com/products' try: page_response = requests.get(target_url, timeout=10) page_response.raise_for_status() # HTTP 오류 발생 시 예외 발생 page_html_content = page_response.text except requests.exceptions.RequestException as e: print(f"Request failed: {e}") page_html_content = NoneHTML 콘텐츠 파싱: 파이썬
BeautifulSoup라이브러리를 활용하여 HTML 태그를 파싱하고 필요한 정보를 추출합니다. 실제 반환되는 콘텐츠 구조에 따라 분석 및 수정이 필요합니다.from bs4 import BeautifulSoup if page_html_content: soup_parser = BeautifulSoup(page_html_content, 'html.parser') product_listings = soup_parser.find_all('div', class_='product-card') extracted_data = [] for product in product_listings: item_name = product.find('h2', class_='product-name').text.strip() if product.find('h2', class_='product-name') else 'N/A' item_price = product.find('span', class_='price-value').text.strip() if product.find('span', class_='price-value') else 'N/A' item_sales_count = product.find('div', class_='sales-count').text.strip() if product.find('div', class_='sales-count') else 'N/A' item_rating_score = product.find('div', class_='star-rating').text.strip() if product.find('div', class_='star-rating') else 'N/A' extracted_data.append({ 'name': item_name, 'price': item_price, 'sales': item_sales_count, 'rating': item_rating_score }) # extracted_data를 활용하여 추가 작업 수행 (예: 데이터베이스 저장, 파일 저장)URL 링크 순회: 페이지네이션이 있는 경우, 여러 페이지에 걸쳐 상품 정보를 수집하기 위해 파이썬의
urllib.parse또는 직접 URL 조작을 통해 URL 링크를 순회합니다.import requests from bs4 import BeautifulSoup base_listing_url = 'https://www.example.com/category?page=' total_pages_to_crawl = 50 # 예시로 50페이지까지 all_collected_items = [] for page_num in range(1, total_pages_to_crawl + 1): current_page_url = base_listing_url + str(page_num) print(f"Crawling page: {current_page_url}") try: page_response = requests.get(current_page_url, timeout=10) page_response.raise_for_status() soup_obj = BeautifulSoup(page_response.text, 'html.parser') items_on_page = soup_obj.find_all('div', class_='product-summary') for item_elem in items_on_page: product_title = item_elem.find('a', class_='item-title').text.strip() product_price_str = item_elem.find('span', class_='display-price').text.strip() # 추가 정보 추출 및 저장 all_collected_items.append({'title': product_title, 'price': product_price_str}) except requests.exceptions.RequestException as e: print(f"Error crawling {current_page_url}: {e}") continue # 다음 페이지로 이동 # all_collected_items 리스트에 수집된 데이터가 저장됩니다.데이터 저장: 파이썬의
csv라이브러리를 사용하여 수집된 데이터를 CSV 파일에 저장합니다.import csv # 위에서 추출된 all_collected_items 리스트를 사용한다고 가정 output_filename = 'product_data_export.csv' if all_collected_items: # 헤더 추출 (첫 번째 항목의 키를 사용) field_names = all_collected_items[0].keys() with open(output_filename, 'w', newline='', encoding='utf-8') as csv_file: writer_obj = csv.DictWriter(csv_file, fieldnames=field_names) writer_obj.writeheader() # 헤더 쓰기 writer_obj.writerows(all_collected_items) # 모든 데이터 쓰기 print(f"Data successfully saved to {output_filename}") else: print("No data to save.")
스크레이퍼 효율성 향상
수백만 건의 데이터를 수집해야 할 경우, 단일 스크레이퍼 프로그램으로는 요구 사항을 충족하기 어려울 수 있습니다. 스크레이퍼의 효율성을 높이기 위해 다음과 같은 방법을 사용할 수 있습니다.
단일 머신 멀티스레딩: 멀티스레딩을 사용하면 여러 요청을 동시에 처리하여 스크레이퍼의 효율성을 높일 수 있습니다. 파이썬의
threading라이브러리로 멀티스레딩을 구현할 수 있습니다.import threading import requests from bs4 import BeautifulSoup import time def fetch_and_parse_page(page_url, result_list): """특정 URL에서 데이터를 가져와 파싱하고 결과 리스트에 추가합니다.""" try: res = requests.get(page_url, timeout=5) res.raise_for_status() parsed_soup = BeautifulSoup(res.text, 'html.parser') page_products = parsed_soup.find_all('div', class_='item-card') for product_entry in page_products: title_elem = product_entry.find('h3', class_='item-title') price_elem = product_entry.find('span', class_='item-price') title = title_elem.text.strip() if title_elem else 'Unknown Title' price = price_elem.text.strip() if price_elem else 'Unknown Price' result_list.append({'product_title': title, 'product_price': price, 'source_url': page_url}) print(f"Finished crawling: {page_url}") except requests.exceptions.RequestException as e: print(f"Error fetching {page_url}: {e}") except Exception as e: print(f"Parsing error on {page_url}: {e}") base_product_list_url = 'https://www.example.com/search?q=query&p=' total_pages_threaded = 20 # 예시로 20페이지 threaded_results = [] active_threads = [] start_time = time.time() for p_num in range(1, total_pages_threaded + 1): target_page_url = base_product_list_url + str(p_num) thread_instance = threading.Thread(target=fetch_and_parse_page, args=(target_page_url, threaded_results)) active_threads.append(thread_instance) thread_instance.start() for t in active_threads: t.join() # 모든 스레드가 완료될 때까지 대기 end_time = time.time() print(f"Total items collected: {len(threaded_results)}") print(f"Time taken: {end_time - start_time:.2f} seconds") # threaded_results 리스트에 모든 수집 데이터가 저장됩니다.분산 스크레이퍼: 여러 스크레이퍼 프로그램을 동시에 실행하여 다른 웹 페이지를 크롤링함으로써 효율성을 높일 수 있습니다. 파이썬의
Scrapy프레임워크는 분산 스크레이핑을 구현할 수 있습니다.Scrapy-Redis 설정 단계:
- 분산 프레임워크(
Scrapy-Redis또는Scrapy-RabbitMQ)를 설치합니다. Scrapy-Redis또는Scrapy-RabbitMQ연결 정보(예: Redis 서버 주소, 포트, 비밀번호 등)를 구성합니다.- Scrapy 프로젝트의
settings.py파일을 수정하여 다음 설정을 추가합니다.# settings.py 예시 # Scrapy-Redis 관련 설정 DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" SCHEDULER = "scrapy_redis.scheduler.Scheduler" SCHEDULER_PERSIST = True SCHEDULER_QUEUE_CLASS = "scrapy_redis.queue.SpiderPriorityQueue" # 또는 SpiderQueue, SpiderStack # Redis 연결 정보 REDIS_HOST = 'your_redis_server_ip' REDIS_PORT = 6379 REDIS_PASSWORD = 'your_redis_password_if_any' # 필요 없으면 주석 처리 또는 None REDIS_DB = 0 # 기타 Scrapy 설정 USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ROBOTSTXT_OBEY = True # 웹사이트의 robots.txt 규칙을 준수 DOWNLOAD_DELAY = 0.5 # 요청 간 지연 시간 (초) CONCURRENT_REQUESTS = 16 # 동시 요청 수 - 스파이더에서 Redis 또는 RabbitMQ URL을 지정하여 작업 분배를 구현합니다.
- Redis 또는 RabbitMQ 서비스를 시작합니다.
- 다음 명령을 사용하여 여러 스크레이퍼 노드를 시작합니다.
여기서scrapy crawl your_spider_name -s JOBDIR=crawls/node_1 scrapy crawl your_spider_name -s JOBDIR=crawls/node_2 # 필요에 따라 더 많은 노드 실행your_spider_name은 스파이더 이름이며,-s JOBDIR=crawls/node_1은 중단된 지점부터 계속 크롤링하는 기능을 활성화하는 명령입니다. - 별도의 터미널에서 다음 명령을 사용하여 초기 URL을 Redis 큐에 추가합니다.
# Redis 큐에 시작 URL을 추가하는 스크립트를 실행하거나 # scrapy-redis-cli를 사용합니다 (Scrapy-Redis 설치 시 포함) # 예시: scrapy-redis-cli queue your_spider_name:start_urls < url_list.txt # 또는 Python 스크립트로 직접 Redis에 추가
Scrapy를 사용한 분산 스크레이핑 예시:
- Scrapy 프로젝트를 생성하고 위에서 언급된 설정으로
settings.py를 구성합니다. - 스파이더 파일에서 크롤링할 URL 큐를 정의합니다.
여기서import scrapy from scrapy_redis.spiders import RedisSpider class ProductCrawlerSpider(RedisSpider): name = 'product_crawler' redis_key = 'product_crawler:start_urls' # Redis에서 시작 URL을 가져올 키 def parse(self, response): """ 웹 페이지 응답을 파싱하여 상품 데이터를 추출합니다. """ product_cards = response.xpath('//div[contains(@class, "product-item")]') for card in product_cards: item_data = {} item_data['product_id'] = card.xpath('.//span[@class="id-code"]/text()').get() item_data['name'] = card.xpath('.//h3[@class="product-title"]/a/text()').get() item_data['price'] = card.xpath('.//div[@class="current-price"]/text()').get() item_data['reviews'] = card.xpath('.//span[@class="review-count"]/text()').get() # 상세 페이지 링크가 있다면 follow detail_page_link = card.xpath('.//h3[@class="product-title"]/a/@href').get() if detail_page_link: yield response.follow(detail_page_link, callback=self.parse_detail, meta={'item': item_data}) else: yield item_data # 상세 페이지가 없는 경우 바로 반환 def parse_detail(self, response): """ 상품 상세 페이지를 파싱하여 추가 데이터를 추출합니다. """ item = response.meta['item'] # 상세 페이지에서 추가 정보 추출 item['description'] = response.xpath('//div[@class="product-description"]/text()').get() item['brand'] = response.xpath('//span[@class="product-brand"]/text()').get() yield itemRedisSpider를 상속받았고,redis_key를product_crawler:start_urls로 설정하여 Redis에서 시작 URL을 가져오도록 지정했습니다.parse메서드에서는 XPath를 사용하여 필요한 정보를 추출하고, 상세 페이지로 이동하기 위한response.follow도 사용했습니다.parse_detail메서드는 상세 페이지에서 추가 정보를 추출합니다. - Redis 서비스를 시작하고 크롤링할 URL을 큐에 추가합니다.
여기서는 Redis의 파이썬 클라이언트 라이브러리import redis redis_client = redis.Redis(host='localhost', port=6379, db=0) # 크롤링할 시작 URL 목록을 Redis 큐에 추가 for i in range(1, 101): # 예시로 100페이지 start_url_to_add = 'https://www.example.com/category/all?page=' + str(i) redis_client.lpush('product_crawler:start_urls', start_url_to_add) print("Initial URLs added to Redis queue.")redis를 사용하여 시작 URL을product_crawler:start_urls큐에 추가합니다. settings.py에 데이터 저장 설정을 추가합니다.
여기서는 Scrapy 자체의 JSON Lines 출력기를 사용하여 데이터를# settings.py 예시 (이전 내용에 추가) FEED_FORMAT = 'jsonlines' # CSV, JSON, XML 등 다양한 형식 가능 FEED_URI = 'collected_products_%(time)s.jsonl' # 파일명에 타임스탬프 추가 FEED_EXPORT_ENCODING = 'utf-8'collected_products_YYYY-MM-DDTHH-MM-SS.jsonl파일에 저장하도록 설정했습니다. (CSV도 가능하지만 JSON Lines가 대규모 데이터에 더 유연합니다)- 여러 스크레이퍼 노드를 시작합니다.
여기서는 두 개의 스크레이퍼 노드를 시작했으며,# 터미널 1 scrapy crawl product_crawler -s JOBDIR=crawls/product_crawler-node1 # 터미널 2 scrapy crawl product_crawler -s JOBDIR=crawls/product_crawler-node2 # 필요한 만큼 더 많은 터미널에서 실행-s JOBDIR매개변수를 사용하여 각 노드가 중단 지점부터 크롤링을 재개할 수 있도록 합니다. - (선택 사항) 크롤링 진행 상황 모니터링 및 Redis 큐 확인:
Redis CLI 또는
redis-py를 사용하여product_crawler:start_urls큐의 길이 및 처리된 항목 수를 확인할 수 있습니다.# Redis CLI에서 LLEN product_crawler:start_urls
기술 요약
위에서 설명한 방법들을 통해 대량의 데이터를 빠르고 효율적으로 수집할 수 있습니다. 물론 웹 스크레이퍼 프로그램을 작성할 때는 웹사이트의
robots.txt규칙을 준수하고, IP 차단을 방지하는 등의 고려 사항에 주의해야 합니다. 또한 수집된 데이터를 클리닝, 분석 및 시각화하여 더욱 가치 있는 정보를 얻을 수 있습니다.- 분산 프레임워크(