Crawlee 1.10.4를 적용할 때는 요청 성공 건수와 함께 추출 결과를 확인하자. XML 선언이 붙은 HTML, 상대 경로인 <base href>, HTML로 표시된 JSON, 빈 응답을 작은 fixture로 남기면 파서나 의존성을 바꿀 때 생기는 누락을 찾기 쉽다.
2026년 10월 7일 기준으로 살펴본 이번 변경은 크롤링 속도보다 입력 해석의 정확성과 관련이 깊다. Crawlee for Python 1.10.4는 10월 6일 공개된 정식 패치 릴리스다. 아래에서는 수정 범위를 짚고, 이를 수집 파이프라인의 회귀 테스트로 옮기는 방법을 제안한다.
어떤 프로젝트가 먼저 확인하면 좋을까
Crawlee는 요청 처리와 링크 수집을 구성하는 Python 라이브러리다. 특히 ParselCrawler를 쓰는 배치 수집기에서 페이지는 내려받았는데 CSS 선택 결과가 비거나, 다음 페이지가 큐에 들어가지 않는 현상을 겪었다면 이번 변경을 확인할 만하다. 링크의 기준 URL 수정은 BeautifulSoupCrawler, ParselCrawler, PlaywrightCrawler에서 확인할 수 있다.
PyPI의 1.10.4 메타데이터는 소유자를 Apify, 작성자를 Apify Technologies s.r.o.로 표시한다. 라이선스는 Apache-2.0이며 Python 3.10 이상이 필요하다. 개발 상태는 Production/Stable로 분류되어 있다. 이 글의 점검 대상은 Python판 1.10.4이며, JavaScript판이나 모든 파서의 동작으로 일반화하지 않는다.
1. XML 선언이 있어도 HTML로 읽어야 하는 응답
Content-Type: text/html인 응답이 <?xml version="1.0"?>로 시작할 수 있다. PR #2262는 Parsel 1.12 이후 이런 XHTML 페이지가 XML로 해석되면서 CSS 선택과 링크 수집 결과가 비는 문제를 설명한다. 수정은 text/html과 application/xhtml+xml 응답의 HTML 해석을 명시한다.
회귀 테스트에는 같은 본문을 서로 다른 Content-Type으로 제공하는 경우를 넣자. HTML 응답에서는 기대한 요소가 추출되는지 확인하고, 실제 application/xml 응답은 XML로 유지되는지 함께 확인한다. 파싱이 예외 없이 끝나는지만 검사하면 요소 누락은 지나갈 수 있다.
2. 상대 base URL 때문에 다음 링크가 사라지는 경우
https://example.test/catalog/index.html에 <base href="../assets/">와 <a href="detail.html">이 있다면 기대하는 링크는 https://example.test/assets/detail.html이다. 기준 경로를 먼저 페이지 URL에 맞춰 해석한 뒤 링크를 결합해야 한다.
PR #2263에 따르면 BeautifulSoupCrawler와 ParselCrawler는 상대 base를 페이지 URL에 대해 해석하고, 잘못된 base는 페이지 URL로 대체한다. 기존에는 상대 base가 있는 페이지의 상대 링크가 누락되거나 잘못된 base 때문에 추출이 실패할 수 있었다. Playwright 경로도 유효하지 않은 base 처리와 링크 주변 공백 처리가 수정됐다.
이 항목은 링크 개수보다 최종 URL을 검사하는 편이 좋다. 절대 base, 상대 base, 형식이 깨진 base, mailto: 같은 base를 나누고 각각의 예상 URL을 고정한다. 리디렉션이 있는 서비스라면 최종 페이지 주소를 기준으로 하는 fixture도 추가할 수 있다.
3. HTML로 표시된 JSON과 빈 응답의 처리
PR #2266은 두 경계를 보완한다. text/html로 전송된 JSON 객체·배열은 JSON으로 인식해 JMESPath로 조회할 수 있게 하고, 빈 본문은 빈 HTML 문서로 처리한다. 회귀 테스트에는 앞쪽 공백과 BOM, 대괄호로 시작하지만 JSON이 아닌 HTML도 포함되어 있다. #2266은 앞선 #2262의 후속 수정으로, 두 변경 모두 이번 정식 릴리스 전에 병합됐다.
따라서 세 번째 점검은 헤더와 본문이 어긋나는 JSON에서 실제 필드 값이 나오는지 확인하는 것이다. 네 번째 점검은 빈 응답이 파서 오류 없이 처리되는지와, 애플리케이션이 그 빈 결과를 어떻게 다룰지 분리하는 것이다. 라이브러리가 빈 본문을 처리한다고 해서 필수 데이터가 없는 페이지를 저장해도 된다는 뜻은 아니다.
외부 사이트 없이 네 가지 경계를 점검하는 예제
아래는 로컬 서버에 네 개의 응답을 만들고 공개 크롤러 API로 결과를 검사하는 자체 작성 예제다. Python 3.10 이상과 crawlee[parsel]==1.10.4가 이미 준비된 테스트 환경을 전제로 한다. 새 패키지 설치나 외부 사이트 요청은 하지 않는다. 문서와 변경 코드를 대조하고 구문을 검사했지만, 이 글을 준비하면서 해당 패키지로 통합 실행하지는 않았다.
import asyncio
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from crawlee.crawlers import ParselCrawler, ParselCrawlingContext
PAGES = {
"/xhtml": (
b'<?xml version="1.0"?>'
b'<html xmlns="http://www.w3.org/1999/xhtml">'
b'<body><h1>Ready</h1></body></html>'
),
"/catalog/index.html": (
b'<html><head><base href="../assets/"></head>'
b'<body><a href="detail.html">Item</a></body></html>'
),
"/json": b'{"result": "ready"}',
"/empty": b"",
}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200 if self.path in PAGES else 404)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(PAGES.get(self.path, b""))
async def check(origin):
crawler = ParselCrawler(max_requests_per_crawl=4)
checked = set()
@crawler.router.default_handler
async def inspect(context: ParselCrawlingContext):
path = context.request.url.removeprefix(origin)
selector = context.selector
if path == "/xhtml":
assert selector.css("h1::text").get() == "Ready"
elif path == "/catalog/index.html":
links = await context.extract_links()
assert [item.url for item in links] == [
origin + "/assets/detail.html"
]
elif path == "/json":
assert selector.jmespath("result").get() == "ready"
elif path == "/empty":
assert selector.type == "html"
assert selector.css("a").getall() == []
else:
raise AssertionError(path)
checked.add(path)
await crawler.run([origin + path for path in PAGES])
assert checked == set(PAGES), checked
print("checked:", len(checked))
if __name__ == "__main__":
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
worker = Thread(target=server.serve_forever, daemon=True)
worker.start()
try:
asyncio.run(check(f"http://127.0.0.1:{server.server_port}"))
finally:
server.shutdown()
worker.join()
server.server_close()
예상 출력의 마지막 줄은 checked: 4다. 링크는 추출만 하고 큐에 추가하지 않는다. 현재 작업의 저장소와 섞이지 않도록 빈 임시 작업 디렉터리에서 실행하는 편이 좋다. 또한 Python의 assert가 생략되는 -O 옵션 없이 실행해야 한다.
마지막 집합 검사는 중요하다. 크롤러가 실패한 요청을 기록하고 실행을 마치더라도 모든 fixture가 검증됐는지 다시 확인할 수 있다. 실서비스 페이지를 직접 반복 수집하는 대신, 허가된 응답에서 개인정보와 토큰을 제거한 최소 fixture를 만들면 원인 파악과 비교가 쉬워진다.
어디에 적용하고 언제 기다릴까
기존 ParselCrawler나 링크 수집 작업에서 위 증상을 겪었다면 같은 fixture로 1.10.4를 먼저 검증할 만하다. 현재 잘 동작하는 일회성 수집기를 이 패치만을 이유로 Crawlee로 옮길 필요는 없다. 직접 HTTP 클라이언트와 파서를 조합한 코드에도 입력별 결과 검증은 적용할 수 있다. XML 문서나 다른 수집 라이브러리를 운영한다면 해당 경로의 fixture를 따로 통과시킨 뒤 배포하자.
업데이트를 마치기 전에 확인할 것
- 추출 결과: 페이지 유형별 필수 필드와 최소 기대 링크를 정하고, 비어 있는 결과를 따로 집계한다.
- 해석의 경계: HTML·XML·JSON과 빈 본문을 분리한다. 전체 오류율만으로는 특정 형식의 누락을 찾기 어렵다.
- URL의 정확성: 수집 건수와 별개로 base 처리 후 URL이 기대한 경로인지 비교한다.
- 같은 입력으로 비교: 기존 잠금 파일의 환경과 1.10.4 후보 환경에 동일 fixture를 적용한다. 이번 수정이 모든 과거 버전에 동일한 실패를 일으켰다고 가정하지 않는다.
이번 패치를 계기로 추가할 만한 것은 작은 응답 fixture와 결과 검증이다. 수집기가 끝까지 실행됐다는 기록에 더해, 어떤 형식의 입력에서 어떤 데이터와 다음 링크를 얻었는지 남겨 두면 다음 업데이트의 판단 근거도 생긴다.
작성·확인: 2026년 10월 7일. AI의 도움으로 작성하고 공식 릴리스, PyPI 메타데이터, 변경 코드와 테스트를 대조했다. 예제는 구문 검사만 수행했으며 Crawlee 통합 실행 결과는 제시하지 않았다.
댓글
댓글 쓰기