웹 스크래퍼는 세 가지 방식으로 실패합니다: JavaScript가 페이지를 렌더링하여 HTML이 비어 있거나, 프론트엔드 업데이트 후 CSS 선택자가 일치하지 않거나, Cloudflare 같은 안티봇 제품에 의해 요청이 차단됩니다. Scrapling은 이 세 가지를 모두 처리하는 오픈소스 Python 라이브러리입니다. 이 가이드는 실제 웹사이트에서 각 부분을 보여주며, 프로덕션 규모에서 관리형 프록시 서비스가 필요한 시점도 다룹니다.
TL;DR
Scrapling은 세 가지 페처 클래스(HTTP, Chromium, 스텔스 Firefox), 클래스 이름 변경 후에도 요소를 재탐색하는 적응형 파서, Scrapy 스타일의 스파이더를 하나의 Python 라이브러리로 통합하여 프로덕션 스크래핑을 지원합니다.
- 작동하는 가장 저렴한 페처를 선택하고, 봇 보호 사이트에는 StealthyFetcher로 단계적으로 전환하세요.
- 적응형 선택자는 알려진 정상 페이지를 먼저 핑거프린팅하면 마크업 변경 후에도 복구됩니다.
- 프로덕션에서는 체크포인팅과 빈 결과 알람이 있는 Spider에 파싱 로직을 래핑하세요.
- 로컬 스텔스가 한계에 달하면(IP 평판, 엔터프라이즈 안티봇 제품), 주거용 프록시나 관리형 언블로킹 엔드포인트로 전환하세요.
이미 requests + BS4가 있는데 왜 Scrapling인가?
requests와 BeautifulSoup의 조합은 안정적인 마크업을 가진 정적 페이지에서는 여전히 잘 작동합니다. 문제는 스크래퍼를 계속 동작하도록 배포하는 순간부터 시작됩니다.
프론트엔드 팀이 요소의 이름을 바꾸거나 재구성하면 선택자가 일치하지 않습니다. 이번 분기에는 서버 사이드로, 다음 분기에는 클라이언트 사이드로 페이지가 렌더링됩니다. 1년 동안 스크래핑하던 사이트가 갑자기 Cloudflare Bot Management를 추가하면 모든 요청이 챌린지 페이지를 반환합니다.
이 중 어느 것도 드문 문제가 아니지만, 각각 고유한 수정이 필요합니다. 이러한 수정들을 requests 스크립트에 결합하면 취약한 try/except 블록과 선택자 폴백의 집합이 됩니다. (선택자가 계속 변경되는 소량 작업의 경우, 렌더링된 HTML에 대한 LLM 추출 패스가 이제 실행 가능한 대안입니다. Scrapling은 페이지당 비용이 중요하고 대규모로 렌더링할 때 설정 비용을 정당화합니다.)
Scrapling은 일반적인 수정 사항을 하나의 라이브러리로 통합합니다:
- 세 가지 페처, 하나의 API. TLS 핑거프린트 모방 기능이 있는 빠른 HTTP 클라이언트(Fetcher), Playwright 기반 브라우저(DynamicFetcher), 일반적인 자동화 신호를 마스킹하는 패치된 Firefox 빌드인 Camoufox 기반 스텔스 브라우저(StealthyFetcher). 모두 동일한 파서 객체를 반환하므로 페처를 변경해도 선택자 코드를 재작성할 필요가 없습니다.
- 마크업 변경에서 살아남는 선택자. 첫 번째 실행에서 요소의 구조적 핑거프린트를 저장하면, 이후 실행에서 클래스, ID, 또는 위치가 변경되어도 Scrapling이 동일한 요소를 찾을 수 있습니다.
- 내장 Spider 프레임워크. 동시 요청, 도메인별 스로틀링, 일시 정지 및 재개, robots.txt 준수, JSON/JSONL 내보내기가 모두 내장되어 있습니다.
- 내장 프록시 로테이션. ProxyRotator 헬퍼가 모든 세션 유형과 통합되며, 요청별 재정의를 지원합니다.
세 가지 페처는 세 가지 난이도 수준에 매핑되므로, 대상을 확인하면 어떤 것을 사용할지 결정이 대개 명확합니다:
| 페이지 유형 | 사용할 페처 | 요청당 비용 (시간, 메모리) |
|---|---|---|
| 정적 HTML, 안티봇 없음 | Fetcher | 밀리초, 브라우저 없음 |
| JavaScript 렌더링, 안티봇 없음 | DynamicFetcher | 초 단위, Chromium 메모리 |
| Cloudflare 또는 유사한 안티봇 뒤에 있음 | StealthyFetcher | 초 단위, Camoufox 메모리 |
Scrapling의 파서는 Parsel 및 lxml과 거의 동일한 속도이며, 대용량 문서에서는 BeautifulSoup보다 빠릅니다. 5,000개 요소 문서의 경우, 공식 벤치마크에서 약 2ms인 반면 bs4 + lxml은 1.5초 이상입니다. 소규모에서는 중요하지 않지만, 월 수백만 페이지를 파싱하면 차이가 누적됩니다.
스크래핑 라이브러리를 선택하기 전에 빠른 확인을 하세요: 대상이 공식 API, RSS 또는 Atom 피드, 사이트맵, JSON-LD 임베드, 또는 공개 데이터 덤프를 노출하는지 확인하세요. 이것들이 있다면 API 호출이 스크래핑보다 빠르고 저렴합니다. 스크래핑은 API가 없거나, API가 유료이거나 사용 사례가 감당할 수 없는 속도 제한이 있거나, 필요한 데이터가 API에 노출되지 않을 때 올바른 답입니다.
Scrapling이 모든 것에 적합한 도구는 아닙니다:
- 분산 클러스터 규모에서는 Scrapy 클러스터와 프레임워크별 분산 실행기가 더 잘 확장됩니다.
- curl에 상응하는 스크래핑으로 requests와 5줄 BeautifulSoup 선택자로 처리되는 경우, 그것들을 사용하세요.
- 코드 없이 관리형 스크래퍼가 필요한 경우, 노코드 플랫폼이 더 적합합니다.
가장 적합한 경우는 매주 계속 작동해야 하는 프로덕션 스크래퍼입니다: 유지 관리가 중요할 만큼 복잡하지만, 클러스터가 필요할 만큼은 아닌 경우입니다.
Scrapling은 BSD-3 라이선스이며, 이 가이드는 v0.4.7(2026년 4월)에 대해 검증되었습니다. 가이드에 사용된 API 이름은 안정적입니다; 기본값이 다를 경우 최신 릴리스의 변경 로그를 확인하세요. 타입 힌트는 공개 API를 포함하며, 응답을 타입이 지정된 파이프라인을 통해 전달하는 경우 중요합니다.
Scrapling 설치
페처 종속성은 명시적으로 선택해야 하므로, 파서만 필요한 머신에 Playwright와 Camoufox를 설치하지 않아도 됩니다. 페처 extras와 브라우저 바이너리와 함께 설치하세요:
# pip
pip install "scrapling[fetchers]"
# or with uv (faster, lockfile-aware)
uv pip install "scrapling[fetchers]"
scrapling install
첫 번째 명령은 라이브러리와 HTTP 및 브라우저 페처를 설치합니다. 두 번째는 브라우저 바이너리(StealthyFetcher용 Camoufox, DynamicFetcher용 Chromium)와 필요한 시스템 종속성을 다운로드합니다. Windows에서는 바이너리가 시스템 전체에 설치될 수 있도록 처음에 관리자 권한으로 터미널을 실행하세요.
모든 것이 올바르게 설치되었는지 확인하려면:
from scrapling.fetchers import Fetcher
page = Fetcher.get('https://httpbin.org/headers')
print(page.status, page.json()['headers']['User-Agent'])
올바르게 설치되면 200과 Chrome 스타일의 User-Agent 문자열이 출력됩니다. User-Agent가 python-requests/x.x처럼 보이면 파서 전용 빌드를 실행 중인 것입니다. curl_cffi(Fetcher의 TLS 모방을 제공하는 라이브러리)도 pip가 설치하도록 [fetchers] extra와 함께 재설치하세요.
알아두면 유용한 두 가지 extras:
- scrapling[shell]은 대화형 IPython 셸(scrapling shell), curl-to-Scrapling 변환기, 터미널에서 한 줄로 콘텐츠를 가져오는 scrapling extract CLI를 추가합니다. 예를 들어, scrapling extract get https://example.com out.md는 페이지(또는 CSS 선택자 하위 집합)를 Markdown으로 씁니다.
- scrapling[all]은 AI 에이전트 통합을 위한 MCP(Model Context Protocol) 서버를 포함한 모든 것을 설치합니다; 프로젝트 문서를 참조하세요.
scrapling[fetchers]는 아래의 모든 예제를 포함합니다.
첫 번째 스크래핑: 정적 페이지에서 인용문 추출
표준 샌드박스는 quotes.toscrape.com으로, 일반 서버 렌더링 HTML로 페이지당 10개의 인용문을 렌더링합니다. JavaScript도, 안티봇도, 속도 제한도 없으므로 Fetcher 경로의 첫 번째 테스트로 좋습니다:
from scrapling.fetchers import Fetcher
page = Fetcher.get('https://quotes.toscrape.com/', stealthy_headers=True)
for quote in page.css('.quote'):
text = quote.css('.text::text').get()
author = quote.css('.author::text').get()
tags = quote.css('.tag::text').getall()
print(f"{author}: {text[:60]}... [{', '.join(tags)}]")
Fetcher.get()은 파서 핸들로도 작동하는 Response 객체를 반환합니다. stealthy_headers=True를 설정하면 Scrapling이 기본 python-requests 헤더 세트 대신 User-Agent, Accept, Accept-Language, sec-ch-ua를 포함한 실제 브라우저 헤더를 전송합니다. 샌드박스에서는 불필요하지만, 프로덕션 사이트는 종종 헤더 일관성으로 필터링합니다.
page.css(‘.quote’)는 일치하는 모든 요소의 Selectors 컨테이너를 반환합니다. ::text 의사 요소는 주변 태그 대신 텍스트 노드를 직접 추출하는 Scrapy/Parsel 규칙입니다.
출력은 다음과 같습니다:
Albert Einstein: "The world as we have created it is a process of our t... [change, deep-thoughts, thinking, world]
J.K. Rowling: "It is our choices, Harry, that show what we truly are,... [abilities, choices]
Albert Einstein: "There are only two ways to live your life. One is as t... [inspirational, life, live, miracle, miracles]
...
Scrapy를 사용해본 적이 있다면 API가 의도적으로 친숙하게 설계되어 있습니다. BeautifulSoup을 사용해본 적이 있다면 Scrapling에도 find_all과 find_by_text가 있습니다:
quotes = page.find_all('div', class_='quote')
einstein = page.find_by_text('Einstein', partial=True)
실제 대상 스크래핑: Hacker News 프론트 페이지
샌드박스 사이트는 연습용일 뿐입니다. 두 가지 변경 사항과 함께 동일한 코드 구조가 실제 대상에서도 작동합니다: 선택자는 실제 마크업을 검사하여 가져오고, 데이터는 더 많은 정리가 필요합니다. Hacker News는 첫 번째 실제 대상으로 유용하며(안정적인 HTML, 안티봇 없음), 알아둘 만한 특이한 구조가 있습니다: 각 스토리는 행이며, 메타데이터(포인트, 사용자, 나이)는 바로 다음 형제 행에 있습니다. 스크래퍼:
from scrapling.fetchers import Fetcher
page = Fetcher.get('https://news.ycombinator.com/', stealthy_headers=True)
stories = []
for athing in page.css('tr.athing'):
title = athing.css('.titleline a::text').get()
href = athing.css('.titleline a::attr(href)').get()
rank = athing.css('.rank::text').get()
# Metadata lives on the next sibling row
subline = athing.next.css('.subline')
points_text = subline.css('.score::text').get() or '0 points'
user = subline.css('.hnuser::text').get()
age = subline.css('.age a::text').get()
stories.append({
'rank': int(rank.rstrip('.')) if rank else None,
'title': title,
'url': href,
'points': int(points_text.split()[0]),
'user': user,
'age': age,
'id': athing.attrib.get('id'),
})
print(f"scraped {len(stories)} stories")
for s in stories[:3]:
print(f" {s['rank']}. [{s['points']:>4}] {s['title'][:55]} by {s['user']}")
이 스니펫은 샌드박스 예제에서 보여주지 않는 세 가지 패턴을 사용합니다:
- athing.next는 다음 형제 요소로 이동하며, 구조적으로 관련된 행이 데이터를 공유할 때 유용합니다(이전 테이블 기반 마크업에서 흔한 패턴).
- .attrib.get(‘id’)는 편리한 ::attr() 단축키가 없을 때 원시 HTML 속성을 읽습니다.
- or ‘0 points’ 기본값은 Hacker News 프론트 페이지에 점수 없이 나타나는 구인 게시물을 처리합니다.
실제 대상에는 거의 항상 이러한 소소한 불규칙성(누락된 필드, 혼합 항목 유형, 가끔 잘못된 형식의 행)이 있습니다. 선택자를 조정하고 작은 기본값을 추가하면 코드 구조는 동일하게 유지됩니다.
find_similar을 사용하여 선택자 없이 스크래퍼 작성
때로는 행 선택자를 작성할 필요도 없습니다. 보이는 텍스트에서 시작하여 올바른 컨테이너로 이동하고, Scrapling이 구조적으로 유사한 모든 요소를 찾도록 하세요:
sample = page.find_by_text("1.") # the rank label on story #1
row = sample.find_ancestor(lambda e: e.tag == "tr") # walk up to the story row
peers = row.find_similar() # find every similar row
print(f"Found {len(peers) + 1} story rows without writing a CSS selector for the row")
실제 프론트 페이지에서 이는 30을 출력합니다(우리가 시작한 것과 구조적 유사성으로 위치한 모든 스토리 행). find_similar는 선택적 similarity_threshold(기본값 0.2; 낮은 값은 더 엄격한 구조적 일치를 의미)와 ignore_attributes 목록(기본값은 href와 src)을 받으므로 URL 차이가 일치를 방해하지 않습니다. 선택자를 유지하는 것보다 마크업이 더 빠르게 변경되는 사이트의 경우, find_by_text와 find_similar를 결합하는 것이 클래스 이름을 쫓는 것보다 더 잘 유지됩니다.
테이블 추출: Wikipedia의 국가 데이터
테이블은 실제 데이터의 또 다른 일반적인 형태입니다: 재무 수치, 스포츠 통계, 참조 목록. Wikipedia는 백과사전 전체에서 일관된 단일 table.wikitable 클래스로 데이터 테이블을 제공하므로, 거의 모든 곳에서 동일한 선택자 패턴이 작동합니다. 국가 인구 스크래핑:
from scrapling.fetchers import Fetcher
URL = 'https://en.wikipedia.org/wiki/List_of_countries_by_population_(United_Nations)'
page = Fetcher.get(URL, stealthy_headers=True)
table = page.css('table.wikitable')[0]
countries = []
for row in table.css('tbody tr'):
cells = row.css('td')
if len(cells) < 3: # skip header and grouping rows
continue
name = cells[0].css('a::attr(title)').get()
pop_text = cells[1].text.strip()
if not name or not pop_text:
continue
countries.append({
'country': name,
'population': int(pop_text.replace(',', '')),
})
print(f"scraped {len(countries)} country rows")
top = sorted(countries, key=lambda c: c['population'], reverse=True)[:3]
for c in top:
print(f" {c['country']:<20} {c['population']:>15,}")
두 가지 패턴이 중요합니다. cells[0].css(‘a::attr(title)’).get()은 링크의 title 속성에서 국가 이름을 추출하는데, 동일한 셀의 국기 아이콘 혼잡을 건너뛰기 때문에 .text보다 더 깔끔합니다. if len(cells) < 3 가드는 거의 모든 서드파티 HTML 테이블에 나타나는 불규칙한 헤더와 그룹화 행을 건너뜁니다.
사이트 변경에서 살아남는 선택자
사이트가 클래스를 .product-card에서 .product-tile로 이름을 바꿉니다. 스크래퍼가 빈 결과를 반환하기 시작합니다. 파이프라인의 이후 단계에서 누락된 데이터를 보고할 때까지 알아차리지 못합니다.
Scrapling의 답은 하나의 구성 옵션과 두 개의 플래그입니다. 각각 하나의 기능을 수행합니다:
| 작성하는 내용 | 작성 시점 | 수행하는 작업 |
|---|---|---|
| 페처 호출에 selector_config={‘adaptive’: True} | 항상 (첫 번째 및 이후 실행) | 기능을 켭니다. 이것 없이는 Scrapling이 다른 두 플래그를 자동으로 무시합니다. |
| .css()에 auto_save=True | 첫 번째 실행 | 일치한 요소의 구조적 핑거프린트(태그, 속성, 위치, 주변 텍스트)를 작은 로컬 SQLite 파일에 기록합니다. |
| .css()에 adaptive=True | 이후 실행 | 선택자가 아무것도 반환하지 않으면 저장된 핑거프린트를 사용하여 요소를 다시 찾습니다. |
처음부터 끝까지의 수명 주기:

