8583465c58
- B-4 fetch_method='signal-only': 페이지 fetch 0 + summarize 스킵(검색 색인만, 맥미니 부하 0) + 본문 무절단(_entry_body — arXiv 초록 1.6K 보존). 다이제스트는 ai_summary NULL 제외 규칙으로 자연 배제. 레지스트리 오설정(page) 방어 가드. - 시드 9 소스 (전 URL 2026-06-11 live 검증): Bloomberg Markets/Technology(skip-video, 비디오 혼재 실측)·Economist Latest·Nikkei Asia(RDF — feedparser 네이티브, 분기 불요 fixture 박제)·ASME JPVT(site_1000037 실측 매핑)·arXiv 2종·IEEE Spectrum 2종(feed-full, 피드 description 이 전문 7.9~14K자 실측). - csb_collector: sitemap lastmod diff (weekly 월 06:50) — 워터마크(selector_override) + cap 40/회 점진 백필 + diff sanity 300 + 보고서 PDF(/assets/, recommendation 제외) → extract 파이프라인. 초기 일괄 = CLI --bulk. - api_standards_collector: 공지 목록 링크 파싱(실측 — 페이지 diff 아님, 상세 URL 10건/페이지) → 신규 상세만 ingest (monthly 5일 07:05). 초기 백필 = CLI --bulk. - ccps_collector: aiche.org 평문 403(UA 무관 실측) → playwright-fetcher 익명 컨텍스트 + referer 쿠키 승계 /download(base64) 신설로 월간 Beacon PDF (monthly 5일 07:20). 헤드리스 차단 시 CrawlBlocked → health 가시화 (르몽드 PARK 선례). - B-5 잔여: rdf/feed-reader-UA = 코드 분기 불요 실측 박제 (Economist 는 Archiver UA 200). table-strip/gn-redirect 는 해당 소스 미진입 — 백로그 유지. - 테스트 24건 신규 (fixture 9건 live 박제, economist/ieee 는 item trim) — 39 passed. - 마이그 327 단일 statement (PKM 트랙과 번호 경합 주의 — 327 본 트랙 선점). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
344 lines
15 KiB
Python
344 lines
15 KiB
Python
"""크롤링 politeness 코어 (A-4, plan crawl-24x7-1)
|
||
|
||
개인 아카이빙 권장치를 그대로 박은 공용 fetch 계층:
|
||
- per-domain 동시성 1 (asyncio.Lock) + 같은 도메인 연속 요청 5–15초 지연 + jitter
|
||
- robots.txt 존중 (urllib.robotparser, 24h 캐시) — 비로그인 공개 크롤링 한정.
|
||
로그인 세션 fetch (B-3) 는 사용자 행위 성격이라 robots 대신 사람 속도가 기준.
|
||
- 정직 식별 UA + 연락처 (익명 크롤링 트랙. 로그인 세션은 브라우저 UA 유지 — B-3)
|
||
- 429 = Retry-After 존중 / 5xx = 재시도 가능 / 403 = 차단 신호 (호출측 circuit 연동)
|
||
|
||
도메인별 마지막 요청 시각 등 rate 상태는 in-process (영속 워터마크는 DB — news_sources).
|
||
SSRF 차단은 core.url_validator.validate_feed_url 재사용 (redirect target 재검증 포함).
|
||
"""
|
||
|
||
import asyncio
|
||
import base64
|
||
import random
|
||
import time
|
||
import urllib.robotparser
|
||
from urllib.parse import urljoin, urlparse
|
||
|
||
import httpx
|
||
|
||
from core.url_validator import validate_feed_url
|
||
from core.utils import setup_logger
|
||
|
||
# bare getLogger 는 root(WARNING) 상속이라 INFO 대기/차단 로그가 드랍됨 — 타 워커와 동일 설정
|
||
logger = setup_logger("crawl_politeness")
|
||
|
||
# 정직 식별 UA + 연락처 — 차단 전 연락 통로 (A-4)
|
||
CRAWL_UA = "HyungiPKM-Archiver/1.0 (personal archive; +mailto:hyun49196@gmail.com)"
|
||
|
||
# 같은 도메인 연속 요청 간격 (초) — 권장치 5–15s + jitter
|
||
_DOMAIN_DELAY_MIN = 5.0
|
||
_DOMAIN_DELAY_MAX = 15.0
|
||
|
||
# 구독 세션(브라우저) fetch 간격 — 사람 속도 (B-3 ④: 기사 간 수십 초)
|
||
_AUTH_DELAY_MIN = 30.0
|
||
_AUTH_DELAY_MAX = 60.0
|
||
|
||
# B-3 Playwright 격리 컨테이너 (internal-only, compose DNS)
|
||
_FETCHER_URL = "http://playwright-fetcher:3400"
|
||
_FETCHER_TIMEOUT = 120.0 # 브라우저 기동 + 네비게이션 + settle 포함
|
||
|
||
# 안티봇 챌린지 페이지 식별 마커 (DataDome/Cloudflare 등) — 좁게 유지(오탐 회피).
|
||
# 실측: 르몽드 기사 = DataDome "Client Challenge" + "Entrez les caractères" CAPTCHA.
|
||
_CHALLENGE_MARKERS = (
|
||
"Client Challenge",
|
||
"Entrez les caractères affichés",
|
||
"Checking your browser before",
|
||
"captcha-delivery.com",
|
||
"geo.captcha-delivery",
|
||
)
|
||
|
||
_ROBOTS_CACHE_TTL = 24 * 3600 # 24h
|
||
_MAX_PAGE_BYTES = 5 * 1024 * 1024 # 피드 fetch 와 동일 5MB cap
|
||
_PAGE_TIMEOUT = 20.0
|
||
_MAX_REDIRECTS = 3
|
||
|
||
_HTML_CONTENT_TYPES = ("text/html", "application/xhtml+xml")
|
||
|
||
|
||
class CrawlFetchError(Exception):
|
||
"""일시 오류 (5xx / timeout / 네트워크) — 큐 재시도 대상."""
|
||
|
||
|
||
class CrawlBlocked(Exception):
|
||
"""차단 신호 (403 / 429 / robots disallow) — 재시도보다 backoff/circuit 대상."""
|
||
|
||
|
||
class CrawlSkip(Exception):
|
||
"""영구 비대상 (비-HTML / 크기 초과 / SSRF 차단 / 4xx) — 격하 처리 대상."""
|
||
|
||
|
||
# 도메인별 직렬화 상태 (in-process)
|
||
_domain_locks: dict[str, asyncio.Lock] = {}
|
||
_domain_last_request: dict[str, float] = {}
|
||
# host → (cached_at, RobotFileParser | None). None = robots 없음/4xx (전부 허용)
|
||
_robots_cache: dict[str, tuple[float, urllib.robotparser.RobotFileParser | None]] = {}
|
||
|
||
|
||
def _domain_of(url: str) -> str:
|
||
return (urlparse(url).hostname or "").lower()
|
||
|
||
|
||
def _get_lock(domain: str) -> asyncio.Lock:
|
||
if domain not in _domain_locks:
|
||
_domain_locks[domain] = asyncio.Lock()
|
||
return _domain_locks[domain]
|
||
|
||
|
||
async def _respect_domain_rate(
|
||
domain: str,
|
||
delay_min: float = _DOMAIN_DELAY_MIN,
|
||
delay_max: float = _DOMAIN_DELAY_MAX,
|
||
) -> None:
|
||
"""같은 도메인 직전 요청에서 delay(jitter) 경과할 때까지 대기."""
|
||
last = _domain_last_request.get(domain)
|
||
if last is not None:
|
||
delay = random.uniform(delay_min, delay_max)
|
||
wait = last + delay - time.monotonic()
|
||
if wait > 0:
|
||
# silent sleep 금지 — politeness 동작 검증·운영 관찰 가시성
|
||
logger.info("[politeness] %s %.1fs 대기", domain, wait)
|
||
await asyncio.sleep(wait)
|
||
|
||
|
||
async def _fetch_robots(client: httpx.AsyncClient, scheme: str, host: str):
|
||
"""robots.txt 조회. 4xx/부재 = 전부 허용(None), 5xx/오류 = 보수적으로 이번 사이클 차단."""
|
||
robots_url = f"{scheme}://{host}/robots.txt"
|
||
try:
|
||
resp = await client.get(robots_url, headers={"User-Agent": CRAWL_UA})
|
||
except httpx.HTTPError as e:
|
||
raise CrawlFetchError(f"robots.txt 조회 실패: {host}: {e}") from e
|
||
if resp.status_code >= 500:
|
||
# 5xx 는 의도 불명 — 표준 관행대로 이번 사이클은 차단 취급
|
||
raise CrawlFetchError(f"robots.txt 5xx: {host}: {resp.status_code}")
|
||
if resp.status_code >= 400:
|
||
return None # robots 없음 = 전부 허용
|
||
rp = urllib.robotparser.RobotFileParser()
|
||
rp.parse(resp.text.splitlines())
|
||
return rp
|
||
|
||
|
||
async def _robots_allows(client: httpx.AsyncClient, url: str) -> bool:
|
||
parsed = urlparse(url)
|
||
host = (parsed.hostname or "").lower()
|
||
cached = _robots_cache.get(host)
|
||
if cached is None or time.monotonic() - cached[0] > _ROBOTS_CACHE_TTL:
|
||
rp = await _fetch_robots(client, parsed.scheme or "https", host)
|
||
_robots_cache[host] = (time.monotonic(), rp)
|
||
cached = _robots_cache[host]
|
||
rp = cached[1]
|
||
if rp is None:
|
||
return True
|
||
return rp.can_fetch(CRAWL_UA, url)
|
||
|
||
|
||
async def fetch_page(
|
||
url: str, *, check_robots: bool = True,
|
||
content_types: tuple[str, ...] = _HTML_CONTENT_TYPES,
|
||
) -> tuple[str, str]:
|
||
"""공개 페이지 1건 politeness fetch. (html_text, final_url) 반환.
|
||
|
||
- SSRF 검증 (redirect target 포함, news_collector 피드 fetch 와 동일 이중 검증)
|
||
- per-domain 동시성 1 + 5–15s jitter 지연
|
||
- 429: Retry-After 로그 후 CrawlBlocked / 403: CrawlBlocked / 그 외 4xx: CrawlSkip
|
||
- 5xx/timeout: CrawlFetchError (큐 재시도)
|
||
- 비-HTML content-type / 5MB 초과: CrawlSkip
|
||
"""
|
||
try:
|
||
validate_feed_url(url)
|
||
except ValueError as e:
|
||
raise CrawlSkip(f"URL 검증 실패: {e}") from e
|
||
|
||
domain = _domain_of(url)
|
||
async with _get_lock(domain):
|
||
await _respect_domain_rate(domain)
|
||
try:
|
||
async with httpx.AsyncClient(
|
||
timeout=_PAGE_TIMEOUT, follow_redirects=False,
|
||
headers={"User-Agent": CRAWL_UA},
|
||
) as client:
|
||
if check_robots and not await _robots_allows(client, url):
|
||
raise CrawlBlocked(f"robots.txt disallow: {url}")
|
||
|
||
resp = await client.get(url)
|
||
redirects = 0
|
||
# has_redirect_location = location 헤더 있는 진짜 redirect 만 (httpx 의
|
||
# is_redirect 는 3xx 전체라 304 등을 redirect 로 오인 — news_collector 동일 함정)
|
||
while resp.has_redirect_location and redirects < _MAX_REDIRECTS:
|
||
location = urljoin(str(resp.request.url), resp.headers["location"])
|
||
try:
|
||
validate_feed_url(location)
|
||
except ValueError as e:
|
||
raise CrawlSkip(f"redirect target 차단: {e}") from e
|
||
# redirect 도 같은 도메인 연속 요청 — 간격은 lock 보유로 충분 (즉시 1회)
|
||
resp = await client.get(location)
|
||
redirects += 1
|
||
if resp.has_redirect_location:
|
||
raise CrawlSkip(f"redirect {_MAX_REDIRECTS}회 초과: {url}")
|
||
except httpx.TimeoutException as e:
|
||
raise CrawlFetchError(f"timeout: {url}") from e
|
||
except httpx.HTTPError as e:
|
||
raise CrawlFetchError(f"네트워크 오류: {url}: {e}") from e
|
||
finally:
|
||
_domain_last_request[domain] = time.monotonic()
|
||
|
||
if resp.status_code == 429:
|
||
retry_after = resp.headers.get("retry-after", "")
|
||
logger.warning("[politeness] 429 %s (Retry-After=%s)", domain, retry_after or "-")
|
||
raise CrawlBlocked(f"429 rate limited: {url} (Retry-After={retry_after or '-'})")
|
||
if resp.status_code == 403:
|
||
raise CrawlBlocked(f"403 forbidden: {url}")
|
||
if resp.status_code >= 500:
|
||
raise CrawlFetchError(f"{resp.status_code}: {url}")
|
||
if resp.status_code >= 400:
|
||
raise CrawlSkip(f"{resp.status_code}: {url}")
|
||
|
||
ct = resp.headers.get("content-type", "").lower()
|
||
if ct and not any(t in ct for t in content_types):
|
||
raise CrawlSkip(f"비허용 content-type: {ct}: {url}")
|
||
if len(resp.content) > _MAX_PAGE_BYTES:
|
||
raise CrawlSkip(f"크기 초과: {len(resp.content)} bytes: {url}")
|
||
|
||
return resp.text, str(resp.request.url)
|
||
|
||
|
||
# ── B-3 구독 세션 fetch (Playwright 격리 컨테이너 경유) ──────────────────────
|
||
|
||
async def fetch_page_via_browser(url: str, profile: str | None) -> tuple[str, str]:
|
||
"""브라우저 페이지 1건 — playwright-fetcher 에 위임, politeness 는 사람 속도(30~60s).
|
||
|
||
profile=None = 익명 컨텍스트 (사이클 3 — 평문 httpx 를 UA 무관 403 하는 공개
|
||
사이트의 WAF 우회 전용, CCPS aiche.org 실측). 값 = B-3 구독 세션.
|
||
(html_text, final_url) 반환. robots 미적용 — 구독 fetch 는 사용자 행위 성격,
|
||
익명 WAF 우회는 월간 1~2회 저빈도 + 사람 속도가 보호 장치.
|
||
예외 어휘는 fetch_page 와 동일 (호출측 분기 재사용).
|
||
"""
|
||
try:
|
||
validate_feed_url(url)
|
||
except ValueError as e:
|
||
raise CrawlSkip(f"URL 검증 실패: {e}") from e
|
||
|
||
payload = {"url": url}
|
||
if profile:
|
||
payload["profile"] = profile
|
||
|
||
domain = _domain_of(url)
|
||
async with _get_lock(domain):
|
||
await _respect_domain_rate(domain, _AUTH_DELAY_MIN, _AUTH_DELAY_MAX)
|
||
try:
|
||
async with httpx.AsyncClient(timeout=_FETCHER_TIMEOUT) as client:
|
||
resp = await client.post(f"{_FETCHER_URL}/fetch", json=payload)
|
||
except httpx.TimeoutException as e:
|
||
raise CrawlFetchError(f"browser fetch timeout: {url}") from e
|
||
except httpx.HTTPError as e:
|
||
raise CrawlFetchError(f"playwright-fetcher 연결 오류: {e}") from e
|
||
finally:
|
||
_domain_last_request[domain] = time.monotonic()
|
||
|
||
if resp.status_code == 503:
|
||
# storage_state 부재 — 수동 세션 박제 대기 (호출측 degrade, 재시도 루프 금지)
|
||
raise CrawlBlocked(f"세션 프로필 부재: {profile}")
|
||
if resp.status_code != 200:
|
||
raise CrawlFetchError(f"playwright-fetcher {resp.status_code}: {url}")
|
||
data = resp.json()
|
||
html_text = data.get("html", "")
|
||
if len(html_text.encode("utf-8", errors="replace")) > _MAX_PAGE_BYTES:
|
||
raise CrawlSkip(f"크기 초과 (browser): {url}")
|
||
# 안티봇 챌린지 페이지(DataDome 등) 식별 — 본문 길이 게이트(200자)를 통과하는
|
||
# 짧은 챌린지 HTML 이 기사 본문으로 승격되는 silent corruption 차단. 헤드리스 탐지라
|
||
# 재시도 무의미 → CrawlBlocked(=degrade, RSS 요약 유지). 마커는 보수적으로 좁게.
|
||
if any(m in html_text for m in _CHALLENGE_MARKERS):
|
||
raise CrawlBlocked(f"안티봇 챌린지 페이지(headless 차단): {url}")
|
||
return html_text, data.get("final_url", url)
|
||
|
||
|
||
_MAX_DOWNLOAD_BYTES = 60 * 1024 * 1024 # fetcher MAX_DOWNLOAD_BYTES 와 동률
|
||
|
||
|
||
async def download_via_browser(
|
||
url: str, *, referer: str | None = None, profile: str | None = None
|
||
) -> tuple[bytes, str]:
|
||
"""바이너리(PDF) 1건 — fetcher /download 위임. (content, content_type) 반환.
|
||
|
||
referer = WAF 챌린지 쿠키를 먼저 획득할 목록 페이지 (CCPS Beacon 패턴).
|
||
내부 status 판정: 403/429 = CrawlBlocked, 그 외 4xx = CrawlSkip, 5xx = CrawlFetchError
|
||
(fetch_page 와 동일 어휘 — 호출측 분기 재사용).
|
||
"""
|
||
try:
|
||
validate_feed_url(url)
|
||
except ValueError as e:
|
||
raise CrawlSkip(f"URL 검증 실패: {e}") from e
|
||
|
||
payload: dict = {"url": url}
|
||
if referer:
|
||
payload["referer"] = referer
|
||
if profile:
|
||
payload["profile"] = profile
|
||
|
||
domain = _domain_of(url)
|
||
async with _get_lock(domain):
|
||
await _respect_domain_rate(domain, _AUTH_DELAY_MIN, _AUTH_DELAY_MAX)
|
||
try:
|
||
async with httpx.AsyncClient(timeout=_FETCHER_TIMEOUT) as client:
|
||
resp = await client.post(f"{_FETCHER_URL}/download", json=payload)
|
||
except httpx.TimeoutException as e:
|
||
raise CrawlFetchError(f"browser download timeout: {url}") from e
|
||
except httpx.HTTPError as e:
|
||
raise CrawlFetchError(f"playwright-fetcher 연결 오류: {e}") from e
|
||
finally:
|
||
_domain_last_request[domain] = time.monotonic()
|
||
|
||
if resp.status_code == 503:
|
||
raise CrawlBlocked(f"세션 프로필 부재: {profile}")
|
||
if resp.status_code != 200:
|
||
raise CrawlFetchError(f"playwright-fetcher {resp.status_code}: {url}")
|
||
data = resp.json()
|
||
inner = int(data.get("status", 0))
|
||
if inner in (403, 429):
|
||
raise CrawlBlocked(f"{inner} (browser download): {url}")
|
||
if 400 <= inner < 500:
|
||
raise CrawlSkip(f"{inner} (browser download): {url}")
|
||
if inner != 200:
|
||
raise CrawlFetchError(f"{inner} (browser download): {url}")
|
||
content = base64.b64decode(data.get("body_b64", ""))
|
||
if len(content) > _MAX_DOWNLOAD_BYTES:
|
||
raise CrawlSkip(f"크기 초과 (browser download): {url}")
|
||
return content, data.get("content_type", "")
|
||
|
||
|
||
async def probe_session(
|
||
profile: str, probe_url: str, min_body_chars: int, paywall_markers: list[str]
|
||
) -> dict:
|
||
"""내용 기반 세션 probe (B-3 ②) — {'ok': bool, 'reason': str|None, 'body_chars': int}.
|
||
|
||
실패를 예외가 아닌 값으로 반환 — 호출측이 source_health 에 기록하고 degrade 분기.
|
||
probe 도 실제 publisher fetch 라 동일 도메인 lock + 사람 속도 적용.
|
||
"""
|
||
domain = _domain_of(probe_url)
|
||
async with _get_lock(domain):
|
||
await _respect_domain_rate(domain, _AUTH_DELAY_MIN, _AUTH_DELAY_MAX)
|
||
try:
|
||
async with httpx.AsyncClient(timeout=_FETCHER_TIMEOUT) as client:
|
||
resp = await client.post(
|
||
f"{_FETCHER_URL}/probe",
|
||
json={
|
||
"profile": profile,
|
||
"probe_url": probe_url,
|
||
"min_body_chars": min_body_chars,
|
||
"paywall_markers": paywall_markers,
|
||
},
|
||
)
|
||
except httpx.HTTPError as e:
|
||
return {"ok": False, "reason": f"fetcher 연결 오류: {e}", "body_chars": 0}
|
||
finally:
|
||
_domain_last_request[domain] = time.monotonic()
|
||
|
||
if resp.status_code == 503:
|
||
return {"ok": False, "reason": f"세션 프로필 부재: {profile}", "body_chars": 0}
|
||
if resp.status_code != 200:
|
||
return {"ok": False, "reason": f"fetcher {resp.status_code}", "body_chars": 0}
|
||
return resp.json()
|