스크래퍼 견고하게 만들기: 레이아웃 변경, 알람, 재시도
사이트 레이아웃이 바뀌면 스크래퍼는 조용히 망가진다. 스키마 검사와 0건 알람, 헤더가 거짓말하는 인코딩 판별, 상한 있는 재시도, 메트릭과 로그를 연습 사이트에서 실측하고 시리즈를 마무리한다.
시리즈: 웹 스크래핑 강좌 6/6 (고급). 5강처럼 정기 실행되는 스크래퍼를 운영하면서, 망가진 걸 3주 뒤가 아니라 그날 알고 싶은 사람을 위한 회차입니다. 다 읽고 나면 스키마 검사와 0건 알람으로 레이아웃 변경을 잡아내고, 잘못된 문자 인코딩에 대처하고, 무엇을 재시도하고 무엇을 포기할지 정하고, 실행마다 메트릭 한 줄을 남길 수 있습니다.
위험한 건 죽는 스크래퍼가 아닙니다. 사이트 개편 뒤에도 계속 돌면서 매일 아침 "변화 0건"을 보고하고, 가격 비교 대시보드가 조용히 멈춰 버리게 만드는 스크래퍼입니다. 이 글은 그 대시보드를 맡은 사람을 위한 글이고, 끝에 시리즈 전체 체크리스트가 있습니다.
아래 실행은 모두 Apple Silicon Mac (M1 Pro), Python 3.9.6 표준 라이브러리만, 그리고 시리즈의 연습용 쇼핑몰을 띄운 PHP 8.5.6(php -S 127.0.0.1:18172)으로 했습니다. 이 쇼핑몰은 일부러 레이아웃을 바꾸고, 깨진 인코딩과 불안정한 응답을 돌려줄 수 있습니다. 경로는 ~/demo/ 로 줄였습니다.
조용한 실패를 재현하다
연습 쇼핑몰에는 상태 파일로 고를 수 있는 레이아웃 세 가지가 있습니다.
- v1:
<li class="product">와<span class="price">. 파서가 기대하는 형태입니다. - v2: 개편. 상품은 모두
<article class="card" data-id="...">가 되고 가격은<span class="amount">로. - v1b: 작은 변경. 가격 클래스 이름만
price-now로 바뀌었습니다.
쇼핑몰을 v2 로 바꾸고 5강의 감시 스크립트를 그대로 돌렸습니다.
$ echo v2 > ~/demo/shop/state/layout
$ python3 shopwatch.py; echo "exit=$?"
2026-10-31T03:00:00Z pages=10 items=0 changes=0 secs=9.1
exit=0
10페이지를 가져왔고, 상품 0건, 변화 0건, 종료 코드 0. Python 입장에서는 아무 문제가 없습니다. "다음" 링크가 남아 있어서 페이지 넘기기는 잘 됐고, html.parser 는 <li class="product"> 를 한 번도 못 만났을 뿐입니다. cron 은 성공으로 봅니다. DB 에는 어제 가격이 아무 일 없었다는 듯 남아 있습니다.
v1b 는 반대로 시끄럽게 죽습니다. normalize() 가 가격으로 None 을 받기 때문입니다.
File "~/demo/shopwatch/shopwatch.py", line 46, in normalize
price = raw["price"].strip().lstrip("$").replace(",", "")
AttributeError: 'NoneType' object has no attribute 'strip'
사실 이쪽이 더 나은 결과입니다. 그리고 여기서 가장 흔한 실수가 나옵니다.
흔한 실수: 기본값으로 크래시를 "고친다"
손이 가는 수정은 한 줄입니다. price = (raw["price"] or "$0.00").strip(). 트레이스백은 사라집니다. v1b 에서 다음 실행 결과가 이렇습니다.
CHANGE SKU-0001 price 62.79 -> 0.00
CHANGE SKU-0002 price 76.09 -> 0.00
CHANGE SKU-0003 price 28.99 -> 0.00
...
2026-10-31T03:00:00Z pages=10 items=200 changes=200 secs=9.1
exit=0
가짜 가격 인하 200건이 이력에 기록되고 종료 코드는 0. 이 알림을 보고 움직인 사람은 쓰레기 데이터로 판단하게 되고, 이력은 청소가 필요해집니다. 올바른 해결책은 기본값의 정반대입니다. 완전히 읽지 못한 레코드는 받지 않고, 세고, 그 수가 0이 아니면 실행을 실패로 만든다.
스키마 검사와 0건 알람
robust.py 는 5강의 감시 스크립트(같은 폴더의 shopwatch.py 를 import)를 네 가지 아이디어로 감쌉니다. 상품마다 하는 레코드 검사, 실행 전체에 대한 실행 검사, 0이 아닌 종료 코드, 그리고 JSON 메트릭 한 줄입니다.
"""robust.py - lecture 6: the lecture 5 watcher plus checks that make failures loud.
Python 3.9+ stdlib. Needs shopwatch.py (lecture 5) in the same folder. Local practice shop only.
"""
import json, logging, os, random, re, socket, sqlite3, sys, time
import urllib.error, urllib.request
from email.utils import parsedate_to_datetime
from urllib.parse import urljoin
import shopwatch as sw
logging.Formatter.converter = time.gmtime # log in UTC
logging.basicConfig(format="%(asctime)sZ %(levelname)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S", level=logging.INFO)
log = logging.getLogger("shopwatch")
RETRY_STATUS = {429, 500, 502, 503, 504}
rng = random.Random(42) # fixed seed for the jitter
class GiveUp(Exception):
pass
def retry_after(value):
if not value: return None
if value.isdigit(): return int(value)
try: return max(0, parsedate_to_datetime(value).timestamp() - time.time())
except (TypeError, ValueError): return None
def fetch_bytes(url, retries=3, timeout=2, metrics=None):
for attempt in range(retries + 1):
try:
req = urllib.request.Request(url, headers={"User-Agent": sw.UA})
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read(), r.headers
except urllib.error.HTTPError as e:
metrics["http_" + str(e.code)] = metrics.get("http_" + str(e.code), 0) + 1
if e.code not in RETRY_STATUS: raise GiveUp(f"{e.code} {url}") # 403/404: retrying won't help
wait = retry_after(e.headers.get("Retry-After"))
except (urllib.error.URLError, socket.timeout) as e:
metrics["net_errors"] = metrics.get("net_errors", 0) + 1
wait = None
if attempt == retries: raise GiveUp(f"gave up after {retries + 1} tries: {url}")
wait = min(60, wait if wait is not None else 2 ** attempt + rng.uniform(0, 0.5))
metrics["retries"] = metrics.get("retries", 0) + 1
log.warning("retry %d for %s in %.1fs", attempt + 1, url, wait)
time.sleep(wait)
META = re.compile(rb'<meta[^>]+charset=["\']?([A-Za-z0-9_-]+)', re.I)
def decode(body, headers, metrics):
meta = META.search(body[:2048])
tried = [headers.get_content_charset(), # 1. HTTP header (can lie)
meta.group(1).decode() if meta else None, # 2. <meta charset> near the top
"utf-8"] # 3. default
for i, cs in enumerate(c for c in tried if c):
try:
text = body.decode(cs)
except (UnicodeDecodeError, LookupError):
continue
if i: # the first candidate failed: count it so the run report shows it
metrics["encoding_fallback"] = metrics.get("encoding_fallback", 0) + 1
log.warning("header charset did not fit the bytes; decoded as %s", cs)
return text
metrics["encoding_fallback"] = metrics.get("encoding_fallback", 0) + 1
log.warning("no declared charset fits; decoding as cp1252")
return body.decode("cp1252", errors="replace")
SKU = re.compile(r"^SKU-\d{4}$")
def check_item(raw):
"""Return a list of problems with one raw record (empty list = OK)."""
p = []
if not SKU.match(raw.get("sku") or ""): p.append("sku")
if not (raw.get("name") or "").strip(): p.append("name")
if not re.fullmatch(r"\$\d{1,3}(,\d{3})*\.\d{2}", (raw.get("price") or "").strip()): p.append("price")
if (raw.get("stock") or "").strip() not in ("In stock", "Sold out"): p.append("stock")
return p
def run(start, expected_min, max_pages=50, retries=3):
m = {"pages": 0, "items": 0, "bad_items": 0, "errors": 0}
t0, url, good = time.perf_counter(), start, []
while url and m["pages"] < max_pages:
try:
body, headers = fetch_bytes(url, retries=retries, metrics=m)
except GiveUp as e: # without this page we have no "next" link
log.error("%s - stopping this run", e); m["errors"] += 1
break
p = sw.ListParser(); p.feed(decode(body, headers, m)); m["pages"] += 1
for raw in p.items:
problems = check_item(raw)
if problems:
m["bad_items"] += 1
if m["bad_items"] <= 3: log.error("bad record %s: %s", raw.get("sku"), ",".join(problems))
else:
good.append(sw.normalize(raw, url))
m["items"] += len(p.items)
url = urljoin(url, p.next) if p.next else None
if url: time.sleep(0.2) # local demo; respect Crawl-delay for real sites
m["secs"] = round(time.perf_counter() - t0, 2)
alarms = []
if m["items"] == 0: alarms.append("ZERO_ITEMS")
elif m["items"] < expected_min: alarms.append(f"FEW_ITEMS<{expected_min}")
if m["bad_items"]: alarms.append(f"BAD_ITEMS={m['bad_items']}")
if m["errors"]: alarms.append(f"FETCH_ERRORS={m['errors']}")
m["alarms"] = alarms
print(json.dumps(m, sort_keys=True)) # one metrics line per run
return good, alarms
if __name__ == "__main__":
start = sys.argv[1] if len(sys.argv) > 1 else sw.BASE + "/products?page=1"
good, alarms = run(start, expected_min=int(sys.argv[2]) if len(sys.argv) > 2 else 150)
if alarms:
log.error("ALARM %s - nothing stored, yesterday's data kept", " ".join(alarms))
sys.exit(2) # cron + monitoring see a non-zero exit
db = sqlite3.connect(os.environ.get("SHOPWATCH_DB", "shop.db"), timeout=5)
db.executescript(sw.SCHEMA)
log.info("stored %d items, %d changes", len(good), len(sw.store(db, good, sw.utc_now())))
(측정을 짧게 하려고 데모에서는 페이지 사이에 0.2초만 기다립니다. 남의 사이트라면 5강처럼 robots.txt 의 Crawl-delay, 최소 1초를 지키세요.)
같은 세 레이아웃을 robust.py 로 돌렸습니다.
# v1 (정상)
{"alarms": [], "bad_items": 0, "errors": 0, "items": 200, "pages": 10, "secs": 1.98}
2026-11-14T23:31:23Z INFO stored 200 items, 0 changes
exit=0
# v2 (개편)
{"alarms": ["ZERO_ITEMS"], "bad_items": 0, "errors": 0, "items": 0, "pages": 10, "secs": 1.94}
2026-11-14T23:31:25Z ERROR ALARM ZERO_ITEMS - nothing stored, yesterday's data kept
exit=2
# v1b (가격 클래스 이름만 변경)
2026-11-14T23:31:26Z ERROR bad record SKU-0001: price
2026-11-14T23:31:26Z ERROR bad record SKU-0002: price
2026-11-14T23:31:26Z ERROR bad record SKU-0003: price
{"alarms": ["BAD_ITEMS=200"], "bad_items": 200, "errors": 0, "items": 200, "pages": 10, "secs": 1.96}
2026-11-14T23:31:28Z ERROR ALARM BAD_ITEMS=200 - nothing stored, yesterday's data kept
exit=2
망가진 두 레이아웃 모두 잡혔고, 잘못된 데이터는 DB 에 들어가지 않았고, 종료 코드가 cron 에 전달됩니다. 비용은? 실제 레코드 200개에 check_item() 을 1,000번 반복해 5회 잰 중앙값은 레코드당 0.99 µs 였습니다. normalize() 는 약 6.7 µs, 예의 있는 대기 시간은 페이지당 약 1초입니다. 검사는 사실상 공짜입니다.
expected_min 정하는 법: 자기 메트릭 줄에 남은 건수에서 출발합니다. 상품 200개짜리 쇼핑몰에 150을 썼습니다. 이전 건수를 저장해 두면 "어제의 절반 미만" 같은 기준도 쓸 수 있습니다. 그리고 레이아웃마다 저장한 HTML 한 장씩을 테스트 픽스처로 두세요. 저장한 페이지로 파서를 돌리는 단위 테스트가 있으면, 배포 전에 파서와 사이트가 아직 맞는지 알 수 있습니다.
인코딩 변종: 헤더가 거짓말할 때
공공기관이나 오래된 사이트에서는 아직 EUC-KR 페이지를 만날 수 있고, 같은 문제가 그대로 생깁니다. 연습 쇼핑몰은 일부러 깨진 페이지 두 개를 줍니다. /enc/sjis 는 Shift_JIS 로 된 일본어 페이지이고 선언은 <meta charset="Shift_JIS"> 뿐입니다. PHP 에서 charset 을 보내지 않았는데도 헤더는 Content-Type: text/html;charset=UTF-8 로 도착했습니다. PHP 의 default_charset 설정이 붙인 것입니다. /enc/latin1 은 windows-1252 로 된 스페인어 페이지인데 헤더는 utf-8 이라고 합니다. 5강의 fetch() 는 헤더를 믿습니다.
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x83 in position 60: invalid start byte
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 134: invalid continuation byte
흔한 응급처치인 errors="replace" 는 이걸 조용한 손상으로 바꿉니다.
['��ド�C�g', '�ۉ��}�O', '����m�[�g�i�`�T�j']
['Caf� molido', 'Pi�ata', 'Jalape�o en lata']
위의 decode() 는 헤더, <meta> charset, UTF-8 순으로 시도하고 대체 경로로 갈 때마다 셉니다.
WARNING header charset did not fit the bytes; decoded as Shift_JIS
WARNING no declared charset fits; decoding as cp1252
/enc/sjis ['卓上ライト', '保温マグ', '方眼ノート(A5)'] {'encoding_fallback': 1}
/enc/latin1 ['Café molido', 'Piñata', 'Jalapeño en lata'] {'encoding_fallback': 1}
encoding_fallback 이 0이 아닌 실행은 그래도 한번 들여다봐야 합니다. cp1252 는 추측일 뿐입니다. 이론과 이미 깨진 문자열 복구는 깨진 글자(모지바케) 디코드, 인코드, 복구와 문자 인코딩 판별 실측에서 다룹니다.
재시도할까, 포기할까
규칙은 시간이 해결할 수 있는 것만, 상한을 두고 재시도한다입니다. 429 나 503 은 "지금은 안 된다"는 뜻입니다. Retry-After 가 있으면(초 단위 또는 HTTP 날짜) 따르고, 없으면 지수 백오프에 약간의 무작위 지터를 더해 여러 클라이언트가 동시에 재시도하지 않게 합니다(3강에서 실측했습니다). 403 이나 404 는 2초 뒤에도 그대로이고, 파싱 실패는 영원히 그대로입니다. 특히 403 은 "이 봇은 사양한다"는 뜻일 수 있습니다. 올바른 반응은 멈추고 약관을 읽는 것이지, 더 세게 시도하는 것이 아닙니다.
$ python3 robust.py 'http://127.0.0.1:18172/gone/products?page=1'
2026-11-14T23:33:01Z ERROR 403 http://127.0.0.1:18172/gone/products?page=1 - stopping this run
{"alarms": ["ZERO_ITEMS", "FETCH_ERRORS=1"], "bad_items": 0, "errors": 1, "http_403": 1, "items": 0, "pages": 0, "secs": 0.01}
2026-11-14T23:33:01Z ERROR ALARM ZERO_ITEMS FETCH_ERRORS=1 - nothing stored, yesterday's data kept
$ echo $?
2
득실을 재기 위해 /flaky/products 를 썼습니다. 3번에 1번은 Retry-After: 1 이 붙은 503, 7번에 1번은 3초 동안 멈추고, 클라이언트 타임아웃은 2초입니다. 매 실행 전에 요청 카운터를 0으로 돌리므로 실패 패턴은 매번 같습니다. 10페이지, 설정마다 3회.
| 재시도 | 건진 상품 | 실행 시간(중앙값) | 일어난 일 |
|---|---|---|---|
| 0 | 40 / 200 | 0.43초 | 3페이지의 첫 503 에서 멈춤 |
| 1 | 80 / 200 | 5.90초 | 503 뒤 타임아웃으로 한 번뿐인 재시도를 다 씀 |
| 3까지 | 200 / 200 | 14.34초 | 503 5번, 타임아웃 2번에 대해 재시도 7번 |
3회 재시도 실행 로그에 대기 시간이 보입니다. Retry-After: 1 이면 1.0초, 헤더 없는 타임아웃에는 지터 섞인 백오프(두 번째 시도에서 2.3초)입니다.
WARNING retry 1 for http://127.0.0.1:18172/flaky/products?page=3 in 1.0s
WARNING retry 1 for http://127.0.0.1:18172/flaky/products?page=5 in 1.0s
WARNING retry 2 for http://127.0.0.1:18172/flaky/products?page=5 in 2.3s
...
{"alarms": [], "bad_items": 0, "errors": 0, "http_503": 5, "items": 200, "net_errors": 2, "pages": 10, "retries": 7, "secs": 14.33}
목록 페이지 하나가 실패했다고 왜 실행 전체를 멈출까요? "다음" 링크가 그 페이지에 있기 때문입니다. page=N+1 을 추측하면 연습 쇼핑몰에서는 되지만, 실제 사이트에서는 링크된 적 없는 URL 로 들어갈 수 있습니다. DB 에서 꺼낸 상세 페이지처럼 서로 독립된 URL 이라면 연속 실패를 세다가, 예를 들어 세 번 연속이면 멈춥니다. 사이트가 죽어 있을 때 계속 두드려 봐야 아무에게도 득이 없습니다.
누군가 읽게 될 로그와 메트릭
- 실행마다 JSON 한 줄. 페이지 수, 건수, 불량 건수, HTTP 상태별 횟수, 재시도, 인코딩 대체, 소요 시간, 알람.
metrics.jsonl에 덧붙여 두면 "실행별 건수" 그래프는 Python 몇 줄로 그릴 수 있고, 이미 쓰는 모니터링 도구로 보낼 수도 있습니다. - 로그 타임스탬프는 UTC(
logging.Formatter.converter = time.gmtime과Z). 5강 데이터와 같은 시계입니다. - 종료 코드를 약속으로. 0 = 저장 완료, 2 = 알람, 그 밖 = 크래시. cron 은 비어 있지 않은 출력을 메일로 보낼 수 있고, 래퍼 스크립트로 종료 코드 2를 메신저 알림으로 바꿀 수도 있습니다. 래퍼 쪽은 셸 스크립트 견고하게 만들기: cron 과 로그를 보세요.
- 불량 레코드는 전부가 아니라 처음 몇 건만 로그에. 세 건이면 "깨진 건 price" 라는 걸 알 수 있습니다.
- 알람은 드물고 진짜여야 합니다. 매일 울리는 알람은 무시됩니다. 누군가의 휴대폰에 연결하기 전에 일주일치 메트릭으로 기준값을 다듬으세요.
자바스크립트로 그려지는 페이지: JSON 부터 찾는다
거의 빈 채로 도착해서 브라우저 안에서 스스로를 채우는 페이지가 있습니다. 연습 쇼핑몰의 /app 이 그렇습니다.
/app bytes: 339 items found: 0 | root text: Loading...
/api/products: pages 10 items 20 {'sku': 'SKU-0001', 'name': 'Desk lamp 1', 'price_cents': 6279, 'in_stock': True}
urllib 이 받는 건 339바이트짜리 껍데기와 "Loading..." 이라는 글자뿐입니다. 페이지의 스크립트는 /api/products?page=1 을 부릅니다. 실제 브라우저라면 개발자 도구의 네트워크 탭(Fetch/XHR 로 필터)에서 그 호출을 찾아 JSON 을 바로 읽을 수 있습니다. 구조화돼 있고 작고, HTML 파싱이 필요 없고, 가격은 처음부터 센트 단위입니다. 여기서는 브라우저를 쓰지 않았으므로 이 단계는 설명만 하고 실행하지 않았습니다. 그런 엔드포인트를 쓰기 전에 약관을 확인하세요. 공개되지 않은 엔드포인트는 초대장이 아니고, 공공데이터라면 같은 데이터가 공공데이터포털에 API 로 올라와 있는 경우가 많습니다.
헤드리스 브라우저(Playwright, Selenium)는 페이지의 자바스크립트를 대신 실행해 줍니다. 여기서는 설치도 실행도 하지 않았습니다. 워커마다 브라우저 하나만큼의 비용이 들고, 몇 자릿수 느리고, 상대 서버에는 무거운 방문자로 보입니다. 기본 수단이 아니라 마지막 수단으로 다루세요.
시리즈 정리: 체크리스트와 윤리 복습
스크래퍼를 정기 실행에 올리기 전에.
| 확인할 것 | 다룬 강의 |
|---|---|
| 공식 API, 피드, 내보내기가 있는가. 있으면 그걸 쓴다 | 1강 |
| robots.txt 가 그 경로를 허용하는가. Crawl-delay 는? | 1강 |
| 약관이 자동 접근과 저장을 허용하는가. 사이트 약관과 해당 지역 법을 확인 | 1강, 4강 |
| 봇이 연락처 URL 과 함께 자기 이름을 밝히는가 | 1강 |
| 대기, 429 처리, 재시도 상한이 있는가 | 3강, 6강 |
| 필요한 필드만 저장하고, 필요 없는 개인정보는 갖고 있지 않은가 | 4강, 5강 |
| 실행이 멱등하고, UTC 타임스탬프와 트랜잭션 하나로 묶였는가 | 5강 |
| 레이아웃 변경, 잘못된 인코딩, 0건이면 알람이 울리는가 | 6강 |
| 0이 아닌 종료 코드가 사람에게 전달되는가 | 6강 |
그리고 이 시리즈에서 일부러 한 번도 하지 않은 것. CAPTCHA 우회, 차단을 피하려는 IP·프록시 로테이션, 브라우저인 척하기, 남의 사이트에 로그인해서 데이터 모으기. 사이트가 안 된다고 하면 답은 안 되는 겁니다. 접근을 요청하거나 공식 경로를 쓰세요.
솔직한 한계와 버전 메모
- Python 3.9.6(표준 라이브러리)과 PHP 8.5.6 내장 서버로 실행했습니다. 레이아웃, 인코딩, 장애는 모두 우리 연습 사이트가 만든 것이고 실제 쇼핑몰에서 관찰한 것이 아닙니다. 실제 개편은 부분적이고 점진적(A/B 테스트)인 경우가 많아서 레코드 단위 검사가 더 중요합니다.
- Python 의
shift_jis코덱은 Windows 의 cp932 보다 엄격합니다. 한국어 쪽도euc-kr은cp949보다 좁아서, 확장 한글이 들어간 페이지는cp949가 필요할 수 있습니다. 여기서 확인한 건 보여 준 상품명 다섯 개뿐입니다. requests, BeautifulSoup, Scrapy, Playwright 는 쓰지 않았습니다. Scrapy 에는 재시도와 자동 속도 조절 미들웨어가 있고,requests에는response.apparent_encoding이 있습니다. 공식 문서를 근거로 한 설명이며 여기서는 실행하지 않았습니다.- 시간은 루프백 값입니다. 실제 네트워크에서는 재시도마다 지연이 더해집니다. 지터는
random.Random(42)라서 숫자가 재현됩니다.
연습 문제
- "지난 실행보다 너무 적다" 검사를 추가해 보세요.
metrics.jsonl의 마지막 줄을 읽고, 오늘items가 그 절반 미만이면 알람을 냅니다. - 그날 세 번째 실행 이후로 사이트가 모든 페이지에
403을 돌려주기 시작했습니다. User-Agent 를 바꿔서 재시도해야 할까요? 어떻게 해야 할까요?
정답
run()전에 마지막 줄을 읽습니다.last = json.loads(open("metrics.jsonl").read().splitlines()[-1])["items"](파일이 없을 때 처리 포함). 실행 뒤에if m["items"] < last / 2: alarms.append("DROP_VS_LAST")를 하고json.dumps(m)을 파일에 덧붙입니다. 고정된expected_min도 남겨 두세요. 개편 직후 첫 실행이 망가지면 다음 날에는 "어제의 절반" 기준을 통과해 버리기 때문입니다.- 아닙니다. 차단을 피하려고 User-Agent 를 바꾸는 건 우회 행위입니다. 실행을 멈추고(
GiveUp이 이미 그렇게 합니다), 알람을 내고, robots.txt 와 약관을 확인하고, 빈도를 낮추고, 사이트 운영자에게 연락하거나 공식 API 를 쓰세요.
시리즈 안내
- 이전 강의: 웹 스크래핑 강좌 5/6: 변화 감지와 SQLite 저장
- 처음으로: 웹 스크래핑 강좌 1/6: robots.txt 와 urllib
- 시리즈의 다른 강의: 2강: HTML 파싱, 3강: 페이지 넘기기와 속도 제한, 4강: 세션, 폼, CSRF
- HTTP 클라이언트가 리다이렉트를 따라가지 않은 이유: Dart http 에서 POST 리다이렉트가 따라가지지 않을 때
- 전체 강좌: 프로그래밍 학습 로드맵
자주 묻는 질문
스크래퍼가 에러 없이 0건이라고만 보고하는 이유는 뭔가요?
HTML 파서에게 아무것도 못 찾은 것은 에러가 아니기 때문입니다. 연습 쇼핑몰을 새 레이아웃으로 바꾸자 5강의 감시 스크립트는 10페이지를 모두 가져오고 상품 0건, 종료 코드 0으로 끝났습니다. 0건이나 예상보다 적은 건수를 실패로 보고 0이 아닌 종료 코드를 내는 실행 단위 검사를 넣어야 cron 과 모니터링이 알아챕니다.
스크래퍼는 어떤 에러를 재시도해야 하나요?
시간이 해결할 수 있는 것만입니다. 429 Too Many Requests, 503 같은 5xx, 타임아웃, 연결 거부를 Retry-After 나 지수 백오프로, 횟수 상한을 두고 재시도합니다. 403, 404, 400 은 재시도하지 말고, 파싱이나 스키마 실패는 절대 재시도하지 않습니다. 불안정한 엔드포인트에서 재시도 없음은 200건 중 40건, 최대 3회는 200건 전부를 14.34초에 얻었습니다.
자바스크립트로 그려지는 페이지는 어떻게 수집하나요?
먼저 브라우저 개발자 도구의 네트워크 탭을 봅니다. 페이지는 대개 JSON 엔드포인트에서 데이터를 불러오고, 그쪽이 읽기 쉬우며 API 로 공개된 경우도 많습니다. 연습 페이지에서 urllib 에는 'Loading...' 과 상품 0건만 보였고, JSON 엔드포인트는 페이지당 20건을 돌려줬습니다. 쓰기 전에 약관을 확인하세요. 헤드리스 브라우저는 마지막 수단입니다.