코드로 표현하면:
from scrapling.fetchers import Fetcher
# First run: enable adaptive on the fetcher, save fingerprints with auto_save
page = Fetcher.get(
'https://quotes.toscrape.com/',
selector_config={'adaptive': True},
)
quotes = page.css('.quote', auto_save=True)
print(f"Found {len(quotes)} quotes on first run")
# Later run: same selector, plus adaptive=True for the fallback path.
# If the site renamed `.quote`, the fingerprint recovers the elements.
page = Fetcher.get(
'https://quotes.toscrape.com/',
selector_config={'adaptive': True},
)
quotes = page.css('.quote', adaptive=True)
print(f"Found {len(quotes)} quotes (recovered via fingerprint if needed)")
핑거프린트 데이터베이스는 스크립트 옆에 저장되므로 동일한 스크립트가 실행 간에 저장된 핑거프린트를 재사용합니다. 이 패턴은 모든 페처에서 동일하게 작동합니다: 페치 호출에서 selector_config를 한 번 전달하고, .css() 호출에서 auto_save와 adaptive를 사용하세요.
핑거프린트 파일을 마이그레이션 아티팩트처럼 취급하세요: 재현 가능한 CI 실행을 위해 커밋하고, Docker에서 볼륨으로 마운트하고, 확인하지 않은 페이지에 auto_save=True를 절대 실행하지 마세요. auto_save로 스크래핑된 CAPTCHA 벽은 핑거프린트를 오염시켜 이후 실행에서 잘못된 요소를 복구합니다. 파일을 삭제하여 재설정하세요.
제한 사항: 적응형 매칭은 요소의 콘텐츠가 대략 안정적으로 유지되고 마크업만 변경될 때만 작동합니다. 사이트가 전체 섹션을 다른 기능으로 교체하면 어떤 알고리즘도 복구할 수 없습니다. 핑거프린트가 처리할 수 없는 방식으로 사이트가 변경될 때 알아차릴 수 있도록 빈 결과 세트에 대한 알림을 유지하세요.
JavaScript 렌더링 페이지 스크래핑
많은 사이트가 거의 빈 HTML 스켈레톤을 보내고 실제 콘텐츠를 클라이언트 사이드에서 렌더링합니다. 이를 위한 표준 테스트 페이지는 quotes.toscrape.com/js로, 정적 버전과 동일한 인용문을 제공하지만 JavaScript를 통해 주입합니다. Fetcher를 이 페이지에 지정하면 결과는 예측 가능합니다:
from scrapling.fetchers import Fetcher
page = Fetcher.get('https://quotes.toscrape.com/js/')
print(page.css('.quote::text').getall())
# []
비어 있습니다. 텍스트는 브라우저가 페이지 로드 시 실행하는 var data = […] JavaScript 변수에 저장되어 있으며, 기본 HTTP 클라이언트는 그 스크립트를 실행하지 않습니다. 수정 방법은 내부적으로 실제 Chromium 인스턴스를 제어하는 DynamicFetcher를 사용하는 것입니다:
from scrapling.fetchers import DynamicFetcher
page = DynamicFetcher.fetch(
'https://quotes.toscrape.com/js/',
headless=True,
network_idle=True,
)
for quote in page.css('.quote'):
print(quote.css('.text::text').get())
이 스니펫에서 두 가지 플래그가 중요합니다. headless=True는 서버에서 원하는 것입니다. network_idle=True는 파서가 페이지를 읽기 전에 네트워크 활동이 중지될 때까지 기다리며, 대부분의 JavaScript 렌더링 페이지를 포착합니다. 하이드레이션이 많은 SPA(Next.js, Remix, SvelteKit)에서는 React가 여전히 하이드레이션 중인 동안 네트워크가 유휴 상태가 될 수 있습니다; 그런 경우 대신 또는 추가로 알려진 안정적인 요소와 함께 wait_selector=”…”를 전달하세요.
브라우저가 페이지를 가져오면 나머지 API는 정적 Fetcher 예제와 동일합니다.
각 브라우저 세션은 약 1GB 상주 메모리를 사용합니다(프로덕션 확장 섹션에 세부 내역이 있습니다). 하루에 수백 페이지의 경우 2GB 워커로 처리할 수 있습니다; 하루에 수만 건을 넘어서면 DynamicSession으로 요청 간에 브라우저를 재사용하거나, 자체 서버 외부에서 실행되는 관리형 스크래핑 브라우저로 작업을 이전하세요.
StealthyFetcher로 안티봇 방어 우회
Cloudflare Turnstile, DataDome, HUMAN Bot Defender(이전 PerimeterX)와 같은 현대적인 안티봇 제품은 요청이 실제 브라우저에서 오는지 판단하기 위해 수십 가지 신호를 확인합니다. 목록에는 TLS 핸드셰이크 핑거프린트(JA3와 JA4가 일반적인 형식), HTTP/2 프레임 순서, navigator 속성(navigator.webdriver, 플러그인 목록, 언어 헤더), 캔버스 및 WebGL 해시, 타이밍 패턴이 포함됩니다. 이러한 정적 확인이 통과되면 종종 행동 레이어가 이어집니다(마우스 이동 엔트로피, 스크롤 리듬, 체류 시간). 기본 Playwright 또는 Selenium 세션은 기본적으로 이 중 여러 가지를 노출하는데, 이것이 스크래핑 포럼에서 “Playwright를 추가했는데 여전히 차단됩니다”가 자주 묻히는 질문인 이유입니다.

녹색 레이어는 StealthyFetcher의 Camoufox 베이스가 자체적으로 처리하는 것이고, 노란색은 행동 점수가 시작되고 관리형 언블로킹이 비용을 정당화하는 곳입니다.
StealthyFetcher는 이러한 시스템이 확인하는 헤드리스 브라우저 및 Playwright 핑거프린트를 무력화하기 위해 일반적인 자동화 신호를 마스킹하는 패치된 Firefox 빌드인 Camoufox를 사용합니다. Cloudflare의 가벼운 Bot Management 계층의 경우, 그것만으로도 충분한 경우가 많습니다. 행동 점수와 Turnstile을 결합하는 엔터프라이즈급 배포는 여전히 로컬 스텔스 설정을 차단합니다; 그것이 관리형 언블로킹이 실용적인 답이 되는 곳입니다(프로덕션 확장 섹션에서 다룹니다). Turnstile 챌린지를 명시적으로 실행하는 사이트의 경우, Scrapling에는 챌린지를 자동으로 통과하는 solve_cloudflare 플래그가 있습니다:
from scrapling.fetchers import StealthyFetcher
page = StealthyFetcher.fetch(
'https://nopecha.com/demo/cloudflare',
headless=True,
solve_cloudflare=True,
network_idle=True,
)
links = page.css('#padded_content a::attr(href)').getall()
print(f"Found {len(links)} links past the challenge")
이 예제의 페이지는 실제 Turnstile 챌린지를 실행하는 공개 Cloudflare 데모입니다.
몇 가지 실제 제한 사항을 기억해 두세요:
- solve_cloudflare 경로는 관리형 Turnstile 챌린지에 작동합니다. 모든 CAPTCHA 카테고리를 처리한다고 보장하지는 않습니다. 이미지 그리드 챌린지(이전 reCAPTCHA, hCaptcha 이미지 퍼즐)는 서드파티 솔버 서비스(2Captcha, CapSolver)를 페이지 액션에 연결하거나, 챌린지 레이어를 종단 간으로 처리하는 관리형 언블로킹 엔드포인트가 필요합니다.
- 스텔스 우회 기술은 자주 변경됩니다. 일회성 설정이 아닌 실제 대상에 대한 주기적인 검증을 계획하세요.
- 결과는 IP 평판에도 달려 있습니다. 대상에서 이미 플래그된 데이터센터 IP는 브라우저 핑거프린트가 아무리 좋아도 성공하지 못합니다.
Cloudflare를 사용하지 않는 사이트의 경우, 챌린지 솔버 없이 스텔스 이점을 얻으세요:
page = StealthyFetcher.fetch('https://example.com', headless=True)
기본 핑거프린팅 보호가 적용되며, 해결할 챌린지가 없으면 solve_cloudflare는 아무것도 하지 않습니다.
알아야 할 패턴: 숨겨진 차단
안티봇 시스템은 때때로 명시적인 403이나 503 대신 위장된 차단 페이지(CAPTCHA 벽, 빈 결과 페이지, 또는 “당신이 인간인지 확인 중” 인터스티셜)가 있는 200 OK를 반환합니다. 빈 결과 확인(프로덕션 준비 스크립트에 표시됨)은 명백한 경우를 포착합니다. 구조는 그대로이고 데이터만 잘못된 숨겨진 차단의 경우, 콘텐츠 수준 확인이 필요합니다: 응답 길이를 기준과 비교하거나, 본문에서 특징적인 문자열(“captcha”, “are you human”, “access denied”)을 찾거나, 알려진 안정적인 항목의 예상 필드를 샘플링하는 방법이 있습니다. 어느 것도 완벽하지 않지만, 함께 사용하면 잘못된 데이터가 다운스트림으로 흐르기 전에 대부분의 자동 차단을 포착합니다.
Spider 프레임워크는 이를 위한 is_blocked 훅을 노출합니다: 재정의(역시 async def)하면 Scrapling이 차단된 응답을 max_blocked_retries(기본값 3)까지 자동으로 재시도합니다:
class MySpider(Spider):
max_blocked_retries = 5
async def is_blocked(self, response: Response) -> bool:
body = (response.body or b'').lower()
return b'are you human' in body or b'captcha' in body
차단 재시도 횟수는 크롤 후 result.stats.blocked_requests_count에 나타납니다. 이 카운터를 프로덕션 알림 지표로 사용하세요.
FetcherSession으로 로그인이 필요한 페이지 스크래핑
실제 대상은 종종 로그인이 필요합니다. FetcherSession의 패턴은 Scrapling의 파서가 응답을 처리하는 것을 제외하고 requests + Session으로 작성할 표준 CSRF + 쿠키 흐름입니다. Quotes-to-Scrape 샌드박스에는 /login에 작동하는 로그인이 포함되어 있어 간단한 테스트 케이스가 됩니다:
from scrapling.fetchers import FetcherSession
with FetcherSession(impersonate='chrome') as session:
# 1. GET the login page to grab the CSRF token
login_page = session.get('https://quotes.toscrape.com/login')
csrf = login_page.css('input[name="csrf_token"]::attr(value)').get()
# 2. POST credentials. Cookies persist on the session automatically.
session.post(
'https://quotes.toscrape.com/login',
data={'csrf_token': csrf, 'username': 'demo', 'password': 'demo'},
)
# 3. Fetch a page that's gated behind the login.
page = session.get('https://quotes.toscrape.com/')
if page.css('a[href="/logout"]').get():
print("Logged in OK")
# Logged-in pages on this sandbox show extra Goodreads links per quote
print("first goodreads link:", page.css('a[href*="goodreads"]::attr(href)').get())
여기서 세 가지가 중요합니다:
- 개별 Fetcher.get() 호출이 아닌 세션을 사용하세요. FetcherSession은 요청 간에 쿠키(및 서버가 반환하는 모든 Set-Cookie)를 유지합니다; 개별 Fetcher.get() 호출은 상태를 공유하지 않습니다.
- 로그인 폼에서 CSRF 토큰을 읽으세요. 대부분의 최신 프레임워크에는 토큰이 포함되어 있으며 없는 POST 요청을 거부합니다. 필드 이름은 프레임워크에 따라 다릅니다: Django는 csrfmiddlewaretoken, Rails는 authenticity_token을 사용하며, 많은 SPA는 대신 헤더에서 토큰을 전송하므로 이름을 가정하기 전에 폼을 검사하세요.
- 진행하기 전에 로그인 성공을 확인하세요. 로그아웃 링크, 네비게이션 바의 사용자 이름, 또는 로그인 폼의 부재를 확인하세요. 오류 없이 로그인이 실패하고 공개 페이지를 스크래핑하면 올바르게 보이지만 실제로는 잘못된 데이터를 얻습니다.
2FA, OAuth, 또는 장기 토큰을 발행하는 로그인 흐름이 있는 사이트의 경우, 가장 간단한 접근 방식은 수동으로(또는 사이트의 API를 통해) 한 번 로그인하고, 결과 쿠키나 토큰을 캡처하여 재사용하는 것입니다. FetcherSession은 저장된 쿠키에서 세션을 채울 수 있도록 생성 시 cookies={…} 딕셔너리를 받습니다.
AsyncFetcher를 사용한 동시 페치
URL 목록이 있고 모두 필요할 때, 동기 Fetcher는 직렬화합니다. AsyncFetcher는 동일한 API를 코루틴으로 노출하므로, asyncio.gather로 모든 요청을 동시에 발행하고 여러 네트워크 왕복을 병렬로 실행할 수 있습니다(파서가 이미 첨부된 AIOHTTP를 사용한 비동기 스크래핑과 동일한 패턴):
import asyncio
from scrapling.fetchers import AsyncFetcher
URLS = [f'https://quotes.toscrape.com/page/{i}/' for i in range(1, 11)]
async def fetch_all():
tasks = [AsyncFetcher.get(u, stealthy_headers=True) for u in URLS]
pages = await asyncio.gather(*tasks)
return [q.css('.text::text').get()
for p in pages for q in p.css('.quote')]
quotes = asyncio.run(fetch_all())
print(f"scraped {len(quotes)} quotes")
동일한 10개 인용문 페이지에서, 이는 일반 가정용 연결에서 순차적인 9초 페치를 약 1초로 줄입니다. FetcherSession 자체도 async with 아래에서 작동하므로, 동기 코드와 동일한 방식으로 비동기 호출 간에 쿠키와 헤더를 재사용할 수 있습니다. 스로틀링, 중복 제거, 재개가 있는 전체 크롤의 경우 Spider 프레임워크가 일반적으로 더 좋은 선택입니다. AsyncFetcher는 알려진 URL 목록이 있고 병렬로 처리하고 싶을 때 중요합니다.
한 가지 함정: 기본 asyncio.gather(*tasks)는 첫 번째 예외를 즉시 다시 발생시키지만, 다른 태스크는 백그라운드에서 계속 실행됩니다; 작업을 중지하지 않고 결과에 접근할 수 없습니다. 부분적 성공을 원하는 프로덕션 목록의 경우, return_exceptions=True를 전달하고 결과를 필터링하거나, asyncio.TaskGroup(3.11+)을 사용하세요. 첫 번째 실패 시 형제를 취소하고 태스크별 오류 처리를 명시적으로 제공합니다.
Spider 프레임워크로 다중 페이지 크롤러 구축
실제 스크래핑 작업은 거의 한 페이지가 아닙니다. 페이지네이션을 따르고, 제품 링크를 따르고, URL을 중복 제거하고, 요청 속도를 제한하고, 모든 것을 디스크에 쓰고, 실패 시 정상적으로 재개합니다. Scrapling은 이를 위한 Spider 프레임워크를 제공하며, Scrapy 사용자에게 친숙할 Spider/parse/yield 형태를 갖추고 있습니다. Scrapy의 미들웨어, 파이프라인, 또는 신호에 의존하지 않는 스파이더는 대부분 기계적으로 포팅됩니다; 나머지는 Scrapling의 훅과 비동기 parse 시그니처에 맞게 재작성이 필요합니다.
약 천 권의 책이 있는 50페이지 페이지네이션 카탈로그를 가진 books.toscrape.com에 대한 간단한 크롤러:
from scrapling.spiders import Spider, Response
class BooksSpider(Spider):
name = "books"
start_urls = ["https://books.toscrape.com/"]
concurrent_requests = 8
download_delay = 0.5 # seconds between requests per domain
async def parse(self, response: Response):
for book in response.css('article.product_pod'):
yield {
"title": book.css('h3 a::attr(title)').get(),
"price": book.css('.price_color::text').get(),
"rating": book.css('p.star-rating::attr(class)').get(),
"url": response.urljoin(book.css('h3 a::attr(href)').get()),
}
next_page = response.css('li.next a::attr(href)').get()
if next_page:
yield response.follow(next_page, callback=self.parse)
if __name__ == "__main__":
result = BooksSpider().start()
print(f"Scraped {len(result.items)} books")
result.items.to_jsonl("books.jsonl")
이 스니펫이 수동으로 구축해야 할 몇 가지 작업을 수행합니다. concurrent_requests는 8개의 요청을 동시에 실행하여 books.toscrape.com에서 전체 크롤을 분에서 초로 줄입니다. download_delay는 단일 호스트를 과부하시키지 않도록 도메인별 간격을 적용합니다. response.follow()는 현재 페이지에 대해 상대 URL을 해석하여 가장 일반적인 페이지네이션 버그 중 하나(상대적인 next 링크를 조인하는 것을 잊는 것)를 제거합니다. 비동기 parse 시그니처는 크롤 루프를 차단하지 않고 페이지별 I/O(세부 페이지 가져오기, 외부 API 호출)를 수행할 수 있게 합니다.
알아둘 만한 두 가지 파서 메서드. .css() 결과에 대한 .re_first(pattern)은 첫 번째 정규식 일치를 반환하며, 형식화된 텍스트에서 숫자 값을 추출하는 데 유용합니다:
# turns '£51.77' into 51.77 in one expression
price = float(book.css('.price_color::text').re_first(r'[d.]+'))
그리고 .css()가 반환하는 Selectors 컨테이너에는 술어를 받는 .filter() 메서드가 있어, 두 번째 루프를 작성하지 않고 Scrapling이 이미 가진 데이터를 좁힐 수 있습니다:
expensive = response.css('article.product_pod').filter(
lambda b: float(b.css('.price_color::text').re_first(r'[d.]+')) >= 50
)
yield {'count_over_50': len(expensive)}
사이트가 필터링하려는 필드에 대한 URL 필터 매개변수를 노출하지 않을 때 유용합니다.
마지막의 내보내기는 대부분의 다운스트림 파이프라인이 예상하는 줄당 하나의 JSON 객체를 씁니다. 단일 JSON 배열을 위해 .to_json()을 사용하거나, process_item 훅을 재정의하여 자체 파이프라인을 작성할 수도 있습니다.
전체 크롤이 완료될 때까지 기다리는 대신 스크래핑되는 동안 항목이 필요한 파이프라인의 경우, Spider는 비동기 생성기로 .stream()을 노출합니다:
import asyncio
async def main():
async for item in BooksSpider().stream():
await write_to_kafka(item) # or any other downstream sink
asyncio.run(main())
더 긴 크롤의 경우, 일시 정지-재개 메커니즘을 처음부터 설정하는 것이 좋습니다:
result = BooksSpider(crawldir="./crawl_data").start()
crawldir을 전달하면 Scrapling이 방문한 URL과 보류 중인 요청을 디스크에 체크포인팅합니다. Ctrl+C를 누르면 크롤이 정상적으로 종료됩니다. 동일한 crawldir로 다시 실행하면 멈춘 곳에서 재개합니다. 50페이지 크롤에서는 불필요하지만, 장기 실행 프로덕션 크롤(카탈로그 새로 고침, 시장 조사, 가격 모니터링)에서는 하루치 진행 상황을 잃는 것과 아무것도 잃지 않는 것의 차이입니다.
대상이 더 리소스 집약적인 페처를 필요로 하는 경우, 스파이더는 URL별로 다른 세션을 통해 요청을 라우팅할 수 있습니다:
from scrapling.spiders import Spider, Request, Response
from scrapling.fetchers import FetcherSession, AsyncStealthySession
class HybridSpider(Spider):
name = "hybrid"
start_urls = ["https://example.com/catalog"]
def configure_sessions(self, manager):
manager.add("fast", FetcherSession(impersonate="chrome"))
manager.add("stealth", AsyncStealthySession(headless=True), lazy=True)
async def parse(self, response: Response):
for link in response.css('a::attr(href)').getall():
if "/protected/" in link:
yield Request(link, sid="stealth", callback=self.parse_protected)
else:
yield Request(link, sid="fast", callback=self.parse)
async def parse_protected(self, response: Response):
yield {"url": response.url, "title": response.css('h1::text').get()}
라우팅 다이어그램으로 시각화하면:

목록 페이지는 저렴한 HTTP 경로를 사용하고, 보호된 세부 페이지만 브라우저 비용을 지불합니다. lazy=True는 첫 번째 스텔스 요청이 실제로 발생할 때까지 브라우저 시작을 연기하므로, 목록만 처리하는 크롤은 Camoufox를 열지 않습니다.
그 예제에서 코드에서 명확하지 않은 몇 가지 세부 사항이 있습니다. AsyncStealthySession과 AsyncDynamicSession은 장기 실행 브라우저 세션입니다. 매 호출마다 새 브라우저를 시작하는 StealthyFetcher.fetch()나 DynamicFetcher.fetch() 대신 여러 요청에 걸쳐 재사용하세요.
configure_sessions는 manager(Spider의 세션 레지스트리)를 받습니다; manager.add(name, session)은 나중에 Request(url, sid=name)으로 라우팅할 수 있는 이름으로 세션을 등록합니다. 스텔스 세션의 lazy=True 플래그는 첫 번째 스텔스 요청을 만들 때까지 브라우저 열기를 지연하므로, 공개 페이지만 요청하는 크롤은 브라우저 시작 오버헤드를 발생시키지 않습니다.
fast 세션은 목록 페이지에 저렴한 HTTP 페처를 사용하고, 보호된 세부 페이지만 실제 브라우저가 필요합니다. 이런 종류의 라우팅은 범용 크롤러에 나중에 추가하기 어렵습니다.
실제 사이트의 페이지네이션
실제 대상에는 books.toscrape.com처럼 단순한 .next 링크가 거의 없습니다. 세 가지 패턴이 대부분의 경우를 처리합니다:
- 번호가 매겨진 페이지네이션(예: ?page=1, 2, 3…)이 가장 쉽습니다. start_urls에서 직접 URL을 생성하거나, 루프에서 parse의 Request 객체를 yield하세요.
- 무한 스크롤은 일반적으로 JSON XHR 엔드포인트에 의존합니다. DevTools → Network를 열고, 페이지를 스크롤하고, 다음 항목 배치를 반환하는 요청을 찾으세요. 그런 다음 Fetcher로 해당 엔드포인트를 호출하세요(브라우저에서 각 스크롤을 렌더링하는 것보다 훨씬 저렴합니다).
- “더 보기” 버튼은 브라우저 내에서 실제 클릭이 필요합니다. DynamicFetcher와 StealthyFetcher는 기본 Playwright 페이지를 받는 page_action 호출 가능 객체를 받습니다; 거기서 버튼을 클릭하고, 새 콘텐츠를 기다리고, 함수가 반환될 때 파서가 페이지를 읽도록 하세요:
from scrapling.fetchers import DynamicFetcher
def click_load_more(page):
# `page` is the underlying Playwright sync Page.
for _ in range(5):
page.click("button.load-more")
page.wait_for_load_state("networkidle")
return page
result = DynamicFetcher.fetch(
"https://example.com/products",
page_action=click_load_more,
headless=True,
)
items = result.css(".product")
대상에 맞게 선택자와 클릭 횟수를 조정하세요. 비동기 세션 클래스(AsyncDynamicSession, AsyncStealthySession)는 동일한 호출 가능 객체의 비동기 버전을 받습니다.
프로덕션을 위한 Scrapling 확장: 프록시와 언블로킹
실제 프로덕션 대상을 대량으로 스크래핑하면 아키텍처가 변경됩니다. 세 가지 제약이 함께 나타나는 경우가 많습니다:
- IP 평판. 동일한 사이트에 시간당 천 개의 요청을 보내는 단일 주거용 또는 데이터센터 IP는 실제 사용자와 유사하지 않습니다. 대부분의 프로덕션 대상은 속도를 제한하고, 스로틀하고, 차단합니다. 해결책은 IP 풀이며, 이상적으로는 주거용(실제 소비자 연결) 또는 ISP(안티봇 점수에서 주거용처럼 보이는 통신사 할당 데이터센터 IP)로, 요청 또는 세션별로 로테이션합니다.
- 지리적 타겟팅. 일부 사이트는 국가, 주, 또는 도시별로 다른 콘텐츠(또는 다른 가격)를 제공합니다. 이러한 뷰를 재현하려면 해당 위치의 프록시가 필요합니다.
- CDN급 안티봇. Cloudflare의 기본 Turnstile 너머로, Akamai Bot Manager(엄격 모드의 DataDome 또는 HUMAN)는 종종 로컬 스텔스 설정을 차단합니다. 그 시점에서 자체 브라우저 풀과 챌린지 솔버를 유지하는 관리형 언블로킹 엔드포인트가 일반적으로 커스텀 솔루션보다 더 잘 작동합니다.
재시도, 타임아웃, 일시적 오류
네트워크 오류는 규모에서 피할 수 없습니다: 연결 재설정, 부하 시 가끔 503, 속도 제한 시 429. FetcherSession은 생성 시 retries=, retry_delay=, timeout=을 받습니다(v0.4.7 기본값: 3, 1초, 30초; 설치된 버전에서 help(FetcherSession)으로 확인하세요). 브라우저 페처(StealthyFetcher, DynamicFetcher)는 생성자 대신 각 .fetch() 호출에서 동일한 매개변수를 받습니다.
서버가 429에서 Retry-After 헤더를 보내는 대상별 속도 제한의 경우, parse 메서드에서 해당 헤더를 읽고 지연과 함께 Request를 다시 yield하세요. 기본 재시도는 Retry-After를 준수하지 않으므로, 이에 의존하면 동일한 429가 다시 발생합니다.
브라우저 메모리: 구체적인 크기 수치
실제 브라우저를 실행하는 것은 DynamicFetcher와 StealthyFetcher를 사용하는 비용입니다. 일반적인 콘텐츠 페이지(~200KB HTML, 미디어 집약 SPA 없음)에서 단일 Camoufox 또는 Chromium 세션은 Linux x86_64의 헤드리스 모드에서 약 700-900MB의 RAM을 사용합니다. 크기는 동일한 세션에서의 페치 간에 거의 변하지 않으므로, 컨테이너 크기를 결정할 때 동시 브라우저 세션당 약 1GB로 계획하세요: 4GB 워커는 3-4개의 동시 세션을 편안하게 실행하고, 8GB 워커는 6-8개를 처리합니다. 더 무거운 대상(이미지 풍부한 페이지, 밀집된 SPA, 수십 개의 분석 스크립트를 로드하는 사이트)은 세션당 비용을 1.2-1.5GB까지 올립니다. 모든 요청에서 브라우저 시작 지연이 발생하지 않도록 일회성 .fetch() 호출 대신 세션을 재사용하세요.
프로덕션 볼륨에서 중요한 두 가지 브라우저 페처 플래그. block_ads=True는 Scrapling의 내장 차단 목록(약 3,500개의 광고 및 트래커 도메인)을 활성화하고 관련 없는 네트워크 요청을 건너뛰어 광고가 많은 사이트에서 페치 시간을 줄입니다. dns_over_https=True는 Cloudflare의 DoH(DNS over HTTPS) 엔드포인트를 통해 DNS 쿼리를 라우팅하고 주거용 프록시를 통해 트래픽을 라우팅할 때 DNS 누수를 방지합니다. 둘 다 DynamicFetcher와 StealthyFetcher에 적용됩니다(HTTP 페처 요청은 페이지 리소스를 로드하지 않으므로 두 플래그가 필요하지 않습니다).
자체 관리형 프록시 로테이션
Scrapling에는 기본 로테이션 케이스를 직접 처리하는 ProxyRotator 헬퍼가 있습니다:
from scrapling.fetchers import FetcherSession
from scrapling.engines.toolbelt.proxy_rotation import ProxyRotator
rotator = ProxyRotator([
"http://user:[email protected]:8000",
"http://user:[email protected]:8000",
"http://user:[email protected]:8000",
])
with FetcherSession(proxy_rotator=rotator) as session:
for url in target_urls:
page = session.get(url)
process(page)
소수의 정적 프록시가 있는 소규모 프로젝트의 경우, 그것으로 충분합니다. 더 큰 규모에서는 일반적으로 요청당(또는 사용자당 고정 세션) 새로운 IP를 제공하는 단일 엔드포인트를 원하며, 그것이 상업적 제공업체를 이용하는 것이 합리적인 곳입니다.
Bright Data의 주거용 프록시 네트워크는 동일한 프록시 URL 패턴을 사용하여 Scrapling과 통합됩니다: 사용자 이름과 비밀번호 인증이 있는 단일 HTTP 프록시 엔드포인트이며, 사용자 이름에는 네트워크에 필요한 라우팅 매개변수(국가 및 고정 세션 ID 포함)가 포함됩니다. 값은 Bright Data 대시보드의 존 액세스 매개변수 페이지에서 가져옵니다.
아래 예제를 실행하려면: brightdata.com에서 가입하고(무료 체험, 시작에 카드 필요 없음), 대시보드에서 주거용 프록시 존을 만들고, id, zone, password를 프록시 URL에 복사하세요. 주거용 프록시는 존 활성화 전에 일회성 KYC 인증이 필요합니다. 요청별 로테이션을 위한 일반적인 설정:
from scrapling.fetchers import FetcherSession
# Replace <id>, <zone>, and <password> with the values from your dashboard.
PROXY = "http://brd-customer-<id>-zone-<zone>:<password>@brd.superproxy.io:33335"
with FetcherSession(impersonate="chrome", verify=False) as session:
page = session.get(
"https://quotes.toscrape.com/",
proxy=PROXY,
stealthy_headers=True,
)
이 설정에 대한 두 가지 참고 사항:
- FetcherSession 생성자가 아닌 요청별로 proxy=를 전달하세요. 요청별 proxy=는 페처 유형 간에 일관되게 동작하며 호출별로 재정의하는 가장 쉬운 경로입니다. 이는 Bright Data뿐만 아니라 모든 제공업체에 적용됩니다.
- 세션에 verify=False를 설정하세요. Bright Data 주거용 네트워크는 자체 서명된 인증서 체인으로 프록시 홉을 종료합니다(주거용 프록시 서비스의 표준). 검증은 프록시에 대한 로컬 홉에 대해서만 비활성화됩니다; 대상 연결은 여전히 프록시의 CONNECT 메서드를 통해 종단 간 TLS입니다. 프로덕션에서 더 깔끔한 패턴은 신뢰 저장소에 Bright Data의 CA 인증서를 설치하고 verify=False를 완전히 제거하는 것입니다; 주거용 프록시를 통하지 않는 코드 경로에 복사-붙여넣기하지 마세요.
고정 세션(장바구니나 로그인 상태를 유지하기 위한 여러 요청에 걸친 동일한 IP)의 경우, 사용자 이름에 세션 ID가 포함됩니다(예: brd-customer–zone–session-rand123). 로테이션 로직은 제공업체 측에서 실행되며, 라이브러리는 URL을 일반 HTTP 프록시로 취급합니다.
동일한 Scrapling 통합이 Bright Data의 다른 프록시 유형(ISP 프록시는 더 높은 볼륨의 주거용 품질 IP, 모바일 전용 뷰를 위한 모바일 프록시)과도 URL의 존 이름만 변경하면 작동합니다.
가장 어려운 대상의 경우, Web Unlocker 패턴을 알아두는 것이 좋습니다. 자체 스텔스 브라우저를 실행하고 벤더가 새 탐지 확인을 출시할 때마다 핑거프린트를 업데이트하는 대신, 단일 엔드포인트에 페처를 지정합니다; 렌더링, 핑거프린팅, IP 로테이션, 챌린지 해결이 원격으로 이루어집니다. Bright Data의 Web Unlocker는 이 패턴을 중심으로 구축되어 있으며, 국가 수준 타겟팅과 벤더가 유지하는 도메인별 언블로킹 로직이 있습니다. 파싱 코드는 동일하게 유지됩니다; 페치 라인만 이동합니다.
동일한 트레이드오프가 JavaScript 집약적인 대상에도 적용됩니다. 로컬에서 Camoufox 또는 Chromium을 실행하는 것은 중간 볼륨에서 작동합니다. 많은 브라우저 컨테이너를 관리하게 되면, 관리형 Bright Data Scraping Browser가 팀에서 언블로킹과 핑거프린트 유지를 제거합니다. Scraping Browser는 Playwright가 내부적으로 사용하는 것과 동일한 프로토콜을 사용하여 WebSocket을 통해 연결하는 원격 브라우저이므로, 로컬 Chromium 브라우저와 동일한 코드 경로에 맞습니다.
이 중에서 선택할 때 두 가지 실용적인 참고 사항이 적용됩니다:
- 문제가 “속도 제한을 피하기 위해 요청당 다른 IP가 필요합니다”라면, 주거용 프록시와 로컬 Fetcher 또는 StealthyFetcher가 일반적으로 충분합니다. IP에 비용을 지불하는 것이지, 차단을 우회하는 작업에 비용을 지불하는 것이 아닙니다.
- 문제가 “해결할 수 없는 CAPTCHA 챌린지를 받고 있으며, 사이트가 몇 주마다 보호를 변경합니다”라면, 관리형 언블로킹 엔드포인트가 일반적으로 더 높은 요청당 비용을 정당화할 만큼 충분한 엔지니어링 시간을 절약합니다.
완전한 프로덕션 준비 Scrapling 스크립트
기본 BooksSpider는 샌드박스에서 깨끗하게 실행됩니다. 다섯 가지 추가로 프로덕션 준비가 됩니다. 아래에 번호가 매겨진 주석으로 표시되어 있습니다:
import logging
from datetime import datetime, timezone
from scrapling.spiders import Spider, Response
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("books")
class BooksSpider(Spider):
name = "books_production"
start_urls = ["https://books.toscrape.com/"]
concurrent_requests = 8
download_delay = 0.5
robots_txt_obey = True # 1. respect robots.txt and Crawl-delay
async def parse(self, response: Response):
if response.status != 200: # 2. handle non-200 responses explicitly
log.warning("Non-200 status %s on %s", response.status, response.url)
return
books_on_page = response.css('article.product_pod')
if not books_on_page: # 3. detect outdated selectors early
log.error("No books found on %s; selector may be outdated", response.url)
return
for book in books_on_page:
yield {
"scraped_at": datetime.now(timezone.utc).isoformat(), # 4. timestamp every row
"title": book.css('h3 a::attr(title)').get(),
"price": book.css('.price_color::text').get(),
"rating": book.css('p.star-rating::attr(class)').get(),
"url": response.urljoin(book.css('h3 a::attr(href)').get()),
}
next_page = response.css('li.next a::attr(href)').get()
if next_page:
yield response.follow(next_page, callback=self.parse)
if __name__ == "__main__":
spider = BooksSpider(crawldir="./crawl_data") # 5. checkpoint for pause/resume
result = spider.start()
log.info("Scraped %d items", len(result.items))
result.items.to_jsonl("books.jsonl")
각 추가가 제공하는 것:
- robots_txt_obey는 robots.txt와 Crawl-delay 지시어를 자동으로 준수합니다.
- 상태 확인은 스파이더가 서버 사이드 실패를 “항목을 찾을 수 없음”으로 처리하는 대신 명시적으로 기록하게 합니다.
- 빈 결과 확인은 다운스트림 보고서에 데이터가 없음을 보여주는 3주 후가 아닌 다음 날 아침에 오래된 선택자를 포착합니다.
- 타임스탬프는 각 행이 스크래핑된 시점을 기록하므로, 며칠에 걸친 재실행이 서로 혼재되지 않습니다.
- crawldir은 Ctrl+C, 커널 패닉, 또는 네트워크 연결 끊김으로 인해 크롤 진행 상황이 손실되지 않음을 의미합니다.
동일한 스크립트를 주거용 프록시로 전환하려면 페처 세션만 변경하면 됩니다. Web Unlocker 엔드포인트로 전환하려면 프록시 URL을 언블로킹 서비스로 변경하세요. 파싱 로직과 스파이더 동작은 동일하게 유지됩니다.
일정에 따라 실행
스크립트를 cron, systemd 타이머, 또는 Airflow나 Prefect 같은 오케스트레이터에 래핑하세요. 이전 실행의 재개 상태가 새 실행으로 이어지지 않도록 실행별 crawldir(예: ./crawl_data/$(date +%Y%m%d))을 사용하고, 워커 머신의 디스크에 두는 대신 내구성 있는 스토리지로 출력을 전송하세요. 일반적인 싱크: 임시 분석을 위해 polars 또는 DuckDB로 읽는 S3 또는 GCS의 Parquet, 또는 관계형 조회가 필요할 때 Postgres 테이블.
JSONL 파일 이외의 대상의 경우, Spider의 on_start, on_scraped_item, on_close 훅을 재정의하세요(세 가지 모두 async def). on_start에서 데이터베이스 연결이나 메시지 큐 프로듀서를 한 번 열고, yield될 때 on_scraped_item에서 각 항목을 씁니다(항목을 전달하려면 반환하고, 삭제하려면 None을 반환). on_close에서 정리하세요.
import asyncpg
from scrapling.spiders import Spider, Response
class BooksToPostgres(Spider):
name = "books_to_pg"
start_urls = ["https://books.toscrape.com/"]
async def on_start(self, resuming: bool = False) -> None:
self.db = await asyncpg.connect(DSN)
async def parse(self, response: Response):
for book in response.css('article.product_pod'):
yield {
"title": book.css('h3 a::attr(title)').get(),
"price": book.css('.price_color::text').get(),
}
async def on_scraped_item(self, item):
await self.db.execute(
"INSERT INTO books (title, price) VALUES ($1, $2)",
item["title"], item["price"],
)
return item # forward downstream too
async def on_close(self) -> None:
await self.db.close()
스크래핑이 실패할 때: 디버깅 체크리스트
프로덕션 스크래퍼는 단위 테스트가 감지하지 못하는 방식으로 실패합니다. 몇 가지 빠른 확인으로 대부분을 처리할 수 있습니다.
브라우저를 가시적 모드로 열기. StealthyFetcher.fetch() 또는 DynamicFetcher.fetch()에 headless=False를 전달하고 페이지 렌더링을 지켜보세요. CAPTCHA 챌린지, 리다이렉트 체인, 지역 IP 리다이렉트, 안티봇 탐지 페이지는 무슨 일이 일어나고 있는지 볼 수 있을 때 종종 명확해집니다. 로컬에서 실행하고; 헤드리스 서버의 경우 대신 page_action을 통해 스크린샷을 저장하세요.
응답 HTML을 디스크에 저장. 선택자가 아무것도 반환하지 않으면 원시 응답을 저장하고 브라우저에서 열어보세요:
page = Fetcher.get('https://example.com')
with open('debug.html', 'wb') as f:
f.write(page.body)
그런 다음 파서가 받은 것과 예상한 것을 비교하세요. 대부분 스크래핑한 것이 CAPTCHA 벽, 다른 언어 리다이렉트, 또는 언뜻 보기에 성공 케이스와 동일해 보이는 빈 결과 페이지인 것으로 밝혀집니다. HTML은 상태 코드가 오해의 소지가 있을 때도 진실을 보여줍니다.
대화형 셸 사용. scrapling[shell]을 설치하고 scrapling shell을 실행하세요. Scrapling이 미리 가져온 IPython 세션과 두 가지 유용한 헬퍼를 로드합니다: uncurl(…)은 curl 명령(DevTools의 Copy as cURL에서)을 Scrapling Request 객체로 파싱하여 정확히 무엇이 전송되는지 검사할 수 있게 하고, curl2fetcher(…)는 파싱-및-실행하여 파싱된 Response를 반환합니다. DevTools에서 XHR 호출을 마우스 오른쪽 버튼으로 클릭하고 cURL로 복사하여 셸 내에서 붙여넣으면 작동하는 Scrapling 페치를 얻을 수 있습니다.
이미 가진 요소에서 선택자를 역공학. find_by_text, 탐색, 또는 다른 곳을 통해 요소를 찾은 경우, .generate_css_selector와 .generate_xpath_selector 속성(참고: 속성이지 메서드가 아님)이 재사용 가능한 선택자를 제공합니다:
einstein = page.find_by_text("Albert Einstein")
print(einstein.generate_css_selector)
# body > div > div:nth-of-type(2) > div > div > span:nth-of-type(2) > small
출력은 사람이 읽기 어렵지만 재사용 가능하며 요소를 이동하지 않는 콘텐츠 변경에서 살아남습니다.
먼저 확인할 사항에 대한 참고 사항. 어제 작동하던 스크래퍼가 오늘 실패하면, 가장 빠른 확인에서 가장 느린 확인으로 작업하세요: 빈 결과 확인(“선택자가 아무것도 반환하지 않았습니다”), 저장된 HTML(“페이지가 렌더링되었는지?”), 그런 다음 headless=False(“사이트가 브라우저에 챌린지를 하고 있는지?”).
대상에 다른 요청을 보내지 않고 parse()를 반복. Spider 클래스에 development_mode = True와 development_cache_dir = “./_dev”를 설정하세요:
class MySpider(Spider):
name = "iter"
start_urls = ["https://target.example.com/"]
development_mode = True
development_cache_dir = "./_dev"
async def parse(self, response):
...
첫 번째 실행은 네트워크에 접근하고 모든 응답을 디스크에 캐시합니다; 이후 실행은 캐시에서 재생합니다(샌드박스 사이트에서 1.2초 대비 약 50ms, 대략 24배 속도 향상). 선택자를 조정하고 데이터를 정리하는 동안 모든 테스트 실행에서 네트워크를 기다릴 필요가 없습니다. 배포하기 전에 development_mode를 False로 다시 설정하세요.
다음 단계
스크래핑하고 싶었던 실제 대상을 선택하고 작동하는 가장 가벼운 페처로 시작하세요. Fetcher는 정적 HTML을 처리하고; 콘텐츠가 JavaScript 렌더링된 경우 DynamicFetcher를, 사이트가 Cloudflare 또는 유사한 안티봇 벤더 뒤에 있는 경우 StealthyFetcher를 사용하세요.
계속 실행할 계획인 모든 것에 대해 처음부터 이러한 기본값을 설정하세요:
- crawldir, robots_txt_obey=True, 모든 페이지에 빈 결과 확인이 있는 Spider에 파싱 로직을 래핑하세요.
- 사이트가 마크업을 변경하기 전에 구조적 핑거프린트가 디스크에 있도록 첫 번째 실행에서 selector_config={‘adaptive’: True}와 auto_save=True를 켜세요.
- 공유 인프라에서 download_delay를 최소 0.5-1초로 설정하고, 429 응답에 대해 parse 메서드에서 Retry-After 헤더를 읽으세요.
로컬 스텔스가 충분하지 않을 때(IP 평판, 동시성 확장, CDN급 안티봇), 각 페치 호출에 단일 proxy= 인수를 추가하여 주거용 프록시 또는 관리형 언블로킹 엔드포인트로 전환하세요. 기본 인증이 있는 HTTP 프록시를 노출하는 모든 제공업체가 동일한 방식으로 작동합니다.
전체 참조는 공식 문서를 참조하세요.
FAQ
Scrapling을 상업적 제품에 사용할 수 있나요?
네. Scrapling은 BSD-3-Clause이므로 로열티나 유료 계층 없이 상업적 제품, SaaS 백엔드, 또는 내부 도구에 포함할 수 있습니다. 주거용 프록시나 CAPTCHA 솔버 같은 선택한 선택적 서드파티 서비스에 대해서만 비용을 지불합니다. Scrapling 자체의 어떤 기능도 라이선스로 제한되지 않습니다.
Scrapling은 Playwright 또는 Selenium과 어떻게 다른가요?
Scrapling은 스크래핑을 위해 특별히 구축되었습니다; Playwright와 Selenium은 일반적인 브라우저 자동화 도구입니다. Scrapling은 스텔스 패치된 Camoufox 빌드(Playwright를 통해 구동), 재시도, 세션 재사용, 적응형 선택자를 래핑하므로, 글루 코드를 덜 작성하고 기본 Playwright가 노출하는 Chromium-CDP 핑거프린트를 피할 수 있습니다.
Scrapling이 CAPTCHA를 해결하나요?
부분적으로. StealthyFetcher는 solve_cloudflare=True일 때 관리형 Cloudflare Turnstile 챌린지를 통과합니다. 다른 카테고리(이미지 그리드 hCaptcha, 오디오 CAPTCHA, 커스텀 엔터프라이즈)는 서드파티 솔버(2Captcha, CapSolver) 또는 챌린지 레이어를 종단 간으로 처리하는 관리형 언블로킹 엔드포인트가 필요합니다.
Scrapling은 Scrapy와 함께 작동할 수 있나요?
네. Scrapling의 파서는 Parsel과 동일한 의사 요소 구문(::text, ::attr(href))을 사용하므로, Scrapling Selector는 대부분의 선택자가 변경 없이 Scrapy 콜백 내에서 작동합니다. Spider/parse/yield 형태가 이어집니다; 무거운 미들웨어나 파이프라인이 없는 스파이더는 대부분 기계적으로 포팅됩니다.
Scrapling을 사용하려면 프록시 서비스가 필요한가요?
아니요, Scrapling은 소규모 작업에서 프록시 없이 작동합니다. 프로덕션 볼륨에서는 완전한 제어를 원하면 정적 목록과 함께 Scrapling의 내장 ProxyRotator를 사용하거나, 요청당 새로운 IP나 국가 수준 타겟팅이 필요할 때 관리형 주거용, ISP, 또는 모바일 엔드포인트를 사용하세요.
Scrapling이 Docker 내에서 실행될 수 있나요?
네. 프로젝트는 모든 브라우저 종속성이 미리 설치된 공식 Docker 이미지를 제공합니다. StealthyFetcher와 DynamicFetcher의 경우, 공식 이미지는 커스텀 컨테이너에서 Camoufox와 Chromium을 작동시키는 데 약 한 시간을 절약합니다. 기본 Fetcher의 경우, 모든 표준 Python 이미지가 작동합니다.