- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes) - 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에 안내용 README.md 추가 - CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가 - docs/README.md 문서 지도 갱신
2464 lines
188 KiB
Markdown
2464 lines
188 KiB
Markdown
# 봇 차단 원리 · 회피 아닌 준수 전략 · 법적 검토
|
||
|
||
> ### ⚠️ 이 문서의 현재 지위: 참고 자료 (구현 대상 아님)
|
||
>
|
||
> 이 문서는 데이터 소스 결정 이전에 작성되었다. 이후 **이 프로젝트는 HTML 크롤링과 봇 차단 우회를 하지 않기로 확정**했다. 식약처가 동일 데이터를 공식 Open API 로 공개하고 있기 때문이다. 결정 근거는 [`design/00-DATA-SOURCE-DECISION.md`](../design/00-DATA-SOURCE-DECISION.md).
|
||
>
|
||
> **그럼에도 이 문서를 보존하는 이유**
|
||
> - 향후 API 가 없는 보조 소스를 붙일 때 "정중한 접근" 규칙(요청 빈도, UA 명시, 백오프, 조건부 요청)이 그대로 필요하다.
|
||
> - 우리가 왜 우회 경로를 택하지 않았는지의 근거 자료다.
|
||
> - 법적 리스크·판례·공공누리 표시에 관한 서술(0절의 결론 5~8, 10)은 **여전히 유효하며 구현에 직접 적용된다.**
|
||
>
|
||
> **여전히 유효한 절**: `0. 한눈에 보기`의 결론 5(법적 리스크 등급)·6(민사 리스크·판례)·7(공공누리 출처표시)·8(정중함 파라미터)·10(회피하지 않는다는 원칙), `6. 정중한 크롤러 체크리스트`(파라미터·코드는 공식 API 호출에도 그대로 적용), `8. 법적 검토`(전체 유효). `4. 한국 공공기관 보호 패턴과 nedrug 실측`·`5. RFC 9309 규범`·`9. 차단 진단 절차`는 부분 참고(HTML 크롤링 전제 부분 제외, 상세는 §3.6 다음의 "이 문서와 확정 설계의 관계" 표).
|
||
> **구현하지 않는 절**: `0. 한눈에 보기`의 결론 1·2·4·9(HTML 직접 크롤링·스텔스 회피 관련 부분), `2. 봇 탐지의 5개 계층` 전체(2.1~2.7), `3. 주요 WAF·봇관리 벤더의 동작 방식` 전체(3.1~3.6), `7. 도구 비교표`(조사 결과 보존 목적으로만 유지), `10. 최종 수집 아키텍처 결정`(`design/00-DATA-SOURCE-DECISION.md` 가 대체).
|
||
>
|
||
> ℹ️ **문서 상태 (2026-09-02 복원 완료)**: 원 작성은 `3.6 벤더 비교 요약` 직후 중단돼 있었다. 이후 복원 작업으로 **4·5·6·7·8·9·10절과 부록 A·B 를 원본 조사 raw dump 로부터 전량 복원**했다. 이제 `1. 목차`가 예고한 모든 절이 파일에 존재하며, 본문의 `§4.2`·`§9` 등 교차 참조도 전부 확인 가능하다. 부록 A 는 조사에 사용된 **224개 URL 전량**을, 부록 B 는 실측·확인이 남은 항목의 체크리스트를 담는다.
|
||
|
||
---
|
||
|
||
> **이 문서의 역할**: DMF_Crawler 가 nedrug.mfds.go.kr 등 한국 공공기관 사이트를 매일 1회 수집할 때, "어떤 기술로 탐지되는가 → 그래서 우리는 무엇을 하고 무엇을 하지 않는가 → 법적으로 어디까지가 안전선인가 → 차단됐을 때 어떻게 진단·복구하는가" 를 하나의 정본으로 확정한다.
|
||
|
||
---
|
||
|
||
## 0. 한눈에 보기
|
||
|
||
이 문서에서 내린 결론은 다음과 같다. 이후 모든 섹션은 이 결론의 근거와 구현 상세다.
|
||
|
||
> ⚠️ 이 절 중 결론 1·2·4·9(HTML 직접 크롤링, nedrug 대상 정중한 접근 실행 정책, 차단 진단·에스컬레이션 방침)는 채택하지 않음. 사유: 공식 API 채택 (design/00-DATA-SOURCE-DECISION.md)
|
||
>
|
||
> ✅ 이 절 중 결론 5·6·7·8·10(법적 리스크 등급, 판례, 공공누리 출처표시, 정중함 파라미터, 회피하지 않는다는 원칙)은 유효. 공식 API 이용에도 그대로 적용된다.
|
||
|
||
- **결론 1 — 1차 데이터 소스는 크롤링이 아니라 공식 OpenAPI 다.** `https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01` (공공데이터포털 데이터셋 ID `15057075`, 「식품의약품안전처_원료의약품등록(DMF)현황」, 이용허락범위 "제한 없음", 개발계정 일 10,000회) 가 존재한다. HTML 크롤링은 **공고 게시판(`https://nedrug.mfds.go.kr/bbs/117`, 총 710건 / 71페이지) 의 주차별 공고문·첨부파일** 처럼 API 로 대체 불가능한 부분에만 한정한다.
|
||
- **결론 2 — `https://nedrug.mfds.go.kr/robots.txt` 는 `User-agent: * / Disallow: /` 다 (2026-09-02 실측, `Last-Modified: Thu, 07 Aug 2025 07:44:40 GMT`, `Content-Length: 26`).** 즉 사이트 전역 크롤링 금지 선언이다. 동시에 `/bbs/117` HTML 안에는 `<meta name="robots" content="index,follow"/>` 가 있어 **정책이 자기모순**이다. 우리는 **보수적 해석(robots.txt 우선)** 을 채택하고, 그 결과 "robots.txt 를 무시한 무제한 크롤링"이 아니라 **OpenAPI 우선 + HTML 은 하루 1회·소수 요청·조건부 요청** 이라는 최소 접근으로 간다. robots.txt 는 RFC 9309 상 **강제력 없는 자발적 준수(voluntary compliance)** 이며 법적 금지가 아니지만, 위반 사실은 민사 분쟁에서 "고의·악의" 정황으로 쓰일 수 있으므로 문서화된 의사결정 기록을 남긴다.
|
||
- **결론 3 — nedrug 는 Cloudflare/Akamai/DataDome 급 봇 관리 제품을 쓰지 않는다 (실측).** 응답 헤더에 `cf-ray`, `__cf_bm`, `_abck`, `x-datadome` 계열이 전혀 없다. 보이는 것은 `Server: Apache`, `JSESSIONID`(`Max-Age=14400`, `.ext21/.ext31/.ext41` 노드 서픽스 = WAS 클러스터), `elevisor_for_j2ee_uid`(1년 만료, J2EE APM/모니터링 계열로 추정 ⚠️ 미검증), HSTS·XSS·nosniff 등 표준 보안 헤더뿐이다. `curl/8.10.1` 의 노골적인 UA 로도 `HTTP/1.1 200 OK` 가 나왔고 HTML 382,506 바이트를 정상 수신했다.
|
||
- **결론 4 — 따라서 스텔스 도구는 전부 불필요하고, 도입해서는 안 된다.** `curl_cffi`(TLS 임퍼소네이션), `patchright`/`rebrowser-playwright`(CDP 은폐), `nodriver`, `Camoufox`, `undetected-chromedriver` 는 **명시적으로 배제**한다. 이유는 (a) 기술적으로 불필요, (b) "탐지 회피를 위한 위장"은 법적 분쟁에서 **접근권한 제한을 우회하려는 의도**의 증거가 되어 정보통신망법 제48조 제1항 리스크를 스스로 만들어내기 때문, (c) 학술 연구(arXiv 2606.30119)가 "스텔스·안티디텍션 기법은 오히려 탐지 가능성을 높인다(Stealth and anti-detection mechanisms often increase detectability rather than decrease it)"고 보고하기 때문. **표준 `httpx` 1개 세션, 동시성 1, 하루 1회**로 간다.
|
||
- **결론 5 — 법적 리스크 등급: 낮음(Low), 단 '조건부'.** 대법원 2022. 5. 12. 선고 **2021도1533**(야놀자-여기어때 형사)은 정보통신망법 제48조 위반·저작권법 위반·컴퓨터등장애업무방해 **3개 혐의 전부 무죄를 확정**했다. 핵심 논리는 "공개된 정보 + 별도 보호조치 없음 + 이용약관의 이용제한은 접근제한이 아님 + 현실적 장애 미발생". 우리 프로젝트는 이 4개 요건을 모두 만족한다. 다만 **민사는 별개**다. 같은 사건 민사 1심(서울중앙지법 2021. 8. 19. 선고 **2018가합508729**)은 부정경쟁방지법 성과도용으로 **10억 원 배상**을 명했다. 형사 무죄 ≠ 민사 무책.
|
||
- **결론 6 — 민사 리스크를 0 에 수렴시키는 조건은 "재배포하지 않는다"이다.** 잡코리아 v. 사람인(서울고법 2017. 4. 6. 선고 **2016나2019365**, 저작권법 제93조 데이터베이스제작자 권리 침해 인정, 손해배상 2억 5,000만 원 + 간접강제금 2억 원)의 핵심은 **수집한 데이터를 자사 서비스에 게재해 경쟁**한 점이다. 우리는 **내부 xlsx 리포트 전용, 외부 공개·재판매·경쟁서비스 제공 없음**이므로 "통상적 이용과의 충돌" 및 "성과 도용"의 구성요건 자체가 성립하기 어렵다. **이 선을 넘는 순간(웹에 재게시, 사내 밖 배포, 상용 제공) 리스크 등급은 Medium 이상으로 상승한다.**
|
||
- **결론 7 — 공공누리 표시와 출처 표기는 무조건 리포트에 넣는다.** data.go.kr 의 해당 DMF API 는 "이용허락범위 제한 없음"이지만, 공공누리 제1~4유형의 공통 의무는 **출처표시**다. xlsx 리포트 첫 시트에 `출처: 식품의약품안전처, 의약품안전나라(nedrug.mfds.go.kr) / 공공데이터포털 15057075, 수집일시 …` 를 자동 삽입한다.
|
||
- **결론 8 — 정중함의 구체 파라미터는 다음으로 고정한다.** 동시성 1, 요청 간 최소 간격 2.0초(+지터 0~1.0초), 하루 1회 06:00 실행(+0~600초 랜덤 시프트), 총 요청 상한 120건/일, 타임아웃 (connect 10s / read 30s), 재시도 최대 4회 지수 백오프 base 5s·factor 2·cap 300s + full jitter, `Retry-After` 절대 우선, 연속 실패 5회 시 서킷 브레이커 OPEN(24시간), `If-Modified-Since`/`If-None-Match` 상시 사용, 세션(쿠키/커넥션) 재사용, 연락처 포함 UA.
|
||
- **결론 9 — 차단 진단은 "HTTP 상태 → 본문 시그니처 → 재현성 → 계층 특정" 4단계로 한다.** 403 즉시 = 헤더/TLS 계층, 429 + `Retry-After` = 레이트리밋, 200 인데 본문이 짧고 `location.href` 만 있으면 = JS 리다이렉트 방식 차단, 캡차 키워드 존재 = 챌린지. 각 케이스별 자동 대응은 "속도 낮추기 → 다음날 재시도 → 사람에게 Windows 알림" 순이며, **UA 위장·프록시 로테이션·TLS 임퍼소네이션으로의 자동 에스컬레이션은 코드에 존재해서는 안 된다.**
|
||
- **결론 10 — 운영 원칙 한 줄: "우리는 회피하지 않는다. 우리는 준수하고, 막히면 멈추고, 사람에게 알린다."**
|
||
|
||
---
|
||
|
||
## 1. 목차
|
||
|
||
> ℹ️ 참고 (2026-09-02 복원 완료): 원 작성은 `3.6 벤더 비교 요약` 직후 중단돼 있었으나, 복원 작업으로 **4~10절과 부록 A·B 전체가 이제 파일에 존재한다.** 다만 §0 의 채택/미채택 구분(결론 1·2·4·9 는 미채택, 결론 5·6·7·8·10 은 유효)이 하위 절에도 그대로 적용된다. 절별 채택 여부는 §3.6 다음의 [이 문서와 확정 설계의 관계](#이-문서와-확정-설계의-관계) 표를 보라.
|
||
|
||
| # | 섹션 | 내용 |
|
||
|---|------|------|
|
||
| 2 | [봇 탐지의 5개 계층](#2-봇-탐지의-5개-계층-전체-원리) | IP/TLS/HTTP/브라우저/행동 계층별 탐지 원리 전체 |
|
||
| 3 | [주요 WAF·봇관리 벤더 동작](#3-주요-waf봇관리-벤더의-동작-방식) | Cloudflare, Akamai, DataDome, Imperva, WAPPLES |
|
||
| 4 | [한국 공공기관 보호 패턴과 nedrug 실측](#4-한국-공공기관-사이트의-보호-패턴과-nedrug-실측-결과) | JSESSIONID/Referer/IP차단 + 2026-09-02 실측 전문 |
|
||
| 5 | [RFC 9309 robots.txt 규범](#5-rfc-9309--robotstxt-규범과-우리의-준수-방침) | 규범 원문과 우리의 해석·방침 |
|
||
| 6 | [정중한 크롤러 체크리스트](#6-정중한-크롤러-규칙-체크리스트--파라미터--코드) | 9개 규칙 + 확정 파라미터 + 완결 코드 |
|
||
| 7 | [도구 비교표와 채택 결정](#7-도구-비교표-2026-기준과-채택배제-결정) | 7개 도구 2026 유지보수 상태·채택 여부 |
|
||
| 8 | [법적 검토](#8-법적-검토) | 정보통신망법·저작권법·판례 4건·공공데이터법 |
|
||
| 9 | [차단 진단·복구 절차](#9-차단-진단-절차와-복구-플로우차트) | 증상 분류 → 플로우차트 → 코드 |
|
||
| 10 | [최종 수집 아키텍처 결정](#10-이-프로젝트의-최종-수집-아키텍처-결정) | 결정 사항 요약표 |
|
||
| A | [출처 목록](#부록-a-출처-목록) | 참조 URL 전량 |
|
||
| B | [미해결 질문](#부록-b-미해결-질문--실측-필요-항목) | 체크박스 |
|
||
|
||
---
|
||
|
||
## 2. 봇 탐지의 5개 계층 (전체 원리)
|
||
|
||
> ⚠️ 이 절은 채택하지 않음(2.1~2.7 전체). 사유: 공식 API 채택 (design/00-DATA-SOURCE-DECISION.md). design/00-DATA-SOURCE-DECISION.md §10 은 이 문서의 "스텔스 도구, TLS 지문 위장, 헤드리스 지문 회피" 부분을 명시적으로 무효화 대상으로 지정한다.
|
||
|
||
### 2.1 계층 모델 개요
|
||
|
||
현대 안티봇은 단일 신호가 아니라 **계층별 신호를 합산해 점수(trust score)를 매기고** 임계값으로 차단·챌린지·통과를 결정한다. 계층이 낮을수록(네트워크에 가까울수록) 위장이 어렵고, 계층이 높을수록(JS 에 가까울수록) 위장 가능하지만 그만큼 위장 흔적 자체가 새로운 신호가 된다.
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ L5 행동 분석 (마우스 궤적, 스크롤, 타이핑 리듬, 세션 내 순회 패턴) │ ← 가장 위장 어려움(비용)
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ L4 브라우저 지문 (navigator.webdriver, CDP, Canvas/WebGL, 폰트) │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ L3 HTTP 계층 (UA, 헤더 집합/순서/케이싱, HTTP/2 SETTINGS) │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ L2 TLS 핑거프린트 (JA3 / JA3N / JA4 / JA4_r / JA4_o) │ ← 가장 위장 어려움(기술)
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ L1 네트워크 (IP 평판, ASN, 요청 빈도/버스트, 지리, 동시 연결 수) │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
> **우리 프로젝트에서의 의미**: 우리는 L1(빈도)만 스스로 통제하면 되고, L2~L5 는 "위장하지 않는다"가 방침이다. nedrug 은 L2 이상을 검사하지 않는다는 것이 실측 결과다(§4.2).
|
||
|
||
### 2.2 L1 — 네트워크: 요청 빈도와 패턴
|
||
|
||
가장 오래되고, 공공기관 사이트에서 **실제로 쓰이는 거의 유일한** 계층이다.
|
||
|
||
| 신호 | 설명 | 우리의 상태 |
|
||
|------|------|-------------|
|
||
| 요청 빈도(rate) | 단위시간당 요청 수. 임계 초과 시 429 또는 IP 차단 | 하루 1회, 총 ≤120 요청, 간격 ≥2초 → 무해 |
|
||
| 버스트 패턴 | 정확히 등간격(예: 정확히 1.000초)인 요청은 기계 신호 | 지터 0~1.0초 삽입 |
|
||
| 동시 연결 수 | 한 IP 에서 동시에 열리는 커넥션 수 | 동시성 1 고정 |
|
||
| IP 평판 | 데이터센터 ASN, 알려진 프록시/VPN 대역, 과거 악성 이력 | 가정용/사무실 고정 IP, 프록시 미사용 |
|
||
| 지리 이상 | UA 는 ko-KR 인데 IP 는 해외 등 | 국내 IP, 국내 사이트 → 일관 |
|
||
| 세션 없는 반복 | 매 요청 새 세션(쿠키 미유지)은 봇 신호 | `JSESSIONID` 재사용 |
|
||
|
||
인용(검색 정리): *"방문 횟수/속도를 감지한 후 과도한 요청 IP를 차단하는 방식이 사용됩니다. User-Agent 검사를 통해 jsoup, curl, python, bot과 같은 크롤링 도구를 차단하고, 특정 IP를 차단 리스트에 추가할 수 있습니다."*
|
||
|
||
또한 정중한 크롤링 일반 가이드에서: *"Set a minimum delay of 0.1 seconds between requests, even for large sites, and set a maximum delay of 2 minutes"*, *"After five straight server errors or timeouts, pause crawling for several hours."*, *"Include contact information in your crawler's user-agent string so admins can reach you."*
|
||
|
||
### 2.3 L2 — TLS 핑거프린트: JA3 / JA3N / JA4 / JA4_r / JA4_o
|
||
|
||
**원리.** TLS 핸드셰이크의 첫 패킷인 `ClientHello` 에는 클라이언트가 지원하는 TLS 버전, 암호 스위트 목록과 **그 순서**, 확장(extension) 목록과 순서, 서명 알고리즘, 타원곡선 그룹, ALPN 등이 그대로 담긴다. 이는 **TLS 스택 구현체의 지문**이며, 애플리케이션이 헤더를 아무리 위장해도 바뀌지 않는다.
|
||
|
||
> *"Transport Layer Security (TLS) fingerprinting is a passive network analysis technique used to identify or classify client and server software based on observable characteristics of their TLS handshakes."*
|
||
|
||
**JA3 (구세대).** `TLSVersion,Ciphers,Extensions,EllipticCurves,ECPointFormats` 를 콤마로 이어 MD5 해시. 약점은 **브라우저의 확장 순서 랜덤화(GREASE 및 Chrome 의 extension shuffling)** 로 인해 같은 브라우저가 매번 다른 JA3 를 내는 것.
|
||
|
||
**JA3N.** JA3 의 확장 목록을 **정렬(normalize)** 해 랜덤화를 무력화한 변형.
|
||
|
||
**JA4 (현세대).** JA3 의 약점을 정면으로 해결한 설계:
|
||
|
||
> *"JA4 is newer and addresses JA3's weaknesses by normalizing fields to handle browser randomization, adding TCP and HTTP/2 context, and producing a 36-character identifier that's harder to evade."*
|
||
> *"JA4 captures Protocol + Version + SNI + CipherCount + ExtensionCount + ALPN + Truncated SHA256(Ciphers) + Truncated SHA256(Extensions + SigAlgs)."*
|
||
|
||
Cloudflare 문서 기준: *"JA3 and JA4 are 'SSL/TLS-based identifiers' that profile clients based on how they initiate connections. They serve as stable identifiers across different destinations, with JA4 improving upon JA3 by sorting ClientHello extensions for better browser grouping."*
|
||
|
||
**실측 — 이 PC 의 `curl 8.10.1` 지문** (2026-09-02, `https://tls.browserleaks.com/json`):
|
||
|
||
```json
|
||
{
|
||
"user_agent": "curl/8.10.1",
|
||
"ja4": "t13d201200_2b729b4bf6f3_e24568c0d440",
|
||
"ja4_r": "t13d201200_002f,0035,003c,003d,009c,009d,1301,1302,c009,c00a,c013,c014,c023,c024,c027,c028,c02b,c02c,c02f,c030_0005,000a,000b,000d,0017,0023,002b,002d,0031,0033,ff01_0804,0805,0806,0401,0501,0201,0403,0503,0203,0202,0601,0603",
|
||
"ja4_o": "t13d201200_1079d97b4c9b_ec8845a7a0de",
|
||
"ja4_ro": "t13d201200_1302,1301,c02c,c02b,c030,c02f,c024,c023,c028,c027,c00a,c009,c014,c013,009d,009c,003d,003c,0035,002f_0000,0005,002b,000d,0023,000a,000b,0033,0031,0017,ff01,002d_0804,0805,0806,0401,0501,0201,0403,0503,0203,0202,0601,0603",
|
||
"ja3_hash": "2800f914a7a4ba98aa9df62d316a460c",
|
||
"ja3_text": "771,4866-4865-49196-49195-49200-49199-49188-49187-49192-49191-49162-49161-49172-49171-157-156-61-60-53-47,0-5-43-13-35-10-11-51-49-23-65281-45,29-23-24,0",
|
||
"ja3n_hash": "ab39bb9170142e5563998af5cebaa6c9",
|
||
"ja3n_text": "771,4866-4865-49196-49195-49200-49199-49188-49187-49192-49191-49162-49161-49172-49171-157-156-61-60-53-47,0-5-10-11-13-23-35-43-45-49-51-65281,29-23-24,0",
|
||
"akamai_hash": "",
|
||
"akamai_text": ""
|
||
}
|
||
```
|
||
|
||
이 값을 읽는 법:
|
||
- `t13d201200` = TLS 1.3(`t13`), d = TCP over ... (프로토콜/ALPN 인코딩), `2012` = 암호 스위트 20개, `00` = ALPN 첫/끝 문자.
|
||
- `ja4_r` 은 해시 이전의 **원문(raw)** 이라 어떤 암호/확장이 있었는지 그대로 보인다. `1301`(TLS_AES_128_GCM_SHA256), `1302`(TLS_AES_256_GCM_SHA384) 등.
|
||
- `akamai_hash` 가 **빈 문자열**인 것이 핵심이다 → 이 연결은 **HTTP/1.1** 로 이뤄져 HTTP/2 SETTINGS 프레임이 없었다는 뜻. 실제로 같은 실측의 `http_version=1.1` 과 일치한다.
|
||
|
||
**학술 근거.** Ghalia Jarad, Kemal Bicakci, *"When Handshakes Tell the Truth: Detecting Web Bad Bots via TLS Fingerprints"*, arXiv:2602.09606 (제출 2026-02-10). 데이터셋 **JA4DB**, 기법 **JA4**. 결과: **CatBoost 분류기 AUC 0.998, F1 0.9734, 테스트 정확도 0.9863**, XGBoost 도 유사. 가장 영향력 큰 특징은 **`ja4_b`, `cipher_count`, `ext_count`** — 즉 봇은 암호 스위트 선택과 확장 구성에서 브라우저와 뚜렷이 다른 패턴을 보인다. 한계로 저자들은 **HTTP/3 등 신규 프로토콜로의 확장 필요**와 **디바이스 지문 특징 추가 필요**를 명시.
|
||
|
||
**실무 요약**: *"TLS fingerprinting has become a standard building block in security and anti-bot pipelines, especially on the server side, with fraud and bot detection vendors combining JA3/JA4 with cookies, IP reputation, and behavioral features."*
|
||
|
||
**Python 표준 클라이언트의 문제**: *"Python's requests library sends a ClientHello with a specific cipher suite combination that hashes to a JA3 value that is distinctly non-browser, and Cloudflare, Akamai, and DataDome check this fingerprint before serving any content."*
|
||
→ **하지만 nedrug 은 이 검사를 하지 않는다.** `curl/8.10.1` 로 200 이 나온 것이 반증이다(§4.2).
|
||
|
||
### 2.4 L3 — HTTP 계층: 헤더 집합·순서·케이싱, HTTP/2 지문
|
||
|
||
#### 2.4.1 User-Agent
|
||
|
||
가장 단순하고 가장 많이 쓰이는 신호. *"The User-Agent request header lets servers identify the application, and some websites block certain requests if they contain User-Agent that doesn't belong to a major browser."* 한국 사이트에서 `jsoup`, `curl`, `python-requests`, `bot` 문자열을 UA 블랙리스트로 거르는 사례가 보고된다.
|
||
|
||
**우리의 선택은 정반대다.** 브라우저 UA 를 사칭하지 않고, **정직한 UA + 연락처**를 쓴다:
|
||
|
||
```
|
||
DMF-Crawler/1.0 (+mailto:yunchanpaca@gmail.com; internal regulatory monitoring; 1 req/2s; daily)
|
||
```
|
||
|
||
이유: (a) 사이트 관리자가 우리를 식별하고 연락할 수 있게 하는 것이 정중함의 핵심, (b) 브라우저 사칭은 §8 의 법적 판단에서 "접근권한 우회 의도"로 해석될 여지를 만든다. 다만 §9 진단 단계에서 "UA 블랙리스트에 걸린 것인지"를 **1회성으로 확인**할 필요가 있으면 브라우저 UA 로 한 번 테스트하는 것은 진단 목적으로 허용하되, **상시 운영 코드에 넣지 않는다.**
|
||
|
||
#### 2.4.2 헤더 순서와 집합
|
||
|
||
HTTP/1.1 에서 브라우저는 헤더를 **일정한 순서**로 보낸다. 서버는 순서 자체를 지문으로 쓸 수 있다.
|
||
|
||
Chrome on Linux 의 실제 순서 예시(출처 인용):
|
||
|
||
```
|
||
GET / HTTP/1.1
|
||
Host: 127.0.0.1:65432
|
||
Connection: keep-alive
|
||
Cache-Control: max-age=0
|
||
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="99", "Google Chrome";v="99"
|
||
sec-ch-ua-mobile: ?0
|
||
sec-ch-ua-platform: "Linux"
|
||
Upgrade-Insecure-Requests: 1
|
||
User-Agent: Mozilla/5.0...
|
||
```
|
||
|
||
라이브러리별 차이(출처 인용):
|
||
- **`requests` 는 헤더 순서를 보존하지 않는다** — *"requests library does not respect header order, making scrapers built with it easily detectable."*
|
||
- **`httpx` 는 헤더 순서를 보존한다** — *"httpx library does respect the header order, and can be safely used for web scraping as a requests alternative."*
|
||
- `aiohttp` 의 순서 보존 여부는 해당 출처가 다루지 않음 ⚠️ 미검증.
|
||
|
||
> **이 프로젝트의 선택**: `httpx` 를 쓴다. 이유는 "브라우저 순서를 흉내내려고"가 아니라, **순서가 결정적(deterministic)이라 재현 가능한 디버깅이 되고**, HTTP/2 지원과 타임아웃/커넥션 풀 제어가 명확하기 때문이다.
|
||
|
||
주요 헤더의 무게(출처 인용): User-Agent("arguably the most important header"), Accept / Accept-Encoding, `Sec-CH-UA` 계열(UA 값과 정합해야 함), `Sec-Fetch-*` 계열(요청 출처·타입 메타데이터).
|
||
|
||
⚠️ 해당 출처는 **HTTP/2 헤더 케이싱(소문자 강제) 규약은 다루지 않음** — 미검증.
|
||
|
||
#### 2.4.3 HTTP/2 핑거프린트 (Akamai fingerprint)
|
||
|
||
HTTP/2 는 연결 수립 직후 `SETTINGS` 프레임으로 `SETTINGS_HEADER_TABLE_SIZE`, `SETTINGS_ENABLE_PUSH`, `SETTINGS_MAX_CONCURRENT_STREAMS`, `SETTINGS_INITIAL_WINDOW_SIZE`, `SETTINGS_MAX_HEADER_LIST_SIZE` 등을 교환하고, `WINDOW_UPDATE` 증분값, `PRIORITY` 프레임 구성, 의사헤더(`:method :authority :scheme :path`) 순서가 클라이언트마다 다르다. 이를 문자열화한 것이 흔히 **Akamai HTTP/2 fingerprint** 로 불린다(browserleaks 의 `akamai_hash`/`akamai_text` 필드).
|
||
|
||
*"HTTP/2 Integration combines JA3 with HTTP/2 SETTINGS frame fingerprinting as part of modern bot detection approaches."*
|
||
*"Modern anti-bot systems inspect TLS fingerprints (JA3/JA4), HTTP/2 SETTINGS frames, Canvas and WebGL outputs, Navigator properties, and behavioral timing signals."*
|
||
|
||
**실측**: nedrug 은 `http_version=1.1` 로 응답했고 `akamai_hash` 는 공란 → **HTTP/2 지문 검사 자체가 성립하지 않는 환경**. (⚠️ 미검증: nedrug 이 ALPN 으로 h2 를 협상할 수 있는지는 별도 실측 필요 — 부록 B 참조.)
|
||
|
||
### 2.5 L4 — 브라우저·헤드리스 지문
|
||
|
||
브라우저를 실제로 띄워야만 노출되는 계층이다. **우리 프로젝트는 브라우저를 띄우지 않으므로 이 계층 전체가 비적용**이지만, 향후 JS 렌더링이 필요해질 경우를 위해 원리를 전량 기록한다.
|
||
|
||
| 신호 | 탐지 방법 | 비고 |
|
||
|------|-----------|------|
|
||
| `navigator.webdriver` | 자동화 제어 시 `true`. *"Chrome headless sets navigator.webdriver = true by default."* | `--disable-blink-features=AutomationControlled` 로 제거 가능 |
|
||
| User-Agent 의 `HeadlessChrome` | 과거 헤드리스 Chrome UA 에 `HeadlessChrome/XXX.0.0.0` 포함. *"Chrome headless used to include 'HeadlessChrome' in the UA string, and now it doesn't, but other UA anomalies remain."* | 현재는 제거됨 |
|
||
| `sec-ch-ua` 내 `HeadlessChrome` | *"Detecting the presence of the HeadlessChrome substring in the sec-ch-ua header"* | UA 를 고쳐도 client hint 에 남을 수 있음 |
|
||
| CDP 노출 — `Runtime.enable` | 안티봇의 핵심 신호. *"All major anti-bot software such as Cloudflare, DataDome, and others rely on detecting the Runtime.Enable CDP command."* | patchright/rebrowser 가 패치하는 지점 |
|
||
| CDP 노출 — `Error.stack` 직렬화 | *"Exploits Chrome DevTools Protocol serialization behavior; triggering property getters during error logging exposes automated contexts."* | 여전히 유효(2024 기준) |
|
||
| `sourceURL` 누출 | `//# sourceURL=pptr:...` 같은 흔적 | rebrowser-patches 가 `app.js` 로 치환 |
|
||
| Utility world 이름 | Playwright/Puppeteer 의 isolated world 명칭 | `REBROWSER_PATCHES_UTILITY_WORLD_NAME` 로 변경 |
|
||
| Canvas 지문 | 동일 그리기 명령의 픽셀 출력 해시가 GPU/드라이버/폰트에 따라 다름 | 헤드리스는 SwiftShader 등으로 고유 패턴 |
|
||
| WebGL 지문 | `UNMASKED_RENDERER_WEBGL` 문자열(예: SwiftShader), 셰이더 정밀도 | Camoufox 가 C++ 레벨에서 스푸핑 |
|
||
| 폰트 목록 | 설치 폰트 열거로 OS/환경 식별 | Camoufox 가 스푸핑 |
|
||
| 플러그인/`window.chrome`/permissions | 헤드리스에서 비거나 이상값 | 해당 출처에서는 상세 미기술 ⚠️ 미검증 |
|
||
| 화면 크기/`languages` | 비정상 해상도, 언어 목록 불일치 | 해당 출처에서는 상세 미기술 ⚠️ 미검증 |
|
||
|
||
**Chrome/Playwright 의 헤드리스 모드 변화 (중요)**
|
||
|
||
Playwright v1.49 부터:
|
||
- 기본값이 **`chromium-headless-shell`** (구 헤드리스 동작을 따르는 별도 빌드)로 바뀌었다. *"By default, Playwright now uses chromium-headless-shell, a separate browser build that follows the old headless mode behavior. This change is transparent—no action required."*
|
||
- **신 헤드리스**를 쓰려면 `channel: 'chromium'` 을 지정한다:
|
||
```javascript
|
||
use: {
|
||
channel: 'chromium',
|
||
}
|
||
```
|
||
- `chrome`, `msedge` 채널 사용자는 영향을 받는다: *"new version of Google Chrome and Microsoft Edge will only include the new headless mode."*
|
||
- 부수효과: PDF 가 다운로드되지 않고 페이지 내 렌더링됨, 스크린샷 결과가 달라짐(스크린샷 기대값 갱신 권고), 평균적으로 약간 느림, GPU/WebGL 가용성이 달라짐.
|
||
- *"New Headless is the real Chrome browser, and is thus more authentic, reliable, and offers more features."*
|
||
- ⚠️ 신 헤드리스의 UA 에 `HeadlessChrome` 가 남는지는 해당 이슈 문서가 언급하지 않음 — 미검증.
|
||
|
||
### 2.6 L5 — 행동 분석과 캡차
|
||
|
||
- **행동 신호**: 마우스 이동 궤적/가속도, 스크롤 패턴, 키 입력 리듬, 페이지 체류 시간, 클릭 좌표 분포, 세션 내 페이지 순회 그래프.
|
||
- Cloudflare: *"Cloudflare analyzes navigation patterns, request timing, mouse movements, and scroll behavior—real users do not request 100 pages in two seconds."*
|
||
- Imperva: *"Its bot protection system analyzes mouse movement patterns, keystroke dynamics, and interaction sequences."*
|
||
- **캡차/챌린지**: 점수가 애매한 구간에 대해 JS 챌린지 → 관리형 챌린지 → 상호작용 챌린지(이미지 선택) 순으로 에스컬레이션. 실측 사례: `https://html.duckduckgo.com/html/?q=...` 요청이 **"오리가 있는 사각형을 고르라"는 DuckDuckGo 캡차**로 막혔다(FETCH #48).
|
||
- **핵심 통찰(학술)**: Iliana Fayolle, Sihem Bouhenniche, Samuel Pélissier, Pierre Laperdrix, Clémentine Maurice, Walter Rudametkin, *"On the Internet, Nobody Knows You're an LLM Bot: Unmasking Web Agents with Multi-Layer Fingerprinting"*, arXiv:2606.30119 (제출 2026-06-29). LLM 기반 웹 에이전트 6종을 네트워크/HTTP·브라우저·행동(허니사이트) 3계층으로 지문화하여 **"모든 평가 대상 에이전트가 사람과도, 서로와도 구별 가능"** 함을 보였다. 결론 3가지:
|
||
1. *"Stealth and anti-detection mechanisms often increase detectability rather than decrease it"* — 스텔스가 오히려 지문을 만든다.
|
||
2. 에이전트는 네트워크·HTTP·브라우저 계층에서 **동시에** 신호를 흘리므로 한 계층만 고쳐서는 소용이 없다.
|
||
3. *"some Web Agents were able to bypass all evaluated anti-bot mechanisms"* 이지만 종합 지문화로 탐지는 가능하다.
|
||
- ⚠️ 미검증: 논문 초록에 구체적 에이전트 제품명(browser-use / OpenAI Operator / Claude computer use 등)과 정확도 수치는 명시되지 않음.
|
||
|
||
> **이 프로젝트에 대한 함의**: 우리는 AI 에이전트 CLI(`agy -p`)를 **크롤링 주체가 아니라 후처리(요약/분류) 주체**로만 쓴다. 즉 `agy` 가 브라우저를 몰고 사이트에 접속하는 구조를 **채택하지 않는다.** 네트워크 접점은 순수 `httpx` 코드로만 두고, `agy` 는 이미 받아온 로컬 HTML/JSON 만 읽는다. 이렇게 하면 위 논문이 지적한 "웹 에이전트 지문" 문제가 아예 발생하지 않는다.
|
||
|
||
### 2.7 계층별 "우리는 어떻게 하는가" 요약표
|
||
|
||
| 계층 | 대표 신호 | nedrug 이 검사하는가 | 우리의 방침 |
|
||
|------|-----------|---------------------|-------------|
|
||
| L1 네트워크 | 빈도, 동시성, IP | **가능성 높음** (일반 WAF 기본 기능) | 스스로 극도로 낮게 유지 (§6) |
|
||
| L2 TLS | JA3/JA4 | **아니오** (curl 로 200) | 위장하지 않음. 표준 스택 사용 |
|
||
| L3 HTTP | UA, 헤더 순서 | **아니오** (curl UA 로 200) | 정직한 UA + 연락처. httpx 순서 결정성 활용 |
|
||
| L4 브라우저 | webdriver, CDP, Canvas | **아니오** (JS 없이 HTML 완전 수신) | 브라우저 미사용 |
|
||
| L5 행동 | 마우스/스크롤/캡차 | **아니오** (캡차 흔적 없음) | 해당 없음 |
|
||
|
||
---
|
||
|
||
## 3. 주요 WAF·봇관리 벤더의 동작 방식
|
||
|
||
> ⚠️ 이 절은 채택하지 않음(3.1~3.6 전체). 사유: 공식 API 채택 (design/00-DATA-SOURCE-DECISION.md). nedrug 을 직접 크롤링하지 않으므로 WAF·봇관리 벤더 우회 판단이 필요 없다.
|
||
|
||
이 섹션은 nedrug 에는 적용되지 않지만, (a) 향후 다른 소스(예: 해외 규제기관 사이트)를 추가할 때의 판단 근거이고, (b) §9 진단 절차에서 "우리가 만난 차단이 어떤 종류인지" 를 식별하는 데 필요하다.
|
||
|
||
### 3.1 Cloudflare
|
||
|
||
**탐지 엔진 (공식 문서 기준)**
|
||
|
||
| 엔진 | 설명 | 가용 플랜 |
|
||
|------|------|-----------|
|
||
| **Heuristics** | *"Processes all requests"* 하고 *"a growing database of malicious fingerprints"* 와 대조 | 전체 |
|
||
| **JavaScript Detections (JSD)** | 가볍고 보이지 않는 클라이언트 사이드 JS 를 주입해 헤드리스 브라우저 식별. PII 수집 없음 | 전체 |
|
||
| **Machine Learning (ML)** | 일 수십억 요청에 대한 지도학습으로 **Bot Score 1–99** 산출. 입력은 *"headers, session characteristics, and browser signals"* | Business / Enterprise |
|
||
| **Anomaly Detection (AD)** | 비지도학습으로 트래픽 베이스라인 대비 통계적 이상치 탐지. User-agent 비의존 | Enterprise 전용, **deprecated** |
|
||
|
||
**쿠키**
|
||
- `__cf_bm` — *"smooths out the bot score and reduce[s] false positives"*, 개별 사용자의 요청 패턴 추적용.
|
||
- `cf_clearance` — JSD 가 성공적으로 실행된 방문자에게 발급. 결과는 `cf.bot_management.js_detection.passed` 필드에 `true`/`false` 로 저장.
|
||
|
||
**JSD 상세 (공식 문서 인용)**
|
||
- 주입 경로: `"/cdn-cgi/challenge-platform/…"`
|
||
- 첫 요청의 한계: *"The first request from a new client to your website or application will generally not have JavaScript Detections data (`cf.bot_management.js_detection.passed` = `false`). This is because Cloudflare needs at least one HTML request before injecting JavaScript Detection and issuing the `cf_clearance` cookie."*
|
||
- 중요: *"Enforcement against bots does not occur automatically—you must create custom WAF rules using the `js_detection.passed` field to actually block or challenge failed requests."*
|
||
|
||
**JA3/JA4 활용 (공식 문서)**
|
||
- 노출 필드: JA3 fingerprint, JA4 fingerprint, 그리고 `ja4Signals` 객체.
|
||
- 비율: `h2h3_ratio_1h`, `heuristic_ratio_1h`, `browser_ratio_1h`, `cache_ratio_1h`
|
||
- 순위: `uas_rank_1h`, `paths_rank_1h`, `reqs_rank_1h`, `ips_rank_1h`
|
||
- 분위: `reqs_quantile_1h`, `ips_quantile_1h`
|
||
- 활용처: Bot Analytics, Security Events, GraphQL API, 로그 / WAF custom rules, Transform Rules, Workers 에서 차단·허용·모바일앱 트래픽 식별·오탐 교정.
|
||
- 한계: 비-TLS 트래픽, 내부 O2O 요청, Bot Management 를 건너뛴 경우에는 지문이 없을 수 있고, *"results with `NaN` or `Infinity` values will be excluded."*
|
||
|
||
**헤드리스 탐지 방식 (검색 정리)**
|
||
- `navigator.webdriver` 플래그.
|
||
- CDP 시그니처: *"Cloudflare detects the proxy objects used for variable overwrites and identifies the presence of the Chrome DevTools Protocol (CDP) communicating in the background."*
|
||
- 다층 분석: JS 환경 분석 + 하드웨어 지문 + 네트워크 프로파일링 + 행동 생체.
|
||
- 갱신 주기: *"Cloudflare pushes detection updates continuously rather than on a fixed schedule, with major fingerprint-detection changes appearing every few weeks while ML model retraining happens more frequently."*
|
||
|
||
**Cloudflare 에러 코드 판별표 (진단에 직결)**
|
||
|
||
| 코드 | 의미 | 판별 포인트 |
|
||
|------|------|-------------|
|
||
| **1010** | Browser Integrity Check 실패 | *"your browser signature (TLS, HTTP/2, or JS fingerprint) was flagged. It's the error you see when you try to scrape with default Python requests, plain Puppeteer, or unpatched Selenium."* |
|
||
| **1015** | Rate limited | 허용 요청 수 초과. → **속도/동시성부터 낮춘다** |
|
||
| **1020** | Access denied | 방화벽 규칙, IP 평판, 봇 탐지에 의한 거부. → **방화벽 규칙 신호·IP 평판·요청 프로파일 점검** |
|
||
| 403 (평문) | 1020 대신 오는 경우 | *"In some cases, Cloudflare may respond with a generic 403 Forbidden response instead of displaying the error 1020 page. This usually happens when using an HTTP client, scripts, or automated tools."* |
|
||
| 429 (평문) | 표준 레이트리밋 | *"Error 1015 is a Cloudflare-specific rate-limiting page, while HTTP 429 ('Too Many Requests') is a standard status code that any server can return."* |
|
||
|
||
핵심 판별 규칙: *"Cloudflare 1xxx errors normally appear in the page content, while a regular 403, 429, or 530 is an HTTP response status."* → **본문을 봐야 한다.**
|
||
|
||
⚠️ **미검증**: `cf-mitigated: challenge` 응답 헤더와 챌린지 페이지의 정확한 상태 코드를 공식 문서에서 확인하려 했으나 실패했다. `https://developers.cloudflare.com/cloudflare-challenges/reference/detecting-a-challenge-page-response/` 는 **HTTP 404**, `https://developers.cloudflare.com/cloudflare-challenges/` 와 `https://developers.cloudflare.com/waf/reference/cloudflare-challenges/` 에는 해당 내용이 없었다. 확인 필요(부록 B).
|
||
|
||
### 3.2 Akamai Bot Manager
|
||
|
||
- *"Akamai uses advanced fingerprinting, behavioral analysis, and anomaly detection, making it well-suited for enterprises needing deep integration within its ecosystem."*
|
||
- *"It's known for detecting headless browsers even when they've patched the obvious fingerprints, using network-level analysis and behavioral ML."*
|
||
- ⚠️ **미검증**: 공식 문서(`https://techdocs.akamai.com/bot-manager/docs/welcome-bot-manager`)는 **auth0 로그인으로 302 리다이렉트**되어 열지 못했다. 따라서 `_abck` 쿠키, sensor data JS 챌린지, tarpit/serve-alternate 액션 등 흔히 알려진 세부는 이 조사에서 **검증되지 않았다**.
|
||
|
||
### 3.3 DataDome
|
||
|
||
- *"DataDome positions itself as a real-time bot protection layer with sub-millisecond response times."*
|
||
- *"DataDome takes a different approach: ML-first, real-time classification. It integrates via a JavaScript tag and a server-side SDK."*
|
||
- *"DataDome's model analyzes request patterns across the entire protected network, making it effective at detecting distributed scraping even when individual requests look legitimate."*
|
||
- ⚠️ **미검증**: 공식 문서 `https://docs.datadome.co/docs/how-datadome-works` 는 **HTTP 404**. `x-datadome` 헤더, 403+캡차 페이지 형태 등은 검증 못 함.
|
||
|
||
### 3.4 Imperva
|
||
|
||
- *"Imperva uses the power of machine learning to collect and analyze data about bots and their behavior. It continuously learns from patterns to pinpoint anomalies in traffic."*
|
||
- 행동: *"analyzes mouse movement patterns, keystroke dynamics, and interaction sequences."*
|
||
- 디바이스: *"Imperva analyzes more than 200 device attributes to effectively identify bad bots."*
|
||
- 지속 추적: *"These fingerprints stick to each bot, so they can be identified even when they connect from random IP addresses, use peer-to-peer networks, or anonymous proxies."*
|
||
|
||
### 3.5 한국 — WAPPLES (펜타시큐리티)
|
||
|
||
한국 공공기관 웹방화벽 시장의 사실상 표준이다. **nedrug 이 WAPPLES 를 쓰는지는 확인되지 않았으나(⚠️ 미검증)**, 국내 공공 사이트를 다룰 때의 기본 전제로 삼을 만하다.
|
||
|
||
| 항목 | 내용 | 출처 성격 |
|
||
|------|------|-----------|
|
||
| 국내 점유율 | **16년 연속 국내 웹방화벽 시장점유율 1위** | 펜타시큐리티 뉴스레터 2024-09 (제목에서 확인) |
|
||
| 나라장터 기준 | 2008~2025 평균 점유율 **56%** | 검색 정리 ⚠️ 미검증 |
|
||
| 나라장터 조달 | 2023년 8월 조달 점유율 **100%** 달성 | 검색 정리 ⚠️ 미검증 |
|
||
| 누적 판매 | 2006년 출시 이후 **6,300대 이상** (공공기관·민간기업·교육기관·금융기관) | 검색 정리 ⚠️ 미검증 |
|
||
| 봇 차단 기능 | *"고도화된 악성 봇 탐지 및 악성 트래픽 차단 기능"*, 20년 업력 기반 이상 탐지 | 검색 정리 |
|
||
| WAPPLES SA for Cloud | 웹방화벽 기능 + **API 보안, Bot 완화, DoS 방어** | 검색 정리 |
|
||
| 아태 지역 | 아시아 태평양 점유율 1위 표방 | 제품 페이지 제목 |
|
||
|
||
> **실무적 의미**: 국내 WAF 는 Cloudflare 처럼 JS 챌린지를 걸기보다 **룰 기반 차단(UA 블랙리스트, 요청 속도, 시그니처 매칭) 후 403 또는 자체 차단 안내 페이지**를 반환하는 경향이 있다. 즉 §9 진단에서 "200 인데 본문이 차단 안내 HTML" 케이스를 반드시 다뤄야 한다.
|
||
|
||
### 3.6 벤더 비교 요약
|
||
|
||
| 벤더 | 1차 신호 | 배포 방식 | 대표 응답 | nedrug 적용 |
|
||
|------|----------|-----------|-----------|-------------|
|
||
| Cloudflare | Heuristics + JSD + ML Bot Score(1–99) | 엣지 프록시 | 1010/1015/1020, 403, 챌린지 페이지, `__cf_bm`/`cf_clearance` | ❌ 흔적 없음 |
|
||
| Akamai | 지문 + 행동 ML + 네트워크 레벨 | 엣지 CDN | ⚠️ 미검증 | ❌ 흔적 없음 |
|
||
| DataDome | ML-first 실시간 분류 (sub-ms) | JS 태그 + 서버사이드 SDK | ⚠️ 미검증 | ❌ 흔적 없음 |
|
||
| Imperva | 200+ 디바이스 속성 + 행동 생체 | 리버스 프록시/에이전트 | ⚠️ 미검증 | ❌ 흔적 없음 |
|
||
| WAPPLES | 룰/이상탐지 + 봇 완화 | 국내 온프레미스 어플라이언스 다수 | 403 / 자체 차단 페이지 (추정 ⚠️) | ⚠️ 미확인 |
|
||
|
||
---
|
||
|
||
## 이 문서와 확정 설계의 관계
|
||
|
||
데이터 소스 결정(`design/00-DATA-SOURCE-DECISION.md`)에 따라, 이 문서의 각 절이 확정 설계에 대해 갖는 지위를 아래와 같이 정리한다.
|
||
|
||
| 절 | 상태 | 비고 |
|
||
|---|---|---|
|
||
| 0. 한눈에 보기 — 결론 1·2·4·9 | ❌ 미채택 | HTML 직접 크롤링, nedrug 대상 정중한 접근 실행 정책, 차단 진단·에스컬레이션 방침. 공식 API 로 대체 |
|
||
| 0. 한눈에 보기 — 결론 5·6·7·8·10 | ✅ 유효 | 법적 리스크 등급, 판례(2021도1533, 2018가합508729, 2016나2019365), 공공누리 출처표시, 정중함 파라미터, "회피하지 않는다"는 원칙. 공식 API 이용에도 동일 적용 |
|
||
| 1. 목차 | ✅ 유효 | 2~10절·부록 A·B 전체가 파일에 존재한다 |
|
||
| 2. 봇 탐지의 5개 계층 (2.1~2.7) | ❌ 미채택 | TLS/HTTP/브라우저 지문 위장 관련 이론과 실측 전량. `design/00-DATA-SOURCE-DECISION.md` §10 표가 이 문서의 해당 부분을 명시적으로 무효화 대상으로 지정 |
|
||
| 3. 주요 WAF·봇관리 벤더의 동작 방식 (3.1~3.6) | ❌ 미채택 | nedrug 을 직접 크롤링하지 않으므로 벤더별 우회·진단 판단이 불필요 |
|
||
| 4. 한국 공공기관 보호 패턴과 nedrug 실측 (4.1~4.3) | ⚠️ 참고 | 2026-09-02 실측 원본(응답 헤더 전문, robots.txt, `/bbs/117` 구조 710건·71페이지)은 보존 가치가 있다. 다만 HTML 크롤링을 하지 않으므로 §4.3 의 3단계 안전장치는 실행되지 않는다 |
|
||
| 5. RFC 9309 robots.txt 규범 (5.1~5.3) | ⚠️ 참고 | 규범 요약(5.1)과 `urllib.robotparser` 표(5.2)는 유효. 코드(5.3)와 `policy.yaml` 의 `path_allowlist` 는 HTML 크롤링 전제이므로 미채택 |
|
||
| 6. 정중한 크롤러 체크리스트 (6.1~6.4) | ✅ **유효(핵심)** | `design/00-DATA-SOURCE-DECISION.md` §10 이 여전히 필요하다고 지목한 부분. UA·간격·지터·백오프·`Retry-After`·서킷 브레이커·조건부 요청은 **공식 API 호출에 그대로 적용**한다 |
|
||
| 7. 도구 비교표와 채택/배제 결정 (7.1~7.4) | ✅ 유효 | "스텔스 도구를 쓰지 않는다"는 결정과 근거, `pyproject.toml` 의 금지 의존성 목록이 그대로 유효 |
|
||
| 8. 법적 검토 (8.1~8.9) | ✅ **유효(핵심)** | 정보통신망법 §48/§71, 2021도1533, 2018가합508729, 2016나2019365, hiQ v. LinkedIn, 공공누리·공공데이터법, 리스크 등급표, Do/Don't, 에스컬레이션 기준 전부 적용 |
|
||
| 9. 차단 진단·복구 절차 (9.1~9.5) | ⚠️ 부분 유효 | HTML 차단 시그니처 판별(9.1 의 E~K)은 미적용. **에스컬레이션 사다리(9.3)와 Windows 운영 연계(9.5)는 API 오류 코드(22 일일한도 / 23 초당한도 / 29 IP차단 / 31 키만료)에 대응시켜 채택** |
|
||
| 10. 최종 수집 아키텍처 결정 | ⚠️ 대체됨 | `design/00-DATA-SOURCE-DECISION.md` 가 이 표를 대체한다. 단 §10.1 의 정중함 파라미터 행(주기·간격·타임아웃·재시도·서킷)은 유효 |
|
||
| 부록 A. 출처 목록 | ✅ 유효 | 조사에 사용된 224개 URL 전량 + 확인 여부. 재조사 시 출발점 |
|
||
| 부록 B. 미해결 질문 | ✅ 유효 | B.2(공식 API)·B.3(법률)·B.5(운영)는 즉시 유효. B.1(사이트 구조)·B.4(봇 탐지)는 보류 |
|
||
|
||
**해소됨**: `design/00-DATA-SOURCE-DECISION.md` §10 이 여전히 필요하다고 지목한 "정중한 크롤러 체크리스트"와 "법적 검토" 상세는 각각 **§6** 과 **§8** 에 전문이 작성되어 있다. 구현 시 이 두 절을 SSOT 로 삼는다.
|
||
|
||
## 4. 한국 공공기관 사이트의 보호 패턴과 nedrug 실측 결과
|
||
|
||
### 4.1 한국 공공기관 사이트의 전형적 보호 5종
|
||
|
||
| # | 보호 | 동작 | 크롤러가 해야 할 일 |
|
||
|---|------|------|---------------------|
|
||
| 1 | **JSESSIONID 세션 필수** | Java/Spring 기반 WAS 가 첫 요청에 세션 쿠키를 심고, 목록→상세 이동 시 세션 유지를 전제로 한 화면 상태(검색조건, 페이지)를 서버에 보관 | 쿠키 자동 저장·재전송 세션 객체를 **하나만** 만들어 끝까지 재사용 |
|
||
| 2 | **Referer 검사** | 상세/다운로드 URL 에 대해 목록 페이지에서 온 요청인지 `Referer` 로 확인 | 상세 요청 시 목록 URL 을 `Referer` 로 정직하게 설정(위조가 아니라 실제 경로대로) |
|
||
| 3 | **웹방화벽(WAF)** | UA 블랙리스트(`curl`, `python-requests`, `jsoup`, `bot`), SQLi/XSS 시그니처, 비정상 파라미터 | 정직한 UA + 정상 파라미터만 사용. 문자열 인젝션 시도 절대 금지 |
|
||
| 4 | **과도 요청 시 IP 차단** | 임계 초과 시 방화벽 레벨 차단(수 분~수 시간, 때로는 영구) | 속도 제한 + 서킷 브레이커. **차단되면 재시도 금지, 사람 호출** |
|
||
| 5 | **POST 기반 페이지네이션 / JS 링크** | 목록 이동이 `<a href="javascript:goPage(2)">` + hidden form POST 인 경우 | 폼 필드를 그대로 재현한 POST. 페이지 수 상한 준수 |
|
||
|
||
인용(검색 정리): *"JSESSIONID는 민감한 정보를 서버에 보관하고 이와 매칭되는 키를 생성하여 클라이언트에게 주는 방식이며, 클라이언트는 JSESSIONID를 요청 header에 담아서 보냅니다."* / *"웹방화벽은 Referer 헤더를 검사하여 요청의 출처를 확인하고, 정상적인 브라우저 요청인지 자동화된 크롤링 요청인지를 판별할 수 있습니다."* / *"방문 횟수/속도를 감지한 후 과도한 요청 IP를 차단하는 방식이 사용됩니다."*
|
||
|
||
### 4.2 nedrug.mfds.go.kr 실측 (2026-09-02 12:57~12:59 KST)
|
||
|
||
#### 4.2.1 robots.txt — 전문
|
||
|
||
요청: `curl -s -D - -A "DMF-Research-Check/0.1 (+mailto:yunchanpaca@gmail.com)" --max-time 20 "https://nedrug.mfds.go.kr/robots.txt"`
|
||
|
||
응답 헤더 전문:
|
||
|
||
```http
|
||
HTTP/1.1 200 OK
|
||
Date: Wed, 02 Sep 2026 12:57:24 GMT
|
||
Server: Apache
|
||
Allow: GET, POST, OPTIONS
|
||
Strict-Transport-Security: max-age=63072000
|
||
Expires: 0
|
||
Cache-Control: max-age=3600
|
||
Access-Control-Allow-Headers: Origin, Content-Type, content-type, Content-Style-Type, Accept, Authorization,DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,If-Modified-Since, x-requested-with, Content-Security-Policy, X-UA-Compatible, X-Content-Type-Options, X-FRAME-OPTIONS, Cache-Control, Pragma
|
||
X-XSS-Protection: 1; mode=block
|
||
Pragma:
|
||
Accept-Ranges: bytes
|
||
Access-Control-Allow-Origin: *.mfds.go.kr
|
||
Last-Modified: Thu, 07 Aug 2025 07:44:40 GMT
|
||
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
|
||
X-Content-Type-Options: nosniff
|
||
Content-Length: 26
|
||
Access-Control-Allow-Methods: GET, POST, OPTIONS
|
||
Set-Cookie: key=value; SameSite=Lax;Secure;;HttpOnly;Secure
|
||
Set-Cookie: JSESSIONID=stogIf1qms8gvtsm0oOsDket8hhkTRa9ijaj5YLV.ext21; path=/; secure; HttpOnly; Max-Age=14400; Expires=Wed, 02-Sep-2026 16:57:24 GMT;HttpOnly;Secure
|
||
Content-Type: text/plain; charset=UTF-8
|
||
```
|
||
|
||
본문 전문 (26 바이트):
|
||
|
||
```
|
||
User-agent: *
|
||
Disallow: /
|
||
```
|
||
|
||
WebFetch 로도 재확인(FETCH #1): *"This robots.txt configuration blocks all web crawlers from accessing any part of the website by disallowing the entire root directory with the '/' path."*
|
||
|
||
#### 4.2.2 `/index` HEAD — 응답 헤더 전문
|
||
|
||
```http
|
||
HTTP/1.1 200 OK
|
||
Date: Wed, 02 Sep 2026 12:57:24 GMT
|
||
Server: Apache
|
||
Allow: GET, POST, OPTIONS
|
||
Strict-Transport-Security: max-age=63072000
|
||
Expires: 0
|
||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||
Access-Control-Allow-Headers: Origin, Content-Type, content-type, Content-Style-Type, Accept, Authorization,DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,If-Modified-Since, x-requested-with, Content-Security-Policy, X-UA-Compatible, X-Content-Type-Options, X-FRAME-OPTIONS, Cache-Control, Pragma
|
||
X-XSS-Protection: 1; mode=block
|
||
Pragma: no-cache
|
||
Access-Control-Allow-Origin: *.mfds.go.kr
|
||
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
|
||
X-Content-Type-Options: nosniff
|
||
Content-Language: ko-KR
|
||
Access-Control-Allow-Methods: GET, POST, OPTIONS
|
||
Set-Cookie: key=value; SameSite=Lax;Secure;;HttpOnly;Secure
|
||
Set-Cookie: JSESSIONID=k7Y6_4LAfUcIcUJhxImuscJ3F_FN4Cz_YBEiHnOa.ext31; path=/; secure; HttpOnly; Max-Age=14400; Expires=Wed, 02-Sep-2026 16:57:24 GMT;HttpOnly;Secure
|
||
Set-Cookie: elevisor_for_j2ee_uid=5kqk2d6z9wbzq; path=/; Max-Age=31536000; Expires=Thu, 02-Sep-2027 12:57:24 GMT;HttpOnly;Secure
|
||
Transfer-Encoding: chunked
|
||
Content-Type: text/html;charset=UTF-8
|
||
```
|
||
|
||
TLS/연결 정보:
|
||
|
||
```
|
||
http_version=1.1 ssl_verify=0 remote_ip=116.67.90.161 time_total=0.101132
|
||
```
|
||
|
||
#### 4.2.3 실측에서 읽어낸 사실 12가지
|
||
|
||
| # | 관측 | 해석 |
|
||
|---|------|------|
|
||
| 1 | `Server: Apache` | 엣지 CDN/봇관리 제품이 앞단에 없다. Cloudflare 라면 `Server: cloudflare` + `cf-ray` 가 붙는다. |
|
||
| 2 | `cf-ray`/`__cf_bm`/`cf_clearance`/`_abck`/`x-datadome` **전무** | Cloudflare·Akamai·DataDome **미사용**. |
|
||
| 3 | `JSESSIONID=...ext21` / `...ext31` / `...ext41` | 요청마다 다른 노드 서픽스 → **WAS 클러스터(최소 3노드) + 로드밸런서**. 세션 유지가 필요한 흐름에서는 **쿠키 재사용이 필수**. |
|
||
| 4 | `JSESSIONID` `Max-Age=14400` | 세션 수명 **4시간**. 우리 작업(수분)에는 충분. |
|
||
| 5 | `elevisor_for_j2ee_uid=5kqk2d6z9wbzq`, `Max-Age=31536000` | 1년짜리 방문자 식별 쿠키. 이름상 J2EE APM/모니터링 제품(Elevisor) 계열로 추정되나 ⚠️ **미검증** — DuckDuckGo HTML 검색이 캡차로 막혀 확인 실패. **우리는 이 쿠키도 그대로 유지·재전송한다**(정상 클라이언트처럼 행동 = 정직한 행동). |
|
||
| 6 | `Set-Cookie: key=value; ...` | 문자 그대로 `key=value` 인 더미 쿠키. 개발 잔재로 보임. 무해. |
|
||
| 7 | `Cache-Control: no-cache, no-store, must-revalidate` (index) | 동적 페이지는 캐시 금지. **`If-Modified-Since` 로 304 를 기대하기 어려울 수 있다** → 조건부 요청은 시도하되 실패를 전제로 설계. |
|
||
| 8 | robots.txt 는 `Cache-Control: max-age=3600` | robots.txt 자체는 1시간 캐시 가능. RFC 9309 의 24시간 캐시 권고와 부합. |
|
||
| 9 | `Access-Control-Allow-Headers` 목록에 `If-Modified-Since` 포함 | 서버가 조건부 요청 헤더를 인지하고 있음. 시도할 가치 있음. |
|
||
| 10 | `http_version=1.1`, `akamai_hash` 공란 | HTTP/2 미협상. HTTP/2 지문 검사 불가능. |
|
||
| 11 | `remote_ip=116.67.90.161` | 단일 오리진 IP. CDN Anycast 아님. |
|
||
| 12 | `curl/8.10.1` UA 로 **200 OK + 완전한 HTML** | **UA 블랙리스트 없음, TLS 지문 검사 없음, JS 챌린지 없음.** 이것이 §0 결론 3·4 의 결정적 근거. |
|
||
|
||
#### 4.2.4 `/bbs/117` (DMF 등록 공고 게시판) 실측
|
||
|
||
요청: `curl -s -D hdr117.txt -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" --max-time 25 -o bbs117.html "https://nedrug.mfds.go.kr/bbs/117"`
|
||
|
||
- 상태: `HTTP/1.1 200 OK`. 헤더 구성은 `/index` 와 동일 패턴 (`JSESSIONID=QlkUfngIrxGZXjOHGuFtos2PYnYfyGSDXjZVAyMx.ext41`, `elevisor_for_j2ee_uid=6tyj95b4dgqkj`).
|
||
- 본문 크기: **382,506 바이트** — **JS 렌더링 없이 서버가 완성된 HTML 을 준다.** → **Playwright 불필요.**
|
||
- `<title>` : `의약품안전나라 > 의약품등 정보 > 의약품 및 화장품 품목정보 > 원료의약품등록(DMF) 정보`
|
||
(부제 title 태그: `현재메뉴 > 3차메뉴 > 2차메뉴 > 1차메뉴 > 식품의약품안전처 의약품통합정보시스템2`)
|
||
- **`<meta name="robots" content="index,follow"/>`** ← robots.txt 의 `Disallow: /` 와 **정면 충돌**.
|
||
- 캡차/WAF 스크립트 힌트: **없음** (`--- scripts hinting WAF/captcha ---` grep 결과 공란).
|
||
- 폼 필드(hidden 포함):
|
||
```html
|
||
<input type="text" style="width:200px;" name="keyword" id="keywordTop" placeholder="검색어로 메뉴·정보 검색 가능" value="" alt="통합검색 검색어 입력창" title="통합검색 검색어 입력창" />
|
||
<input type="hidden" name="totalPages" id="totalPages" value="71" />
|
||
<input type="hidden" name="page" id="page" value="1" />
|
||
<input type="hidden" name="searchYn" id="searchYn" value="" />
|
||
<input type="hidden" name="limit" id="limit" value="10" />
|
||
```
|
||
- 검출된 폼/파라미터 이름 전체: `action="/search"`, `btnSearch`, `ctgryNo`, `description`, `keyword`, `limit`, `page`, `registTsEnd`, `registTsStart`, `robots`, `searchYn`, `title`, `totalPages`
|
||
- **`totalPages=71`, `limit=10`** → 총 약 710건. WebFetch(FETCH #22)도 *"총 게시글: 710건"* 으로 일치.
|
||
- 목록 컬럼: **연번, 제목, 조회건수, 등록자, 등록일자**. 페이지당 10/20/30/40/50 선택 가능. 페이지네이션은 **GET 쿼리 파라미터** 방식, 10개 번호 + 처음/이전/다음/마지막 버튼.
|
||
- 로그인·캡차 요구 **없음** (공개 열람).
|
||
- 최근 게시글 제목 샘플(HTML grep, 인코딩 깨짐 포함 원문 그대로):
|
||
```
|
||
등록대상 원료의약품(DMF) 등록 공고(2021년 2월 1,2주차, 2021.2.1.-2021.02.14) 관련 현재
|
||
등록대상 원료의약품(DMF) 등록 공고(2021년 2월 1,2주차, 2021.2.1.-2021.02.14) 관련 현재
|
||
등록대상 원료의약품(DMF) 등록 공고(2021년 2월 1,2주차, 2021.2.1.-2021.02.14)
|
||
등록대상 원료의약품(DMF) 등록 공고(2021년 1월 4주차, 2021.1.25.-2021.01.31) 관련 현재
|
||
등록대상 원료의약품(DMF) 등록 공고(2021년 1월 4주차, 2021.1.25.-2021.01.31) 관련 현재
|
||
```
|
||
WebFetch(FETCH #22)가 본 최근 3건: `등록대상 원료의약품(DMF) 등록 공고(2021년 2월 1,2주차...)`, `(2021년 1월 4주차...)`, `(2021년 1월 3주차...)`
|
||
- HTML 내 날짜 문자열 빈도(상위 8):
|
||
```
|
||
4 2020-11-11
|
||
1 2026-08-27
|
||
1 2023-07-06
|
||
1 2021-02-19
|
||
1 2021-02-05
|
||
1 2021-01-28
|
||
1 2021-01-21
|
||
1 2021-01-08
|
||
```
|
||
- **주의**: `--- item links ---` 와 `--- onclick/js pagination ---` grep 결과가 **둘 다 공란**이었다. 게시글 상세 링크 URL 패턴과 페이지 이동 메커니즘을 이번 실측 명령으로는 추출하지 못했다. 실제 셀렉터·URL 패턴은 **구현 착수 시 반드시 재실측**해야 한다(부록 B).
|
||
|
||
#### 4.2.5 로컬 실행 환경 실측
|
||
|
||
```
|
||
Python 3.11.9
|
||
requests 2.32.3
|
||
httpx 0.28.1
|
||
curl_cffi -> ModuleNotFoundError: No module named 'curl_cffi' (미설치, 그리고 설치하지 않는다)
|
||
playwright -> ok
|
||
curl -> curl/8.10.1
|
||
```
|
||
|
||
### 4.3 robots.txt `Disallow: /` 와 `<meta robots content="index,follow">` 의 모순 — 우리의 해석
|
||
|
||
**사실관계**
|
||
|
||
1. `https://nedrug.mfds.go.kr/robots.txt` = `User-agent: * / Disallow: /` (`Last-Modified: 2025-08-07`).
|
||
2. 그런데 `/bbs/117` HTML 은 `<meta name="robots" content="index,follow"/>` 로 **색인 허용**을 선언.
|
||
3. 사이트는 검색엔진에 정상 노출되어 있다(검색 결과에 `/index`, `/safetyuseinfo`, `/bbs/117`, `/eng/index`, `/cntnts/80` 등장).
|
||
4. 즉 robots.txt 는 **의도된 전면 차단이라기보다 운영상 방치/실수일 가능성이 있다** ⚠️ 추정(단정 금지).
|
||
|
||
**규범적 사실 (RFC 9309)**
|
||
|
||
- robots.txt 는 **자발적 준수(voluntary compliance)** 규약이다. 위반 자체는 형사·민사 위법이 아니다.
|
||
- 다만 대법원 2021도1533 의 판시("보호조치나 이용약관 등 객관적으로 드러난 여러 사정을 종합적으로 고려하여 신중하게 판단")를 보면, robots.txt 는 **"서비스제공자가 접근을 제한하고 있는지"를 판단하는 객관적 사정 중 하나**로 평가될 여지가 있다.
|
||
|
||
**우리의 결정 (3단계 안전장치)**
|
||
|
||
| 단계 | 결정 | 근거 |
|
||
|------|------|------|
|
||
| **1. 소스 우선순위** | 정량 데이터는 **공공데이터포털 OpenAPI(15057075)** 로 받는다. HTML 접근은 API 로 얻을 수 없는 공고 게시물·첨부파일에만 사용한다. | API 는 정부가 명시적으로 개방한 채널이며 "이용허락범위 제한 없음". robots.txt 논쟁 자체가 발생하지 않는다. |
|
||
| **2. 최소 접근** | HTML 요청은 **하루 1회, 총 ≤120건, 간격 ≥2초, 동시성 1**. 신규 공고 탐지에 필요한 최신 5페이지만. | "통상적 이용과의 충돌" 및 "현실적 장애 발생"의 구성요건을 물리적으로 배제. |
|
||
| **3. 기록과 중단** | robots.txt 를 매 실행마다 다시 읽어 **변경을 감지**하고 SHA-256 을 로깅한다. 사이트가 `403`/차단 안내 페이지/명시적 크롤링 금지 문구를 반환하면 **즉시 영구 중단하고 사람에게 알린다(자동 재시도 금지)**. | 위반 의사 부재를 증명하는 운영 기록. "접근 제한 조치가 있었는가"에 대해 "있었다면 즉시 멈췄다"는 사실을 남긴다. |
|
||
|
||
> **주의**: 이 결정은 법률자문이 아니다. 데이터 사용 범위를 내부 리포트 밖으로 확대할 계획이 생기면 §8.9 의 에스컬레이션 기준에 따라 변호사 검토를 받는다.
|
||
|
||
---
|
||
|
||
## 5. RFC 9309 — robots.txt 규범과 우리의 준수 방침
|
||
|
||
RFC 9309 *"Robots Exclusion Protocol"* (IETF, 2022 발행). *"RFC 9309 specifies and extends the 'Robots Exclusion Protocol' method originally defined by Martijn Koster in 1994 for service owners to control how content served by their services may be accessed, if at all, by automatic clients known as crawlers."*
|
||
|
||
### 5.1 규범 요약 (원문 인용 포함)
|
||
|
||
| 항목 | 규범 | 원문 인용 |
|
||
|------|------|-----------|
|
||
| User-agent 매칭 | **대소문자 무시**. product token 은 letters/underscore/hyphen 만. 여러 그룹이 매칭되면 **규칙을 하나로 합쳐야** 한다. 명시적 매칭이 없으면 `*` 그룹을 따른다. | *"the matching groups' rules MUST be combined into one group"* |
|
||
| Allow/Disallow 우선순위 | **가장 구체적인(옥텟이 가장 많은) 매칭이 이긴다.** 동등하면 **Allow 우선**. | *"The most specific match found MUST be used. The most specific match is the match that has the most octets."* |
|
||
| HTTP 4xx (unavailable) | robots.txt 가 없는 것으로 간주 → **전체 접근 가능** | *"the crawler MAY access any resources on the server"* |
|
||
| HTTP 5xx (unreachable) | **완전 금지로 간주해야 한다.** 단 약 30일 이상 지속되면 unavailable 로 취급하거나 캐시본 사용 가능 | *"the crawler MUST assume complete disallow"* |
|
||
| 캐시 | **24시간 초과 사용 금지** (도달 불가한 경우 예외) | *"Crawlers SHOULD NOT use the cached version for more than 24 hours, unless the robots.txt file is unreachable."* |
|
||
| 파싱 크기 한도 | **최소 500 KiB 이상** 파싱해야 함 | *"The parsing limit MUST be at least 500 kibibytes."* |
|
||
| **Crawl-delay** | **표준에 없다.** allow / disallow / user-agent 만 규범이다. | *"Crawl-delay is not part of RFC 9309's normative specification—only allow, disallow, and user-agent directives are defined."* |
|
||
| 보안 아님 | robots.txt 는 보안 수단이 아니다. 경로를 적으면 오히려 공개된다. | *"the Robots Exclusion Protocol is not a substitute for valid content security measures, as listing paths in the robots.txt file exposes them publicly and thus makes the paths discoverable."* |
|
||
|
||
### 5.2 Python 표준 라이브러리 `urllib.robotparser`
|
||
|
||
| 메서드 | 시그니처 | 추가 버전 | 설명 |
|
||
|--------|----------|-----------|------|
|
||
| `set_url` | `set_url(url)` | 기본 | robots.txt URL 지정 |
|
||
| `read` | `read()` | 기본 | robots.txt 를 읽어 파서에 공급 |
|
||
| `parse` | `parse(lines)` | 기본 | 라인 리스트 파싱 |
|
||
| `can_fetch` | `can_fetch(useragent, url)` | 기본 | 허용 여부 반환 |
|
||
| `mtime` | `mtime()` | 기본 | 마지막으로 가져온 시각 |
|
||
| `modified` | `modified()` | 기본 | 가져온 시각을 현재로 설정 |
|
||
| `crawl_delay` | `crawl_delay(useragent)` | **3.6+** | `Crawl-delay` 값 (없거나 유효하지 않으면 `None`) |
|
||
| `request_rate` | `request_rate(useragent)` | **3.6+** | `RequestRate(requests, seconds)` 네임드튜플 (없으면 `None`) |
|
||
| `site_maps` | `site_maps()` | **3.8+** | `Sitemap` 리스트 (없으면 `None`) |
|
||
|
||
로컬 Python 은 **3.11.9** 이므로 전부 사용 가능하다.
|
||
|
||
### 5.3 우리의 robots.txt 취급 코드 (완결)
|
||
|
||
`src/dmf_crawler/net/robots.py`:
|
||
|
||
```python
|
||
"""robots.txt 를 RFC 9309 에 맞게 읽고, 판단 결과를 '기록' 한다.
|
||
|
||
방침: robots.txt 를 '차단 장치' 가 아니라 '사이트 운영자의 의사표시' 로 읽고,
|
||
그 의사표시를 감사 로그에 남긴 뒤, policy.yaml 이 허용한 최소 경로만 접근한다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import logging
|
||
import time
|
||
import urllib.robotparser
|
||
from dataclasses import dataclass
|
||
from urllib.parse import urljoin, urlparse
|
||
|
||
import httpx
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
ROBOTS_CACHE_TTL_SEC = 24 * 60 * 60 # RFC 9309: 24시간 초과 사용 금지
|
||
ROBOTS_PARSE_LIMIT_BYTES = 512 * 1024 # RFC 9309: 최소 500 KiB
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RobotsVerdict:
|
||
"""robots.txt 판단 결과 스냅샷 (감사 로그용)."""
|
||
|
||
robots_url: str
|
||
http_status: int | None
|
||
raw_text: str
|
||
sha256: str
|
||
allowed: bool
|
||
reason: str
|
||
crawl_delay: float | None
|
||
fetched_at: float
|
||
|
||
def as_log_line(self) -> str:
|
||
return (
|
||
f"robots[{self.robots_url}] status={self.http_status} "
|
||
f"allowed={self.allowed} reason={self.reason!r} "
|
||
f"crawl_delay={self.crawl_delay} sha256={self.sha256[:16]} "
|
||
f"bytes={len(self.raw_text)}"
|
||
)
|
||
|
||
|
||
class RobotsGate:
|
||
"""도메인별 robots.txt 를 24시간 캐시하며 판단을 제공한다."""
|
||
|
||
def __init__(self, client: httpx.Client, user_agent: str) -> None:
|
||
self._client = client
|
||
self._ua = user_agent
|
||
self._cache: dict[str, tuple[float, urllib.robotparser.RobotFileParser, str, int | None]] = {}
|
||
|
||
@staticmethod
|
||
def _robots_url(url: str) -> str:
|
||
p = urlparse(url)
|
||
return f"{p.scheme}://{p.netloc}/robots.txt"
|
||
|
||
def _load(self, robots_url: str):
|
||
now = time.time()
|
||
cached = self._cache.get(robots_url)
|
||
if cached and (now - cached[0]) < ROBOTS_CACHE_TTL_SEC:
|
||
return cached[1], cached[2], cached[3]
|
||
|
||
parser = urllib.robotparser.RobotFileParser()
|
||
parser.set_url(robots_url)
|
||
raw = ""
|
||
status: int | None = None
|
||
try:
|
||
resp = self._client.get(robots_url, timeout=httpx.Timeout(10.0, read=20.0))
|
||
status = resp.status_code
|
||
if 400 <= status < 500:
|
||
# RFC 9309: unavailable -> 전체 허용으로 간주
|
||
parser.parse([])
|
||
raw = ""
|
||
elif 500 <= status < 600:
|
||
# RFC 9309: unreachable -> 완전 금지로 간주
|
||
parser.parse(["User-agent: *", "Disallow: /"])
|
||
raw = "(5xx -> assumed complete disallow per RFC 9309)"
|
||
else:
|
||
raw = resp.text[:ROBOTS_PARSE_LIMIT_BYTES]
|
||
parser.parse(raw.splitlines())
|
||
except httpx.HTTPError as exc:
|
||
log.warning("robots.txt fetch failed for %s: %r -> assume disallow", robots_url, exc)
|
||
parser.parse(["User-agent: *", "Disallow: /"])
|
||
raw = f"(fetch error: {exc!r} -> assumed complete disallow)"
|
||
|
||
parser.modified()
|
||
self._cache[robots_url] = (now, parser, raw, status)
|
||
return parser, raw, status
|
||
|
||
def check(self, url: str) -> RobotsVerdict:
|
||
robots_url = self._robots_url(url)
|
||
parser, raw, status = self._load(robots_url)
|
||
allowed = parser.can_fetch(self._ua, url)
|
||
delay = parser.crawl_delay(self._ua)
|
||
rate = parser.request_rate(self._ua)
|
||
if delay is None and rate is not None and rate.requests:
|
||
delay = rate.seconds / rate.requests
|
||
verdict = RobotsVerdict(
|
||
robots_url=robots_url,
|
||
http_status=status,
|
||
raw_text=raw,
|
||
sha256=hashlib.sha256(raw.encode("utf-8")).hexdigest(),
|
||
allowed=allowed,
|
||
reason="rfc9309-match" if raw else "no-robots-or-4xx",
|
||
crawl_delay=float(delay) if delay is not None else None,
|
||
fetched_at=time.time(),
|
||
)
|
||
log.info(verdict.as_log_line())
|
||
return verdict
|
||
|
||
|
||
def resolve_join(base: str, href: str) -> str:
|
||
"""상세 링크 상대경로를 절대 URL 로."""
|
||
return urljoin(base, href)
|
||
```
|
||
|
||
`config/policy.yaml` — robots 판단을 코드가 아니라 정책 파일에 노출시켜 감사 가능하게 한다:
|
||
|
||
```yaml
|
||
# config/policy.yaml
|
||
site:
|
||
base_url: "https://nedrug.mfds.go.kr"
|
||
# robots.txt 는 2026-09-02 기준 "User-agent: * / Disallow: /" 이다.
|
||
# 우리는 이를 인지하고 있으며(robots_acknowledged=true),
|
||
# 아래 allowlist 이외의 어떤 경로도 요청하지 않는다.
|
||
robots_acknowledged: true
|
||
robots_snapshot_sha256: "" # 최초 실행 시 자동 기록. 변경되면 실행 중단 후 사람 확인.
|
||
on_robots_change: "halt_and_notify"
|
||
path_allowlist:
|
||
- "/bbs/117" # DMF 등록 공고 게시판 목록
|
||
- "/bbs/117/*" # 개별 공고 상세 (실측 후 정확 패턴으로 교체 필요)
|
||
path_denylist:
|
||
- "/search" # 통합검색은 절대 호출하지 않는다 (부하 유발)
|
||
- "*login*"
|
||
- "*download*all*"
|
||
budget:
|
||
max_requests_per_run: 120
|
||
max_pages_list: 5 # 신규 탐지에는 최신 5페이지면 충분
|
||
daily_runs: 1
|
||
```
|
||
|
||
---
|
||
|
||
## 6. "정중한 크롤러" 규칙 체크리스트 — 파라미터 + 코드
|
||
|
||
### 6.1 체크리스트 (9규칙) 와 확정 파라미터
|
||
|
||
| # | 규칙 | 확정 파라미터 | 근거 |
|
||
|---|------|---------------|------|
|
||
| 1 | **robots.txt 준수·기록** | 매 실행 재조회, 24h 캐시, 500 KiB 파싱, SHA-256 스냅샷 비교, 변경 시 `halt_and_notify` | RFC 9309 §2.3~2.5 |
|
||
| 2 | **하루 1회 저빈도** | `daily_runs=1`, 06:00 KST, `max_requests_per_run=120`, `max_pages_list=5` | "real users do not request 100 pages in two seconds" |
|
||
| 3 | **연락처 포함 UA** | `DMF-Crawler/1.0 (+mailto:yunchanpaca@gmail.com; internal regulatory monitoring; 1 req/2s; daily)` | *"Include contact information in your crawler's user-agent string so admins can reach you."* |
|
||
| 4 | **지수 백오프 + 지터** | `base=5.0s`, `factor=2.0`, `cap=300.0s`, **full jitter**, `max_retries=4` (총 대기 상한 약 10분) | *"Jitter is a small, randomly selected delay added to each retry attempt to prevent multiple HTTP clients from retrying simultaneously."* / *"Pure exponential backoff is recognized by sophisticated bot-detection systems, but adding random jitter breaks this fingerprint."* |
|
||
| 5 | **조건부 요청** | `If-Modified-Since` + `If-None-Match` 상시. 304 면 즉시 스킵. ETag/Last-Modified 를 SQLite 에 영속화 | RFC 9110. nedrug 이 `Access-Control-Allow-Headers` 에 `If-Modified-Since` 를 명시 |
|
||
| 6 | **세션 재사용** | `httpx.Client` 1개, 쿠키 자동 유지(`JSESSIONID`, `elevisor_for_j2ee_uid`), keep-alive, `max_connections=1` | 매 요청 새 세션은 봇 신호. WAS 클러스터 노드 고정에도 유리 |
|
||
| 7 | **동시성 1** | `httpx.Limits(max_connections=1, max_keepalive_connections=1)` + 글로벌 요청 락 | 서버 부하를 물리적으로 1 요청 이하로 고정 → 컴퓨터등장애업무방해죄 구성요건 배제 |
|
||
| 8 | **서킷 브레이커** | 연속 실패 5회 → OPEN 24시간. `403`/차단 페이지 → OPEN **무기한**(사람 해제 전까지). 상태는 SQLite 영속 | *"After five straight server errors or timeouts, pause crawling for several hours."* |
|
||
| 9 | **시간대 분산** | 06:00:00 고정이 아니라 06:00 + `uniform(0, 600)` 초. 요청 간 간격도 `2.0 + uniform(0, 1.0)` | 정확히 등간격/정각 요청은 기계 신호. 서버의 정각 배치와도 겹치지 않게 |
|
||
|
||
**추가 준수 사항 (규칙 외)**
|
||
|
||
| 항목 | 값 |
|
||
|------|-----|
|
||
| 타임아웃 | connect 10.0s / read 30.0s / write 10.0s / pool 5.0s |
|
||
| `Retry-After` | **절대 우선**. HTTP-date 형식과 delay-seconds 형식 모두 파싱 (RFC 9110) |
|
||
| `Accept-Encoding` | `gzip, deflate` (서버 대역폭 절약 = 정중함) |
|
||
| HTTP 버전 | `http2=False` (nedrug 이 1.1 이므로 굳이 협상 시도 안 함) |
|
||
| 리다이렉트 | `follow_redirects=True` 이되 최대 5회, 도메인 이탈 시 중단 |
|
||
| 응답 크기 상한 | 20 MB (초과 시 중단 — 첨부파일은 별도 정책) |
|
||
| 캐시 | 원본 HTML/JSON 을 `data/raw/YYYY-MM-DD/` 에 그대로 보관 → **재크롤링 없이 파싱 재시도 가능** |
|
||
|
||
### 6.2 완결 구현 — `src/dmf_crawler/net/politeness.py`
|
||
|
||
```python
|
||
"""정중한 크롤러 핵심 모듈.
|
||
|
||
설계 원칙:
|
||
1) 서버에 절대 부담을 주지 않는다 (동시성 1, 최소 간격 2초, 하루 1회).
|
||
2) 우리가 누구인지 숨기지 않는다 (연락처 포함 UA).
|
||
3) 막히면 우회하지 않고 멈춘다 (서킷 브레이커 + 사람 알림).
|
||
4) 같은 것을 두 번 받지 않는다 (조건부 요청 + 원본 캐시).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import random
|
||
import sqlite3
|
||
import threading
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timedelta, timezone
|
||
from email.utils import parsedate_to_datetime
|
||
from pathlib import Path
|
||
from typing import Any, Final
|
||
|
||
import httpx
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
# ---------------------------------------------------------------- 상수 (SSOT)
|
||
|
||
CONTACT_EMAIL: Final[str] = "yunchanpaca@gmail.com"
|
||
USER_AGENT: Final[str] = (
|
||
"DMF-Crawler/1.0 "
|
||
f"(+mailto:{CONTACT_EMAIL}; internal regulatory monitoring; 1 req/2s; daily)"
|
||
)
|
||
|
||
MIN_INTERVAL_SEC: Final[float] = 2.0 # 요청 간 최소 간격
|
||
INTERVAL_JITTER_SEC: Final[float] = 1.0 # 위 간격에 더할 랜덤 지터 상한
|
||
|
||
BACKOFF_BASE_SEC: Final[float] = 5.0
|
||
BACKOFF_FACTOR: Final[float] = 2.0
|
||
BACKOFF_CAP_SEC: Final[float] = 300.0
|
||
MAX_RETRIES: Final[int] = 4
|
||
|
||
CIRCUIT_FAIL_THRESHOLD: Final[int] = 5
|
||
CIRCUIT_OPEN_SEC: Final[int] = 24 * 60 * 60 # 24시간
|
||
MAX_REQUESTS_PER_RUN: Final[int] = 120
|
||
MAX_RESPONSE_BYTES: Final[int] = 20 * 1024 * 1024
|
||
|
||
TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=5.0)
|
||
LIMITS = httpx.Limits(max_connections=1, max_keepalive_connections=1, keepalive_expiry=60.0)
|
||
|
||
# 요청 헤더는 '브라우저 흉내' 가 아니라 '정직한 최소 집합' 이다.
|
||
BASE_HEADERS: Final[dict[str, str]] = {
|
||
"User-Agent": USER_AGENT,
|
||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.5",
|
||
"Accept-Encoding": "gzip, deflate",
|
||
"Connection": "keep-alive",
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------- 예외
|
||
|
||
class CrawlBlocked(RuntimeError):
|
||
"""차단으로 판단되어 즉시 중단해야 하는 상황 (사람 개입 필요)."""
|
||
|
||
|
||
class CircuitOpen(RuntimeError):
|
||
"""서킷 브레이커가 열려 있어 요청을 보내지 않는다."""
|
||
|
||
|
||
class BudgetExceeded(RuntimeError):
|
||
"""1회 실행 요청 예산 초과."""
|
||
|
||
|
||
# ---------------------------------------------------------------- 상태 저장소
|
||
|
||
class StateStore:
|
||
"""ETag / Last-Modified / 서킷 상태를 SQLite 로 영속화."""
|
||
|
||
def __init__(self, db_path: str | os.PathLike[str]) -> None:
|
||
self.path = Path(db_path)
|
||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
|
||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||
self._conn.executescript(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS http_cache (
|
||
url TEXT PRIMARY KEY,
|
||
etag TEXT,
|
||
last_modified TEXT,
|
||
fetched_at REAL NOT NULL,
|
||
body_sha256 TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS circuit (
|
||
host TEXT PRIMARY KEY,
|
||
fail_count INTEGER NOT NULL DEFAULT 0,
|
||
opened_until REAL NOT NULL DEFAULT 0,
|
||
permanent INTEGER NOT NULL DEFAULT 0,
|
||
last_reason TEXT
|
||
);
|
||
"""
|
||
)
|
||
self._conn.commit()
|
||
|
||
# --- 조건부 요청용 검증자 -------------------------------------------
|
||
|
||
def validators(self, url: str) -> dict[str, str]:
|
||
row = self._conn.execute(
|
||
"SELECT etag, last_modified FROM http_cache WHERE url = ?", (url,)
|
||
).fetchone()
|
||
if not row:
|
||
return {}
|
||
headers: dict[str, str] = {}
|
||
if row[0]:
|
||
headers["If-None-Match"] = row[0]
|
||
if row[1]:
|
||
headers["If-Modified-Since"] = row[1]
|
||
return headers
|
||
|
||
def remember(self, url: str, resp: httpx.Response, body_sha256: str | None) -> None:
|
||
self._conn.execute(
|
||
"""
|
||
INSERT INTO http_cache (url, etag, last_modified, fetched_at, body_sha256)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
ON CONFLICT(url) DO UPDATE SET
|
||
etag=excluded.etag,
|
||
last_modified=excluded.last_modified,
|
||
fetched_at=excluded.fetched_at,
|
||
body_sha256=excluded.body_sha256
|
||
""",
|
||
(
|
||
url,
|
||
resp.headers.get("ETag"),
|
||
resp.headers.get("Last-Modified"),
|
||
time.time(),
|
||
body_sha256,
|
||
),
|
||
)
|
||
self._conn.commit()
|
||
|
||
# --- 서킷 브레이커 ---------------------------------------------------
|
||
|
||
def circuit_state(self, host: str) -> tuple[int, float, bool, str | None]:
|
||
row = self._conn.execute(
|
||
"SELECT fail_count, opened_until, permanent, last_reason FROM circuit WHERE host = ?",
|
||
(host,),
|
||
).fetchone()
|
||
if not row:
|
||
return 0, 0.0, False, None
|
||
return int(row[0]), float(row[1]), bool(row[2]), row[3]
|
||
|
||
def record_failure(self, host: str, reason: str, permanent: bool = False) -> tuple[int, bool]:
|
||
fails, _, was_perm, _ = self.circuit_state(host)
|
||
fails += 1
|
||
perm = bool(was_perm or permanent)
|
||
opened_until = 0.0
|
||
if perm:
|
||
opened_until = time.time() + 10 * 365 * 24 * 3600 # 사실상 무기한
|
||
elif fails >= CIRCUIT_FAIL_THRESHOLD:
|
||
opened_until = time.time() + CIRCUIT_OPEN_SEC
|
||
self._conn.execute(
|
||
"""
|
||
INSERT INTO circuit (host, fail_count, opened_until, permanent, last_reason)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
ON CONFLICT(host) DO UPDATE SET
|
||
fail_count=excluded.fail_count,
|
||
opened_until=excluded.opened_until,
|
||
permanent=excluded.permanent,
|
||
last_reason=excluded.last_reason
|
||
""",
|
||
(host, fails, opened_until, int(perm), reason),
|
||
)
|
||
self._conn.commit()
|
||
return fails, opened_until > time.time()
|
||
|
||
def record_success(self, host: str) -> None:
|
||
_, _, perm, _ = self.circuit_state(host)
|
||
if perm:
|
||
return # 영구 차단은 자동 해제하지 않는다. 사람만 해제할 수 있다.
|
||
self._conn.execute(
|
||
"""
|
||
INSERT INTO circuit (host, fail_count, opened_until, permanent, last_reason)
|
||
VALUES (?, 0, 0, 0, NULL)
|
||
ON CONFLICT(host) DO UPDATE SET fail_count=0, opened_until=0, last_reason=NULL
|
||
""",
|
||
(host,),
|
||
)
|
||
self._conn.commit()
|
||
|
||
def reset_circuit(self, host: str) -> None:
|
||
"""사람이 수동으로 서킷을 닫을 때만 호출 (CLI: dmf-crawler circuit reset)."""
|
||
self._conn.execute("DELETE FROM circuit WHERE host = ?", (host,))
|
||
self._conn.commit()
|
||
|
||
|
||
# ---------------------------------------------------------------- 백오프
|
||
|
||
def full_jitter_backoff(attempt: int) -> float:
|
||
"""AWS 스타일 full jitter: sleep = uniform(0, min(cap, base * factor**attempt)).
|
||
|
||
attempt 는 0부터 시작. 순수 지수 백오프는 그 자체로 기계 지문이 되므로 지터가 필수다.
|
||
"""
|
||
ceiling = min(BACKOFF_CAP_SEC, BACKOFF_BASE_SEC * (BACKOFF_FACTOR ** attempt))
|
||
return random.uniform(0.0, ceiling)
|
||
|
||
|
||
def parse_retry_after(value: str | None) -> float | None:
|
||
"""RFC 9110 Retry-After: delay-seconds 또는 HTTP-date 둘 다 처리."""
|
||
if not value:
|
||
return None
|
||
value = value.strip()
|
||
try:
|
||
return max(0.0, float(int(value)))
|
||
except ValueError:
|
||
pass
|
||
try:
|
||
when = parsedate_to_datetime(value)
|
||
if when.tzinfo is None:
|
||
when = when.replace(tzinfo=timezone.utc)
|
||
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------- 페이서
|
||
|
||
class Pacer:
|
||
"""전역 요청 간격을 강제한다. 동시성 1 을 락으로도 이중 보장."""
|
||
|
||
def __init__(self, min_interval: float = MIN_INTERVAL_SEC,
|
||
jitter: float = INTERVAL_JITTER_SEC) -> None:
|
||
self._min = min_interval
|
||
self._jitter = jitter
|
||
self._last = 0.0
|
||
self._lock = threading.Lock()
|
||
|
||
def wait(self) -> None:
|
||
with self._lock:
|
||
target = self._last + self._min + random.uniform(0.0, self._jitter)
|
||
now = time.monotonic()
|
||
if now < target:
|
||
time.sleep(target - now)
|
||
self._last = time.monotonic()
|
||
|
||
|
||
# ---------------------------------------------------------------- 차단 판별
|
||
|
||
BLOCK_MARKERS: Final[tuple[str, ...]] = (
|
||
"비정상적인 접근",
|
||
"접근이 차단",
|
||
"차단되었습니다",
|
||
"웹방화벽",
|
||
"일시적으로 이용",
|
||
"Access Denied",
|
||
"Forbidden",
|
||
"captcha",
|
||
"recaptcha",
|
||
"hcaptcha",
|
||
"Turnstile",
|
||
"cf-error-details",
|
||
"Attention Required! | Cloudflare",
|
||
"Error 1010",
|
||
"Error 1015",
|
||
"Error 1020",
|
||
)
|
||
|
||
|
||
def looks_blocked(resp: httpx.Response, text: str) -> str | None:
|
||
"""차단 시그니처를 찾으면 사유 문자열을, 아니면 None 을 반환."""
|
||
if resp.status_code in (401, 403, 407, 451):
|
||
return f"http-{resp.status_code}"
|
||
if "cf-mitigated" in resp.headers:
|
||
return f"cf-mitigated:{resp.headers['cf-mitigated']}"
|
||
if "x-datadome" in {k.lower() for k in resp.headers}:
|
||
return "datadome-header"
|
||
lowered = text[:20000].lower()
|
||
for marker in BLOCK_MARKERS:
|
||
if marker.lower() in lowered:
|
||
return f"marker:{marker}"
|
||
# 200 인데 본문이 비정상적으로 짧고 JS 리다이렉트만 있는 경우
|
||
if resp.status_code == 200 and len(text) < 1500 and "location.href" in lowered:
|
||
return "js-redirect-stub"
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------- 클라이언트
|
||
|
||
@dataclass
|
||
class FetchResult:
|
||
url: str
|
||
status: int
|
||
from_cache: bool # 304 로 인해 본문을 받지 않았는가
|
||
text: str
|
||
headers: dict[str, str] = field(default_factory=dict)
|
||
elapsed_sec: float = 0.0
|
||
|
||
|
||
class PoliteClient:
|
||
"""동시성 1, 간격 2초+지터, 조건부 요청, 백오프, 서킷 브레이커를 강제하는 HTTP 클라이언트."""
|
||
|
||
def __init__(self, store: StateStore, base_url: str = "") -> None:
|
||
self.store = store
|
||
self.pacer = Pacer()
|
||
self._budget = MAX_REQUESTS_PER_RUN
|
||
self._client = httpx.Client(
|
||
headers=BASE_HEADERS,
|
||
timeout=TIMEOUT,
|
||
limits=LIMITS,
|
||
follow_redirects=True,
|
||
max_redirects=5,
|
||
http2=False, # nedrug 은 HTTP/1.1. 굳이 h2 협상하지 않는다.
|
||
base_url=base_url,
|
||
)
|
||
|
||
def close(self) -> None:
|
||
self._client.close()
|
||
|
||
def __enter__(self) -> "PoliteClient":
|
||
return self
|
||
|
||
def __exit__(self, *exc: Any) -> None:
|
||
self.close()
|
||
|
||
# --- 서킷 확인 -------------------------------------------------------
|
||
|
||
def _assert_circuit_closed(self, host: str) -> None:
|
||
fails, opened_until, permanent, reason = self.store.circuit_state(host)
|
||
now = time.time()
|
||
if permanent:
|
||
raise CircuitOpen(
|
||
f"[{host}] 영구 차단 상태입니다 (사유={reason!r}). "
|
||
f"사람이 확인 후 'dmf-crawler circuit reset {host}' 로만 해제할 수 있습니다."
|
||
)
|
||
if opened_until > now:
|
||
remain = timedelta(seconds=int(opened_until - now))
|
||
raise CircuitOpen(
|
||
f"[{host}] 서킷 OPEN (실패 {fails}회, 사유={reason!r}). 남은 시간 {remain}."
|
||
)
|
||
|
||
# --- 핵심 요청 -------------------------------------------------------
|
||
|
||
def get(self, url: str, *, referer: str | None = None,
|
||
conditional: bool = True) -> FetchResult:
|
||
host = httpx.URL(url).host or ""
|
||
self._assert_circuit_closed(host)
|
||
|
||
if self._budget <= 0:
|
||
raise BudgetExceeded(f"1회 실행 요청 예산 {MAX_REQUESTS_PER_RUN} 건을 모두 소진했습니다.")
|
||
|
||
headers: dict[str, str] = {}
|
||
if referer:
|
||
headers["Referer"] = referer
|
||
if conditional:
|
||
headers.update(self.store.validators(url))
|
||
|
||
last_exc: Exception | None = None
|
||
for attempt in range(MAX_RETRIES + 1):
|
||
self.pacer.wait()
|
||
self._budget -= 1
|
||
started = time.monotonic()
|
||
try:
|
||
resp = self._client.get(url, headers=headers)
|
||
except httpx.HTTPError as exc:
|
||
last_exc = exc
|
||
fails, opened = self.store.record_failure(host, f"transport:{exc!r}")
|
||
log.warning("요청 실패(%s/%s) %s: %r", attempt + 1, MAX_RETRIES + 1, url, exc)
|
||
if opened:
|
||
raise CircuitOpen(f"[{host}] 연속 실패 {fails}회 → 서킷 OPEN") from exc
|
||
time.sleep(full_jitter_backoff(attempt))
|
||
continue
|
||
|
||
elapsed = time.monotonic() - started
|
||
|
||
# 304: 변경 없음 → 본문 없이 종료 (가장 정중한 결과)
|
||
if resp.status_code == 304:
|
||
self.store.record_success(host)
|
||
log.info("304 Not Modified: %s (%.2fs)", url, elapsed)
|
||
return FetchResult(url, 304, True, "", dict(resp.headers), elapsed)
|
||
|
||
# 429 / 503: Retry-After 절대 우선
|
||
if resp.status_code in (429, 503):
|
||
wait = parse_retry_after(resp.headers.get("Retry-After"))
|
||
if wait is None:
|
||
wait = full_jitter_backoff(attempt)
|
||
wait = min(wait, BACKOFF_CAP_SEC)
|
||
fails, opened = self.store.record_failure(host, f"http-{resp.status_code}")
|
||
log.warning(
|
||
"%s from %s → %.1f초 대기 (시도 %s/%s, 누적실패 %s)",
|
||
resp.status_code, url, wait, attempt + 1, MAX_RETRIES + 1, fails,
|
||
)
|
||
if opened:
|
||
raise CircuitOpen(f"[{host}] {resp.status_code} 반복 → 서킷 OPEN")
|
||
time.sleep(wait)
|
||
continue
|
||
|
||
# 5xx: 백오프 재시도
|
||
if 500 <= resp.status_code < 600:
|
||
fails, opened = self.store.record_failure(host, f"http-{resp.status_code}")
|
||
if opened:
|
||
raise CircuitOpen(f"[{host}] 5xx 연속 → 서킷 OPEN")
|
||
time.sleep(full_jitter_backoff(attempt))
|
||
continue
|
||
|
||
# 응답 크기 상한
|
||
if len(resp.content) > MAX_RESPONSE_BYTES:
|
||
raise CrawlBlocked(
|
||
f"응답이 {len(resp.content)} 바이트로 상한 {MAX_RESPONSE_BYTES} 초과: {url}"
|
||
)
|
||
|
||
text = resp.text
|
||
reason = looks_blocked(resp, text)
|
||
if reason:
|
||
# 차단은 재시도하지 않는다. 영구 서킷 OPEN 후 사람 호출.
|
||
self.store.record_failure(host, f"blocked:{reason}", permanent=True)
|
||
raise CrawlBlocked(
|
||
f"차단으로 판단됨 url={url} status={resp.status_code} reason={reason}. "
|
||
"자동 재시도를 중단하고 사람의 확인을 요청합니다."
|
||
)
|
||
|
||
resp.raise_for_status()
|
||
self.store.record_success(host)
|
||
self.store.remember(url, resp, None)
|
||
log.info("200 OK %s (%d bytes, %.2fs)", url, len(resp.content), elapsed)
|
||
return FetchResult(url, resp.status_code, False, text, dict(resp.headers), elapsed)
|
||
|
||
raise CrawlBlocked(f"재시도 {MAX_RETRIES + 1}회 모두 실패: {url} (마지막 예외: {last_exc!r})")
|
||
|
||
|
||
# ---------------------------------------------------------------- 실행 시각 분산
|
||
|
||
def sleep_until_scheduled(hour: int = 6, minute: int = 0, spread_sec: int = 600) -> None:
|
||
"""06:00 정각이 아니라 06:00 + uniform(0, spread_sec) 에 실행되도록 대기.
|
||
|
||
정확히 정각에 때리는 요청은 (a) 기계 신호이고 (b) 서버의 정각 배치와 겹친다.
|
||
"""
|
||
now = datetime.now()
|
||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||
if target <= now:
|
||
target += timedelta(days=1)
|
||
target += timedelta(seconds=random.uniform(0, spread_sec))
|
||
delay = (target - datetime.now()).total_seconds()
|
||
if delay > 0:
|
||
log.info("예정 실행 시각까지 %.0f초 대기 (목표 %s)", delay, target.isoformat(timespec="seconds"))
|
||
time.sleep(delay)
|
||
|
||
|
||
# ---------------------------------------------------------------- 사용 예
|
||
|
||
def run_once(db_path: str = "data/state.sqlite3") -> None:
|
||
"""실행 진입점 예시: 목록 5페이지만 정중하게 가져온다."""
|
||
store = StateStore(db_path)
|
||
base = "https://nedrug.mfds.go.kr"
|
||
list_url = f"{base}/bbs/117"
|
||
|
||
with PoliteClient(store) as client:
|
||
try:
|
||
first = client.get(list_url)
|
||
if first.from_cache:
|
||
log.info("변경 없음 — 오늘은 파싱할 것이 없습니다.")
|
||
return
|
||
snapshot_dir = Path("data/raw") / datetime.now().strftime("%Y-%m-%d")
|
||
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||
(snapshot_dir / "bbs117_page1.html").write_text(first.text, encoding="utf-8")
|
||
|
||
for page in range(2, 6): # max_pages_list = 5
|
||
url = f"{list_url}?page={page}"
|
||
res = client.get(url, referer=list_url)
|
||
if res.from_cache:
|
||
continue
|
||
(snapshot_dir / f"bbs117_page{page}.html").write_text(res.text, encoding="utf-8")
|
||
|
||
except CircuitOpen as exc:
|
||
log.error("서킷 열림: %s", exc)
|
||
notify_windows("DMF Crawler 중단", str(exc))
|
||
except CrawlBlocked as exc:
|
||
log.error("차단 감지: %s", exc)
|
||
notify_windows("DMF Crawler 차단 감지 — 사람 확인 필요", str(exc))
|
||
except BudgetExceeded as exc:
|
||
log.error("예산 초과: %s", exc)
|
||
|
||
|
||
def notify_windows(title: str, message: str) -> None:
|
||
"""Windows 토스트 알림 (PowerShell BurntToast 미설치 환경도 견디도록 폴백)."""
|
||
import subprocess
|
||
|
||
payload = json.dumps({"title": title, "message": message}, ensure_ascii=False)
|
||
log.error("NOTIFY %s", payload)
|
||
ps = (
|
||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, "
|
||
"ContentType = WindowsRuntime] > $null; "
|
||
"$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent("
|
||
"[Windows.UI.Notifications.ToastTemplateType]::ToastText02); "
|
||
f"$t.GetElementsByTagName('text').Item(0).AppendChild($t.CreateTextNode({title!r})) > $null; "
|
||
f"$t.GetElementsByTagName('text').Item(1).AppendChild($t.CreateTextNode({message[:200]!r})) > $null; "
|
||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('DMF Crawler')"
|
||
".Show([Windows.UI.Notifications.ToastNotification]::new($t))"
|
||
)
|
||
try:
|
||
subprocess.run(
|
||
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
|
||
check=False, timeout=20, capture_output=True,
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - 알림 실패가 크롤링을 죽이면 안 된다
|
||
log.warning("Windows 알림 실패: %r", exc)
|
||
```
|
||
|
||
### 6.3 왜 "지터"가 단순한 예의가 아니라 기술적 필수인가
|
||
|
||
- 순수 지수 백오프는 재시도 시각이 **결정적**이라, 여러 클라이언트가 동시에 재시도하는 thundering herd 를 만든다. *"when everyone's retry schedule is synchronized, it just makes the stampede happen at longer intervals."*
|
||
- 동시에 그 규칙성 자체가 지문이 된다: *"Pure exponential backoff is recognized by sophisticated bot-detection systems, but adding random jitter breaks this fingerprint."*
|
||
- 우리 프로젝트는 클라이언트가 1대뿐이라 herd 문제는 없지만, **"정확히 5초, 10초, 20초"라는 패턴이 로그에 남는 것 자체를 피한다**는 관점에서 full jitter 를 쓴다.
|
||
|
||
### 6.4 조건부 요청이 실패할 때의 폴백
|
||
|
||
nedrug 의 동적 페이지는 `Cache-Control: no-cache, no-store, must-revalidate` 라 **304 를 주지 않을 가능성이 높다**(§4.2.3 관측 7). 그래서 2중 안전장치를 둔다:
|
||
|
||
1. **1차**: `If-None-Match` / `If-Modified-Since` 를 보낸다 → 304 면 즉시 종료(요청 1건으로 끝).
|
||
2. **2차(폴백)**: 200 을 받았다면 **본문의 SHA-256 을 이전 값과 비교**한다. 같으면 파싱을 건너뛴다. 서버 부하는 못 줄이지만 CPU 와 오탐은 줄인다.
|
||
|
||
```python
|
||
import hashlib
|
||
|
||
def body_digest(text: str) -> str:
|
||
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
|
||
|
||
|
||
def changed_since_last(store: StateStore, url: str, text: str) -> bool:
|
||
row = store._conn.execute(
|
||
"SELECT body_sha256 FROM http_cache WHERE url = ?", (url,)
|
||
).fetchone()
|
||
digest = body_digest(text)
|
||
return not (row and row[0] == digest)
|
||
```
|
||
|
||
> 주의: 게시판 HTML 에는 조회수·세션 토큰 등 매 요청 바뀌는 값이 섞여 있어 전체 해시는 항상 달라질 수 있다. **실제로는 목록 테이블 영역만 추출해 정규화한 뒤 해시**해야 한다. 구현 시 파서 계층에서 처리한다(부록 B).
|
||
|
||
---
|
||
|
||
## 7. 도구 비교표 (2026 기준)과 채택/배제 결정
|
||
|
||
### 7.1 실측 기반 도구 현황표
|
||
|
||
모든 버전·날짜·스타 수는 **2026-09-02 실제 조회값**이다.
|
||
|
||
| 도구 | 최신 버전 / 날짜 | 스타 · 지표 | 하는 일 | 2026 유지보수 상태 | 이 프로젝트 |
|
||
|------|------------------|-------------|---------|--------------------|-------------|
|
||
| **`requests`** | 2.32.3 (로컬 설치됨) | 표준 | 동기 HTTP | 안정 | ⚠️ **보조만** — 헤더 순서 미보존 |
|
||
| **`httpx`** | 0.28.1 (로컬 설치됨) | 표준 | 동기/비동기 HTTP, HTTP/2 옵션 | 안정 | ✅ **채택 (주력)** — 헤더 순서 보존, 타임아웃·커넥션 풀 제어 명확 |
|
||
| **`curl_cffi`** (`curl-cffi`) | **0.16.3 (2026-09-02 릴리스)** | GitHub `lexiforest/curl_cffi` **6.4k stars, 546 forks, 569 commits, open issues 38, PR 34** | curl-impersonate 바인딩. BoringSSL 로 Chrome 의 cipher 순서·GREASE·확장 목록·HTTP/2 SETTINGS 를 바이트 단위 재현 | **매우 활발.** Python ≥3.10 (v0.14부터). **37개 프리셋 지문**. HTTP/3 지원(v0.11.4~, v0.15.0 에서 지문·UDP 프록시 추가). `AsyncSession` 완전 지원. WebSocket 동기/비동기 지원 | ❌ **배제** |
|
||
| **`playwright`** | (로컬 설치 확인됨: `playwright ok`) | Microsoft | 실브라우저 자동화 | 활발. v1.49 부터 기본 헤드리스가 `chromium-headless-shell`, 신 헤드리스는 `channel: 'chromium'` | ❌ **배제** (JS 렌더링 불필요) |
|
||
| **`playwright-stealth` (Python)** | **2.0.3 (2026-04-04)**, 메인테이너 `Mattwmaster58` | PyPI | JS 레이어 패치로 자동화 흔적 감춤. `Stealth` 클래스, `use_async()` 컨텍스트 매니저 | 유지되나 **본인들이 한계를 명시**: *"a proof-of-concept starting point"*, *"won't bypass sophisticated bot detection"*. patchright 에 의해 대체되는 추세 | ❌ **배제** |
|
||
| **`patchright`** | **1.62.2 (2026-08-29)** | GitHub `Kaliiiiiiiiii-Vinyzu/patchright` **4.2k stars, 204 forks, 1,020 commits** | Playwright 드롭인 대체. **`Runtime.enable` 미사용**(isolated ExecutionContext 로 JS 실행), **`Console.enable` 비활성화**, `--disable-blink-features=AutomationControlled` 추가 + `--enable-automation` 제거, closed shadow root 접근 | **가장 활발.** Chromium 계열만 지원 (Firefox/WebKit 미지원) | ❌ **배제** |
|
||
| **`rebrowser-patches` / `rebrowser-playwright`** | PyPI `rebrowser-playwright` **1.52.0 (2025-05-09 업로드)**, npm `rebrowser-playwright` latest **1.52.0** (created 2024-09-28, modified 2025-05-09) | GitHub `rebrowser/rebrowser-patches` **1.4k stars** | `Runtime.Enable` 누출 패치(3모드: `addBinding`, `alwaysIsolated`, `enableDisable`), `sourceURL` 마스킹(`pptr:` → `app.js`), utility world 이름 변경, Browser 에 `_connection()` 추가 | **정체 조짐.** 테스트된 최신은 Puppeteer 24.8.1(2025-05-06), Playwright 1.52.0(2025-04-17). **1년 이상 새 릴리스 없음** (2026-09 기준) | ❌ **배제** |
|
||
| **`nodriver`** | **0.50.3 (2026-05-13)**, Python ≥3.9 | GitHub `ultrafunkamsterdam/nodriver` **4.7k stars, 166 commits** | undetected-chromedriver 의 **공식 후속작**. Selenium/chromedriver 불필요, 순수 async CDP 직통 | 활발. README: *"This is the official successor of the Undetected-Chromedriver python package."* 헤드리스 옵션 있음 | ❌ **배제** |
|
||
| **`undetected-chromedriver`** | 3.5.0 | GitHub `ultrafunkamsterdam/undetected-chromedriver` **12.8k stars, 331 commits** | Selenium 기반 탐지 회피 | **모호.** README 에 deprecation 문구 **없고** *"still passing strong"* 이라 적혀 있으나, 저자가 nodriver 를 후속작으로 명시 → 사실상 대체됨. 검색 정리: *"Nodriver replaces undetected-chromedriver."*, 대규모 스크래핑 부적합 | ❌ **배제** |
|
||
| **`camoufox`** | **0.5.5 (2026-08-18)**, Python ≥3.10 <4.0 | GitHub `daijro/camoufox` **11.6k stars(레포 페이지) / 11,057 stars(2026-08-13 시점), 987 forks, 681 commits, last push 2026-08-12** | Firefox 포크 안티디텍트 브라우저. **JS 주입이 아니라 C++ 구현 레벨에서** navigator/WebGL/WebRTC/화면/폰트/지오/타임존 스푸핑. `pip install camoufox` + `python -m camoufox fetch` | **주의 필요.** README 경고: *"There has been a year gap in maintenance due to a personal situation. Camoufox is currently under active development."* → 안정 프로덕션 부적합 명시 | ❌ **배제** |
|
||
| **`puppeteer-extra` / `puppeteer-extra-plugin-stealth`** | — | GitHub `berstend/puppeteer-extra` **7.4k stars, issues 239, PR 36** | Node 진영 스텔스 플러그인 | README 에 **명시적 deprecation 문구 없음**(확인). 다만 검색 정리에서 *"The maintainers deprecated puppeteer-stealth in Feb 2025 and it doesn't bypass current Cloudflare versions."* ⚠️ 상충 — 미검증 | ❌ **배제** (Node 스택 자체를 안 씀) |
|
||
|
||
**보조 참고**: `undetected-geckodriver` 라는 PyPI 패키지가 검색 결과에 등장했으나 상세 미확인 ⚠️ 미검증.
|
||
|
||
### 7.2 "무엇을 쓰고 무엇을 쓰지 않는가" — 결정과 근거
|
||
|
||
#### 채택: `httpx` (+ 표준 라이브러리)
|
||
|
||
**근거 4가지**
|
||
|
||
1. **기술적 충분성 (실측)**: `curl/8.10.1` 이라는 가장 노골적인 봇 UA 로도 `/robots.txt`, `/index`, `/bbs/117` 모두 `200 OK` 였고, `/bbs/117` 은 382,506 바이트의 **완성된 HTML** 을 반환했다. TLS 지문·헤더 순서·JS 실행 어느 것도 요구되지 않았다.
|
||
2. **JS 렌더링 불필요**: 목록 데이터가 서버사이드 렌더링으로 이미 HTML 에 있다(`totalPages=71`, 게시글 제목들이 raw HTML 에서 grep 됨). 브라우저를 띄우면 **CPU/메모리/실행시간이 수십 배** 늘고, Windows 스케줄러 환경에서 실패 모드만 늘어난다.
|
||
3. **헤더 순서 결정성**: *"httpx library does respect the header order"* — 이건 위장 목적이 아니라 **재현 가능한 디버깅**을 위해서다. 같은 코드가 항상 같은 요청을 만든다.
|
||
4. **법적 안전성**: 표준 HTTP 클라이언트로 공개 페이지를 받는 것은 §8 의 판례 기준에서 "기술적 보호조치 우회"에 해당할 여지가 전혀 없다.
|
||
|
||
#### 배제: `curl_cffi`
|
||
|
||
- 기능은 훌륭하다: `impersonate="chrome"` 한 줄로 JA3/JA4/HTTP2 지문을 브라우저와 동일하게 만든다.
|
||
```python
|
||
import curl_cffi
|
||
r = curl_cffi.get("https://tls.browserleaks.com/json", impersonate="chrome")
|
||
print(r.json())
|
||
```
|
||
설치: `pip install curl_cffi --upgrade`. 지원 타깃: `chrome99`~`chrome131`, `chrome124`, `chrome135`, `chrome150/152`, `safari`, `safari_ios`, `firefox`, `edge`, 그리고 `ja3=`/`akamai=` 파라미터로 **커스텀 지문**까지.
|
||
- **그런데 우리에게는 필요 없고, 있으면 해롭다.**
|
||
- 필요 없음: nedrug 이 TLS 지문을 보지 않는다(실측).
|
||
- 해로움: "브라우저인 척 TLS ClientHello 를 바이트 단위로 위조"하는 행위는, 분쟁 시 **"접근권한 제한을 우회하려는 적극적 의도"** 의 정황증거가 된다. 대법원 2021도1533 이 무죄로 본 이유 중 하나가 "**별도의 보호조치가 없었고 우회 행위도 없었다**"는 점이다. 우리가 스스로 그 안전지대를 벗어날 이유가 없다.
|
||
- 운영 리스크: 로컬에 미설치(`ModuleNotFoundError`)이며, BoringSSL 바이너리 휠 의존성은 Windows 재부팅·업그레이드 시 새 실패 모드를 만든다.
|
||
|
||
#### 배제: `patchright` / `rebrowser-playwright` / `playwright-stealth`
|
||
|
||
- 이들이 해결하는 문제(`Runtime.enable` 누출, `Console.enable`, `sourceURL`, utility world 이름)는 **브라우저를 띄웠을 때만 존재하는 문제**다. 우리는 브라우저를 띄우지 않는다.
|
||
- `playwright-stealth` 자신이 *"a proof-of-concept starting point"* 이며 *"won't bypass sophisticated bot detection"* 이라고 명시한다. 즉 얻는 것도 확실치 않다.
|
||
- `rebrowser-playwright` 는 2025-05-09 이후 새 릴리스가 없어 Playwright 최신과 벌어질 위험이 있다.
|
||
|
||
#### 배제: `nodriver` / `undetected-chromedriver` / `camoufox`
|
||
|
||
- `nodriver`(0.50.3, 2026-05-13)는 잘 관리되지만 **CDP 직통 브라우저 제어**라 우리 용도에 과잉이다.
|
||
```python
|
||
import nodriver as uc
|
||
|
||
async def main():
|
||
browser = await uc.start()
|
||
page = await browser.get('https://www.nowsecure.nl')
|
||
# further code...
|
||
|
||
if __name__ == '__main__':
|
||
uc.loop().run_until_complete(main())
|
||
```
|
||
- `undetected-chromedriver`(3.5.0)는 저자 본인이 `nodriver` 를 후속작으로 선언했다. 신규 채택 이유가 없다.
|
||
- `camoufox`(0.5.5, 2026-08-18)는 C++ 레벨 스푸핑이라 기술적으로 가장 강력하지만, **README 가 "1년 유지보수 공백" 을 스스로 경고**하고 "under development" 라 밝힌다. 프로덕션 일일 배치에 넣을 수 없다.
|
||
```python
|
||
from camoufox.sync_api import Camoufox
|
||
|
||
with Camoufox() as browser:
|
||
page = browser.new_page()
|
||
page.goto("https://example.com")
|
||
```
|
||
|
||
### 7.3 도구 선택 결정 트리 (재사용 가능한 판단 기준)
|
||
|
||
```
|
||
[대상 사이트를 처음 만났다]
|
||
│
|
||
▼
|
||
(1) curl -A "curl/8.x" 로 대상 URL 을 GET 한다.
|
||
│
|
||
├─ 200 + 필요한 데이터가 HTML 에 있다 ────────► requests/httpx 로 충분. 끝. ← nedrug 여기
|
||
│
|
||
├─ 200 인데 HTML 에 데이터가 없다 (JS 로 채움)
|
||
│ │
|
||
│ ▼
|
||
│ (2) DevTools Network 에서 XHR/fetch 로 오는 JSON API 를 찾는다.
|
||
│ │
|
||
│ ├─ 찾았다 ───────────────────────────► 그 JSON API 를 httpx 로 직접 호출. 끝.
|
||
│ │ (*"Many websites use single-page applications that fetch data via
|
||
│ │ XHR/Fetch requests to JSON APIs, which are cleaner, faster, and
|
||
│ │ less likely to trigger anti-bot systems than full browser rendering."*)
|
||
│ │
|
||
│ └─ 못 찾았다 (WebSocket/난독화) ─────► Playwright(표준) 검토
|
||
│
|
||
├─ 403 즉시 응답
|
||
│ │
|
||
│ ▼
|
||
│ (3) 브라우저 UA + Accept-Language 를 붙여 재시도
|
||
│ ├─ 200 ──────────► UA 블랙리스트였다. **하지만 우리는 사칭하지 않는다.**
|
||
│ │ → 사이트 운영자에게 연락하거나 공식 API 를 찾는다.
|
||
│ └─ 여전히 403 ───► TLS/HTTP2 지문 계층. 회피 대신 **수집 포기 또는 공식 채널 요청**.
|
||
│
|
||
├─ 429 / Retry-After ─────────────────────────► 속도만 낮춘다. 도구 문제 아님.
|
||
│
|
||
└─ 캡차/챌린지 페이지 ────────────────────────► **즉시 중단.** 명시적 봇 거부 의사표시.
|
||
```
|
||
|
||
**판단 인용**: *"The nature of the block often indicates which detection layer flagged your request: an instant 403 error means your headers or TLS fingerprint gave you away, while a page that renders fine in a browser but fails from Python requests often points to a TLS fingerprint issue."*
|
||
|
||
### 7.4 프로젝트 의존성 확정
|
||
|
||
```toml
|
||
# pyproject.toml (발췌)
|
||
[project]
|
||
name = "dmf-crawler"
|
||
requires-python = ">=3.11"
|
||
dependencies = [
|
||
"httpx>=0.28.1", # 주력 HTTP 클라이언트 (헤더 순서 보존)
|
||
"selectolax>=0.3.21", # 빠른 HTML 파싱 (lxml 대비 Windows 휠 안정)
|
||
"openpyxl>=3.1.5", # xlsx 리포트 생성
|
||
"pyyaml>=6.0.2", # policy.yaml
|
||
"python-dateutil>=2.9.0", # 한국식 날짜 문자열 파싱
|
||
]
|
||
|
||
[project.optional-dependencies]
|
||
# 진단 전용. 운영 코드 경로에서 import 하지 않는다.
|
||
diag = ["rich>=13.9.0"]
|
||
|
||
# 아래는 '의도적으로 넣지 않는다' 는 기록이다. 절대 추가하지 말 것:
|
||
# curl_cffi — TLS 지문 위장. 불필요하며 법적 리스크를 만든다.
|
||
# playwright / patchright — JS 렌더링 불필요. 브라우저 지문 문제 자초.
|
||
# playwright-stealth — 위와 동일. 저자도 한계 명시.
|
||
# nodriver / undetected-chromedriver / camoufox — 전부 동일 사유.
|
||
# selenium — 동일.
|
||
# requests[socks] / proxy pool — IP 로테이션은 회피 행위다. 사용 금지.
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 법적 검토
|
||
|
||
> **면책**: 이 섹션은 조사 결과의 정리이며 법률자문이 아니다. 사업적 의사결정(외부 공개, 상용화, 재배포) 전에는 변호사 검토를 받는다.
|
||
|
||
### 8.1 정보통신망법 제48조 — 조문과 벌칙
|
||
|
||
**「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제48조(정보통신망 침해행위 등의 금지)** — 조문 원문:
|
||
|
||
- **제1항**: *"누구든지 정당한 접근권한 없이 또는 허용된 접근권한을 넘어 정보통신망에 침입하여서는 아니 된다."*
|
||
- **제2항**: *"누구든지 정당한 사유 없이 정보통신시스템, 데이터 또는 프로그램 등을 훼손·멸실·변경·위조하거나 그 운용을 방해할 수 있는 프로그램(이하 '악성프로그램'이라 한다)을 전달 또는 유포하여서는 아니 된다."*
|
||
- **제3항**: *"누구든지 정보통신망의 안정적 운영을 방해할 목적으로 대량의 신호 또는 데이터를 보내거나 부정한 명령을 처리하도록 하는 등의 방법으로 정보통신망에 장애가 발생하게 하여서는 아니 된다."*
|
||
|
||
**벌칙**: 제71조 제1항 제9호(제48조 제1항 위반) — **5년 이하의 징역 또는 5천만원 이하의 벌금**.
|
||
|
||
**해석 주의**: *"정보통신망법은 그 보호조치에 대한 침해나 훼손이 수반되지 않더라도 부정한 방법으로 타인의 식별부호를 이용하거나 보호조치에 따른 제한을 면할 수 있게 하는 부정한 명령을 입력하는 등의 방법으로 침입하는 행위도 금지하고 있습니다."* → **즉, "보호조치를 뚫지 않았으니 괜찮다"가 자동으로 성립하지는 않는다. 그러나 아래 2021도1533 이 그 경계를 명확히 했다.**
|
||
|
||
**제3항의 함의**: "대량의 신호 또는 데이터를 보내" 장애를 발생시키는 행위가 명시적으로 금지된다. → **우리의 동시성 1 / 하루 1회 / 총 120건 상한은 이 조항에 대한 직접적 방어 설계다.**
|
||
|
||
### 8.2 대법원 2022. 5. 12. 선고 2021도1533 (야놀자-여기어때, 형사) — **가장 중요한 판례**
|
||
|
||
#### 8.2.1 사실관계
|
||
|
||
- 피고인: 여기어때(위드이노베이션) 창업주 심명섭 전 대표 등.
|
||
- 행위: 2015년부터 경쟁사 야놀자의 모바일 앱/PC 웹페이지에 접속해 제휴 숙박업소 목록·주소·가격 정보를 확인·공유했고, **2016년 1월부터 크롤링 프로그램을 사용**. 2016년 6~10월 사이 야놀자의 전산 서버에 **1,594만여 회 이상 접속**해 제휴 숙박업소 목록, 입·퇴실 시간, 주소·가격 정보, 할인금액 등을 수집.
|
||
- 기소: 2019년 3월. 죄명 3개 — **구 정보통신망법 위반(정보통신망침해등), 저작권법 위반, 컴퓨터등장애업무방해**.
|
||
- 경과: **1심 유죄(징역 1년 2개월, 집행유예 2년) → 2심 무죄 → 대법원 상고기각, 무죄 확정**.
|
||
|
||
#### 8.2.2 판시 (1) — 정보통신망법 제48조 제1항 '접근권한' 판단 기준
|
||
|
||
판결문 인용:
|
||
|
||
> *"서비스제공자로부터 권한을 부여받은 이용자가 아닌 제3자가 정보통신망에 접속한 경우 그에게 접근권한이 있는지 여부는 서비스제공자가 부여한 접근권한을 기준으로 판단하여야 한다"*
|
||
|
||
> *"정보통신망에 대하여 서비스제공자가 접근권한을 제한하고 있는지 여부는 보호조치나 이용약관 등 객관적으로 드러난 여러 사정을 종합적으로 고려하여 신중하게 판단하여야 한다"*
|
||
|
||
**구체적 판단 근거 4가지:**
|
||
1. API 서버 URL·명령구문은 **일반인도 기술조작으로 알아낼 수 있는 정보**였다.
|
||
2. 일반 이용자는 회원가입 후/없이도 **자유롭게 접근 가능**했다.
|
||
3. **접근을 막는 별도의 보호조치가 없었다.**
|
||
4. **이용약관의 정보 이용제한은 '접근제한'이 아니다.**
|
||
|
||
기사 정리 인용: *"누구나 쉽게 프로그램을 이용해서 API 내 정보를 알아낼 수 있었고 약관상 크롤링 제한은 비회원인 여기어때에는 적용되지 않기 때문"*
|
||
|
||
#### 8.2.3 판시 (2) — 저작권법 제93조 데이터베이스제작자 권리 침해 기준
|
||
|
||
> **'상당한 부분' 판단**: *"양적으로 상당한 부분인지 여부는 복제 등이 된 부분을 전체 데이터베이스의 규모와 비교하여 판단하여야 하며, 질적으로 상당한 부분인지 여부는 데이터베이스제작자가 그 복제 등이 된 부분의 제작 또는 그 소재의 갱신·검증 또는 보충에 인적 또는 물적으로 상당한 투자를 하였는지를 기준으로 제반 사정에 비추어 판단"*
|
||
|
||
> **반복적·체계적 복제**: *"데이터베이스의 개별 소재 또는 상당한 부분에 이르지 못하는 부분의 반복적이거나 특정한 목적을 위한 체계적 복제 등에 의한 데이터베이스제작자의 권리 침해는 데이터베이스의 개별 소재 또는 상당하지 않은 부분에 대한 반복적이고 체계적인 복제 등으로 결국 상당한 부분의 복제 등을 한 것과 같은 결과를 발생하게 한 경우에 한하여 인정"*
|
||
|
||
본건 판단(기사 정리): *"이미 잘 알려진 정보로, 데이터베이스의 통상적인 이용을 방해하거나 회사 이익을 부당하게 해친 경우에 해당하지 않는다"*, 그리고 *"크롤링 정보가 일부분이고, 이미 알려진 정보여서 특별한 노력이 없었으며, 야놀자의 이익을 부당하게 해치지 않았다"*.
|
||
|
||
#### 8.2.4 판시 (3) — 컴퓨터등장애업무방해죄
|
||
|
||
> *"위 죄가 성립하기 위해서는 위와 같은 가해행위 결과 정보처리장치가 그 사용목적에 부합하는 기능을 하지 못하거나 사용목적과 다른 기능을 하는 등 정보처리에 장애가 현실적으로 발생하여야 한다"*
|
||
|
||
본건: 원심에서 **증거만으로는 부정한 명령 입력 및 장애 발생을 인정하기 어렵다**고 판단. 즉 **"현실적 장애 발생"이 필수 요건**이며, 1,594만 회 접속도 그 자체로는 장애 입증이 아니었다.
|
||
|
||
#### 8.2.5 결론
|
||
|
||
**상고 기각 → 원심 무죄 확정.** 3개 공소사실 전부 무죄.
|
||
|
||
#### 8.2.6 ⚠️ 자료 간 불일치 경고 (중요)
|
||
|
||
이 조사 과정에서 **동일 판결에 대해 정반대로 요약한 자료가 있었다.**
|
||
|
||
| 출처 | 요약 내용 | 신뢰도 |
|
||
|------|-----------|--------|
|
||
| `casenote.kr/대법원/2021도1533` (판결문 본문) | 상고기각, **무죄 확정** | ✅ **채택** — 판결문 원문 인용 다수 포함 |
|
||
| `zdnet.co.kr/view/?no=20220512180515` (기사) | 상고 기각, **무죄 확정** | ✅ 일치 |
|
||
| `v.daum.net/v/Fi3RbZBWW5` (한경 긱스 기사) | 3개 혐의 전부 **무죄** | ✅ 일치 |
|
||
| `scourt.go.kr` 판례속보 (FETCH #12) | *"특정 범죄 구성을 인정하며 처벌을 명합니다"*, 제3자의 데이터 수집 목적 접근은 *"기술적 보호조치 우회와 무관하게 위법"* | ❌ **채택하지 않음.** 다른 3개 출처 및 판결문 원문과 정면 모순. 요약 오류로 판단 ⚠️ 미검증 |
|
||
| `shinkim.com/.../1843` PDF (FETCH #4) | "실질적 접근 가능성" 기준, "비상업적 목적이라도 법적 안전성 보장 안 됨" | ⚠️ 부분 채택 — 실무 시사점은 유용하나 판시사항 요약이 판결문과 어긋나는 부분 있음 |
|
||
|
||
**교훈: 판례는 반드시 판결문 원문(casenote / 국가법령정보센터)으로 확인한다. 요약 기사·뉴스레터만으로 결론 내지 않는다.**
|
||
|
||
### 8.3 야놀자 v. 여기어때 — **민사** (서울중앙지법 2021. 8. 19. 선고 2018가합508729)
|
||
|
||
형사 무죄와 **정반대 결론**이 나온 사건이다. 이 프로젝트가 가장 경계해야 할 지점.
|
||
|
||
| 항목 | 내용 |
|
||
|------|------|
|
||
| 법원 / 선고일 | 서울중앙지방법원, **2021. 8. 19.** |
|
||
| 사건번호 | **2018가합508729** |
|
||
| 사건명 | 데이터베이스제작자의권리침해금지등 |
|
||
| 법적 근거 | **부정경쟁방지법 (성과도용 부정경쟁행위)** |
|
||
| 인정 사실 | 2016년 1월~10월 약 **9개월간 조직적으로 대량 정보 무단 복제**. 수집 정보를 분석해 **경쟁 전략 수립에 활용** |
|
||
| 손해배상액 | **10억 원** (원고 일부 승소) |
|
||
| 지연손해금 | 2018. 3. 1. ~ 2021. 8. 19. 연 **5%**, 그 이후 연 **12%** |
|
||
| 산정 근거 | *"야놀자가 2016년 한 해에 영업부서 인건비만으로 26억원이 넘는 금액을 투여한 점, 여기어때가 손해 배상을 위한 노력을 기울이지 않고 있는 점 등을 종합"* |
|
||
| 배경 | 야놀자는 2016년 서버에 접속이 몰려 **장애가 발생**하자 원인 분석 후 고소, 2018년 민사 제기 |
|
||
| 부정경쟁 판단 | *"야놀자의 상당한 투자와 노력으로 만들어진 성과를 공정한 상거래 관행이나 경쟁질서에 반하는 방법으로 여기어때의 영업을 위해서 무단으로 사용함으로써 야놀자의 경제적 이익을 침해했다"* |
|
||
| 항소심/상고심 | ⚠️ **미확인** — 웹검색 예산 소진으로 확인 실패. 확정 여부 불명 |
|
||
|
||
**핵심 교훈 3가지**
|
||
|
||
1. **형사 무죄 ≠ 민사 무책.** 같은 사실관계에서 형사는 무죄, 민사는 10억이었다.
|
||
2. **결정적 차이는 "경쟁 목적 이용"이다.** 형사에서는 "접근권한"과 "현실적 장애"를 봤고, 민사에서는 "**타인의 투자 성과를 자기 영업에 무단 사용**"을 봤다.
|
||
3. **우리 프로젝트는 경쟁 관계가 아니다.** 식약처는 공공기관이고, 우리는 그 데이터로 식약처와 경쟁하지 않으며 재배포하지도 않는다. → 성과도용 구성요건의 핵심 요소가 결여된다.
|
||
|
||
### 8.4 잡코리아 v. 사람인HR (서울고법 2017. 4. 6. 선고 2016나2019365)
|
||
|
||
| 항목 | 내용 |
|
||
|------|------|
|
||
| 사실관계 | 사람인HR 이 **2008년** 잡코리아에 등록된 기업 채용공고를 크롤링해 **자사 사이트에 게재** |
|
||
| 크롤링 방식 | 자동화된 크롤러로 **HTML 소스를 기계적으로 복제**하여 별도 서버에 저장 후 게재 |
|
||
| 1심 (서울중앙지법, 2016. 2.) | 부정경쟁행위 인정. *"사람인HR은 채용정보 369건을 폐기하고 잡코리아에 1건당 50만원씩 총 1억 9800만원을 지급하라"* |
|
||
| 2심 (서울고법 민사4부, **2017. 4. 6. 선고 2016나2019365**) | **저작권법 제93조 제1항·제2항 위반**, **데이터베이스제작자 권리 침해** 인정 |
|
||
| 2심 판시 | *"피고는 별도의 마케팅비용 없이 반복적, 체계적으로 원고 데이터베이스의 채용정보를 복제"* 했고 이로써 *"저작권법 93조 2항, 1항에서 정하고 있는 원고의 데이터베이스 제작자의 권리가 침해되었다"* |
|
||
| 손해배상 | **저작권 침해 손해배상 2억 5,000만 원** + **조정조서 부작위의무 위반 간접강제금 2억 원**(400건 × 50만원/건) |
|
||
| 확정 | **대법원 상고 기각으로 확정** (법무법인 민후 업무사례 기준). ⚠️ 대법원 사건번호는 확인되지 않음 |
|
||
| 의의 | *"동의를 받지 않은 무단 크롤링이 불법이라는 점과 잡코리아와 같은 UCC 사이트도 데이터베이스제작자에 해당함을 밝힌 점"* |
|
||
|
||
**이 판례의 결정적 요소는 "복제 후 자사 사이트 게재"다.** 단순 수집이 아니라 **재배포·경쟁 이용**이 침해를 구성했다.
|
||
|
||
### 8.5 hiQ Labs v. LinkedIn (미국) — 참고용
|
||
|
||
| 시점 | 사건 | 인용 |
|
||
|------|------|------|
|
||
| 2017 | N.D. Cal. 이 hiQ 의 예비적 금지명령 인용 — **273 F. Supp. 3d 1099 (N.D. Cal. 2017)** | |
|
||
| 2019-09-09 | 제9순회항소법원 인용 유지 — **938 F.3d 985** | 공개 프로필 데이터 스크래핑 허용 |
|
||
| 2021-06-14 | 연방대법원이 ***Van Buren v. United States*** 를 이유로 제9순회 판결 파기·환송 | Van Buren 은 CFAA 의 "exceeds authorized access" 를 "정당한 접근권한은 있으나 허용되지 않은 영역에 도달한 경우"로 좁혔다 |
|
||
| 2022-04-18 | 제9순회, 환송 후 **원판단 재확인 — 31 F.4th 1180** | *"the automated capture of data from the publicly accessible pages of websites (that do not require the creation of an account for access) does not violate the CFAA's prohibition on accessing a computer 'without authorization.'"* / *"on a publicly available website, there are no rules or access permissions to prevent access, and therefore accessing that publicly available data cannot violate the CFAA."* |
|
||
| 2022-11 | 지방법원, hiQ 가 **LinkedIn 이용약관(User Agreement)을 위반했다**고 판단 | 계약 위반은 별개 |
|
||
| 2022-12-06/07 | **화해 및 합의판결**. hiQ 는 영구적 금지명령을 수용해 스크래핑 중단, 소스코드·데이터·알고리즘 전량 삭제. **50만 달러($500,000) 판결금** | (1) LinkedIn 이용약관 위반에 따른 계약 위반, (2) **가짜 계정으로 비밀번호 보호 페이지에 직접 접근**한 데이터 수집 관행에 따른 CFAA 위반 |
|
||
|
||
**핵심 시사 (인용)**: *"While the stipulation is not considered a finding of fact by the court and therefore has no precedential value, the various decisions leading up to this point show that, under certain circumstances, data scraping publicly available websites is legal under the Computer Fraud and Abuse Act (CFAA) but may create liability risk under a breach of contract claim or even common law torts claims."*
|
||
|
||
**한국 판례와의 구조적 유사성**: 한·미 모두 결론이 같다 —
|
||
> **"공개 데이터 접근" 자체는 형사(침입죄/CFAA) 대상이 아니다. 그러나 (a) 계정·인증을 우회하거나, (b) 이용약관을 위반해 계약책임을 지거나, (c) 경쟁적으로 재이용해 민사책임을 지는 것은 완전히 별개다.**
|
||
|
||
### 8.6 공공데이터법 · 공공누리 · data.go.kr 이용정책
|
||
|
||
#### 8.6.1 공공데이터포털 이용정책 (실측 확인)
|
||
|
||
| 항목 | 내용 |
|
||
|------|------|
|
||
| 신청 절차 | 공공데이터포털을 통해 제공 중인 데이터는 **"별도의 신청절차 없이 이용 가능"**. 미제공 데이터는 제공신청 가능하나, **공공데이터법 제17조**의 제외대상 정보 포함 시 거부될 수 있음 |
|
||
| 공공누리 적용 | 저작물이 포함된 공공데이터는 **공공누리 유형(0~4유형, AI유형)** 을 부착해 이용허락 범위를 표시 |
|
||
| 상업적 이용 | 제0, 1, 3, AI유형 = **가능**. 제2, 4유형 = **비상업적 이용만** |
|
||
| 크롤링 명시 규정 | **명시적 크롤링 금지 규정 없음.** 다만 오픈 API 의 경우 제공기관은 *"특정 회원의 이용형태로 인해 제공기관의 업무에 지장을 초래하거나 제공시스템의 성능 저하 등의 문제가 발생할 경우 서비스 이용을 제한할 수 있습니다."* |
|
||
| 과도 트래픽 | 구체적 수치 언급은 없으나 **제공기관은 시스템 성능 저하 시 이용 제한 권한 보유** |
|
||
|
||
#### 8.6.2 공공누리(KOGL) 유형별 이용조건 (실측 확인)
|
||
|
||
| 유형 | 출처표시 | 상업적 이용 | 변경 가능 |
|
||
|------|----------|-------------|-----------|
|
||
| **제1유형** | 필수 | 가능 | 가능 |
|
||
| **제2유형** | 필수 | **불가능** | 가능 |
|
||
| **제3유형** | 필수 | 가능 | **불가능** |
|
||
| **제4유형** | 필수 | **불가능** | **불가능** |
|
||
|
||
- **출처표시 문구 규격**: *"본 저작물은 'OOO(기관명)'에서 'OO년'작성하여 공공누리 제O유형으로 개방한 '저작물명(작성자:OOO)'을 이용하였으며"* 로 명시. 온라인의 경우 출처 웹사이트 하이퍼링크 제공 권장.
|
||
- 제2·4유형: *"비영리 목적으로만 이용 가능"*, 별도 허락으로 상업 이용 가능.
|
||
- 제3·4유형: *"저작물을 변경 혹은 2차 저작물 작성금지"*.
|
||
|
||
#### 8.6.3 대상 데이터의 라이선스 (실측 확인)
|
||
|
||
**「식품의약품안전처_원료의약품등록(DMF)현황」** (data.go.kr 데이터셋 ID **15057075**)
|
||
|
||
| 항목 | 값 |
|
||
|------|-----|
|
||
| 서비스명 | 식품의약품안전처_원료의약품등록(DMF)현황 |
|
||
| 제공기관 | 식품의약품안전처 |
|
||
| 엔드포인트 | `https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01` |
|
||
| 인증 | URL Encode 된 공공데이터포털 발급 인증키 (`serviceKey`) |
|
||
| 출력 필드 | `DMF_PERMIT_NO`(등록번호), `INGR_KOR_NAME`(성분명), `ENTP_NAME`(업체명), `MNFCTR_NAME`(제조소명), `MNFCTR_PLACE`(제조소 소재지), `MANUF_COUNTRY_CODE_NM`(제조국가명), `DMF_PERMIT_DATE`(발급일자) |
|
||
| 요청 파라미터 | 업체명, 성분명 (선택) / 페이지 번호, 결과 수 (선택) / 서비스키 (필수) / 데이터포맷 (선택, 기본 `xml`) |
|
||
| 일일 트래픽 | **개발계정 10,000회 / 운영계정 증설 가능** |
|
||
| **이용허락범위** | **"제한 없음"** |
|
||
| 최근 수정일 | **2025-09-19** |
|
||
| 갱신주기 | 페이지에 **명시되지 않음** ⚠️ |
|
||
|
||
관련 포털: 식의약 데이터 포털 `https://data.mfds.go.kr/`, 공공데이터 목록·이용안내 `https://data.mfds.go.kr/cntnts/20`, 의약품안전나라 공공데이터 개요 `https://nedrug.mfds.go.kr/cntnts/80`.
|
||
|
||
일반론 인용: *"식약처의 공공데이터는 '공공데이터포털(data.go.kr)' 및 '식의약데이터포털'(data.mfds.go.kr) 등을 통해 신청가능하며, 비용은 원칙적으로 무료입니다."* / *"제약사 및 연구자 등은 DMF 현황 파악으로 제품 개발 시 원료 사용 가능성 및 원료 제공처 파악 등에 활용할 수 있습니다."*
|
||
|
||
#### 8.6.4 그러나 — 공공데이터도 무제한은 아니다
|
||
|
||
검색 정리 인용: *"웹상에 공개된 데이터를 수집하는 경우 저작권법상 데이터베이스제작자의 권리 침해 및 부정경쟁방지법에서 규정하는 성과도용행위에 해당할 수 있습니다."* / *"한국법상 크롤링의 허용범위에 대한 기준도 명확히 정립되어 있지 않습니다."*
|
||
|
||
→ **"공공데이터니까 다 된다"는 잘못된 전제다.** 포털을 통한 API 이용은 명시적 허락이지만, 포털 밖의 홈페이지 HTML 을 크롤링하는 것은 그 허락 범위 밖이다. 이것이 §0 결론 1(API 우선)의 법적 근거이기도 하다.
|
||
|
||
### 8.7 이 프로젝트의 리스크 평가
|
||
|
||
#### 8.7.1 구성요건별 적합성 판정
|
||
|
||
| 법적 쟁점 | 판단 기준 (판례) | 우리 프로젝트 | 판정 |
|
||
|-----------|------------------|---------------|------|
|
||
| 정보통신망법 §48①<br>(정당한 접근권한) | 서비스제공자가 접근권한을 제한했는가 (보호조치·이용약관 등 객관적 사정) | 로그인·캡차·인증 없음. 누구나 브라우저로 열람 가능. 우리는 우회 기술 미사용 | ✅ **성립 안 함** |
|
||
| 정보통신망법 §48③<br>(대량 신호로 장애 유발) | 안정적 운영 방해 **목적** + 대량 신호 | 하루 1회, ≤120건, 동시성 1. 방해 목적 부존재 | ✅ **성립 안 함** |
|
||
| 컴퓨터등장애업무방해 | **현실적 장애가 발생**해야 함 | 물리적으로 발생 불가능한 부하 수준 | ✅ **성립 안 함** |
|
||
| 저작권법 §93<br>(DB제작자 권리) | 양적/질적 '상당한 부분' 복제, 또는 반복·체계적 복제가 상당부분 복제와 같은 결과 | 공고 게시물 메타데이터 + 공식 API 데이터. 전체 DB 복제 아님. **재배포 없음** → 통상적 이용과 충돌 없음 | ✅ **성립 어려움** (단, 전량 아카이브 후 외부 제공 시 달라짐) |
|
||
| 부정경쟁방지법 (성과도용) | 타인의 상당한 투자·노력의 성과를 **공정한 상거래 관행에 반해 자기 영업에 무단 사용** | 식약처와 경쟁관계 없음. 내부 규제 모니터링 용도. 영업적 무단 사용 아님 | ✅ **성립 어려움** |
|
||
| 이용약관 위반 (계약책임) | 사이트 이용약관에 크롤링 금지 조항이 있는가 | ⚠️ **nedrug 이용약관 미확인** — 부록 B |
|
||
| robots.txt 위반 | 법적 강제력 없음. 다만 정황증거 | `Disallow: /` 존재 → **불리한 정황**. §4.3 의 3단계 안전장치로 완화 | ⚠️ **관리 필요** |
|
||
| 공공누리 출처표시 | 모든 유형 공통 의무 | 리포트에 자동 삽입 | ✅ **준수** |
|
||
|
||
#### 8.7.2 종합 리스크 등급
|
||
|
||
| 시나리오 | 등급 | 근거 |
|
||
|----------|------|------|
|
||
| **A. 현재 계획** (OpenAPI 주력 + HTML 최소 수집, 내부 xlsx 리포트 전용) | 🟢 **Low** | 형사 구성요건 전부 결여, 민사 성과도용 요건 결여, 공식 API 는 명시적 허락 |
|
||
| **B. HTML 전량 아카이브 (710건 전체 + 첨부파일)** | 🟡 **Low-Medium** | "반복적·체계적 복제로 상당한 부분 복제와 같은 결과" 논쟁 여지. 단 1회성이고 재배포 없으면 여전히 낮음 |
|
||
| **C. 수집 데이터를 사내 밖으로 공유 / 웹 게시** | 🟠 **Medium** | 잡코리아-사람인 구조에 접근. DB제작자 권리·공공누리 조건 검토 필수 |
|
||
| **D. 상용 서비스로 재판매 / 경쟁 서비스 제공** | 🔴 **High** | 야놀자 민사(10억) 구조. **변호사 검토 없이 진행 금지** |
|
||
| **E. 스텔스 도구로 차단 우회 (TLS 위장, UA 사칭, 프록시 로테이션)** | 🔴 **High** | "접근권한 제한의 우회" 정황을 스스로 생성. 대법원이 무죄로 본 안전지대를 벗어남 |
|
||
|
||
### 8.8 지켜야 할 선 — Do / Don't
|
||
|
||
**✅ 반드시 한다**
|
||
|
||
1. **공식 OpenAPI 를 1차 소스로 쓴다.** 인증키를 발급받고, 개발계정 일 10,000회 한도 대비 실제 사용량을 로깅한다.
|
||
2. **하루 1회, 동시성 1, 간격 2초+지터**를 코드 레벨에서 강제한다(§6.2).
|
||
3. **연락처가 포함된 UA** 로 자신을 밝힌다.
|
||
4. **모든 리포트에 출처를 표기**한다: `출처: 식품의약품안전처 「원료의약품등록(DMF)현황」(공공데이터포털 15057075) / 의약품안전나라 nedrug.mfds.go.kr, 수집일시 YYYY-MM-DD HH:MM KST`
|
||
5. **robots.txt 를 매 실행 확인하고 SHA-256 을 로깅**한다. 변경 시 실행 중단.
|
||
6. **차단 신호를 만나면 즉시 영구 중단하고 사람을 부른다.** 자동 우회 코드는 존재해서는 안 된다.
|
||
7. **수집 목적·범위·보관기간을 문서로 남긴다.** (내부 규제 모니터링 / 공고 메타데이터 / 3년)
|
||
8. **원본을 그대로 캐시**해 재크롤링을 줄인다.
|
||
|
||
**❌ 절대 하지 않는다**
|
||
|
||
1. **브라우저 UA 사칭.** (진단 목적 1회 테스트는 예외이되 운영 코드 금지)
|
||
2. **TLS/HTTP2 지문 임퍼소네이션** (`curl_cffi impersonate=`).
|
||
3. **프록시/VPN/IP 로테이션.**
|
||
4. **캡차 우회 서비스 이용.**
|
||
5. **로그인·인증이 필요한 영역 접근.** (hiQ 가 $500,000 을 문 직접적 사유가 "가짜 계정으로 비밀번호 보호 페이지 접근"이다)
|
||
6. **차단 후 자동 재시도 루프.**
|
||
7. **통합검색(`/search`) 호출** — 서버 부하가 큰 기능은 아예 건드리지 않는다.
|
||
8. **수집 데이터의 외부 재배포·재판매.**
|
||
9. **동시 다발 요청 / 병렬 워커.**
|
||
10. **`agy` 등 AI 에이전트가 직접 브라우저로 사이트에 접속하게 하기.** (에이전트는 로컬 파일만 읽는다)
|
||
|
||
### 8.9 에스컬레이션 기준 — 언제 변호사에게 가는가
|
||
|
||
다음 중 **하나라도** 해당하면 진행 전에 법률 검토를 받는다.
|
||
|
||
- [ ] 수집 데이터를 회사 밖(고객, 파트너, 공개 웹)으로 내보낼 계획이 생겼을 때
|
||
- [ ] 수집 대상을 nedrug 외 민간 사업자 사이트로 확대할 때
|
||
- [ ] 사이트로부터 차단·경고·연락을 받았을 때
|
||
- [ ] 이용약관에 크롤링 금지 조항이 확인되었을 때
|
||
- [ ] 하루 요청 수를 1,000건 이상으로 올려야 할 때
|
||
- [ ] 로그인이 필요한 영역의 데이터가 필요해졌을 때
|
||
- [ ] 수집 데이터를 유료 서비스·AI 학습 데이터로 쓰려 할 때
|
||
|
||
---
|
||
|
||
## 9. 차단 진단 절차와 복구 플로우차트
|
||
|
||
### 9.1 증상 분류표 — "무엇에 막혔는가"
|
||
|
||
| 증상 | HTTP 상태 | 본문 특징 | 응답 헤더 특징 | 추정 계층 | 1차 조치 |
|
||
|------|-----------|-----------|----------------|-----------|----------|
|
||
| **A. 즉시 403** | 403 | 짧음, `Forbidden` 또는 벤더 페이지 | `Server` 가 평소와 다름 | L3(헤더/UA) 또는 L2(TLS) | 요청 중단. UA 블랙리스트인지 1회 진단(§9.4) |
|
||
| **B. 429 + Retry-After** | 429 | 짧음 | `Retry-After: <sec 또는 date>` | L1(빈도) | `Retry-After` 만큼 대기. 다음 실행부터 간격 2배 |
|
||
| **C. 503 + Retry-After** | 503 | 점검 안내 | `Retry-After` | 서버 점검 | 대기 후 재시도. 5회 실패 시 서킷 |
|
||
| **D. 200 인데 빈/짧은 본문** | 200 | < 1,500 바이트, `location.href` 만 존재 | — | JS 리다이렉트형 차단 또는 세션 만료 | 세션 재수립 1회 → 실패 시 중단 |
|
||
| **E. 200 인데 차단 안내 HTML** | 200 | "비정상적인 접근", "차단되었습니다", "웹방화벽" | — | 국내 WAF(WAPPLES 류) | **즉시 영구 중단 + 사람 호출** |
|
||
| **F. 캡차 페이지** | 200 또는 403 | `captcha`, `recaptcha`, `hcaptcha`, `Turnstile` | — | L5(챌린지) | **즉시 영구 중단 + 사람 호출** |
|
||
| **G. Cloudflare 1010** | 403 | 본문에 `Error 1010`, `cf-error-details` | `Server: cloudflare`, `cf-ray` | L2/L3/L4 지문 | 우회 금지. 수집 포기 또는 공식 채널 |
|
||
| **H. Cloudflare 1015** | 429 상당 | 본문에 `Error 1015` | `cf-ray` | L1 빈도 | 속도·동시성 하향 |
|
||
| **I. Cloudflare 1020** | 403 | 본문에 `Error 1020` | `cf-ray` | 방화벽 규칙/IP 평판 | 요청 프로파일 점검, 우회 금지 |
|
||
| **J. 리다이렉트 루프** | 301/302 반복 | — | `Location` 이 자기 자신 또는 로그인 | 세션/쿠키 문제 | 쿠키 초기화 후 1회 재시도 |
|
||
| **K. 타임아웃/연결 거부** | — | — | — | IP 차단(방화벽 드롭) 또는 네트워크 | 다른 네트워크에서 1회 확인(§9.4) |
|
||
| **L. 200 인데 파싱 실패** | 200 | 정상 크기지만 셀렉터 불일치 | — | **차단 아님 — 사이트 개편** | 파서 수정. 크롤링 파라미터는 건드리지 않음 |
|
||
|
||
**핵심 판별 규칙**: *"Cloudflare 1xxx errors normally appear in the page content, while a regular 403, 429, or 530 is an HTTP response status."* → **상태 코드만 보고 판단하지 말고 반드시 본문 앞 20KB 를 검사한다.**
|
||
|
||
### 9.2 진단 플로우차트 (텍스트)
|
||
|
||
```
|
||
┌──────────────────────────┐
|
||
│ 수집 실패 이벤트 발생 │
|
||
└────────────┬─────────────┘
|
||
│
|
||
┌──────────────▼──────────────┐
|
||
│ 0. 원본 응답을 통째로 저장 │
|
||
│ data/diag/<ts>/ │
|
||
│ ├ request.txt (URL/헤더) │
|
||
│ ├ response_headers.txt │
|
||
│ ├ response_body.html │
|
||
│ └ meta.json (상태/소요/IP)│
|
||
└──────────────┬──────────────┘
|
||
│
|
||
┌──────────────▼──────────────┐
|
||
│ 1. 예외인가, 응답인가? │
|
||
└───┬─────────────────────┬───┘
|
||
│ 예외(연결/타임아웃) │ 응답 있음
|
||
▼ ▼
|
||
┌──────────────────────────┐ ┌──────────────────────────────┐
|
||
│ K. 네트워크/IP 차단 의심 │ │ 2. 상태 코드 분기 │
|
||
│ → 같은 URL 을 다른 회선 │ └───┬──────┬──────┬──────┬─────┘
|
||
│ (모바일 테더링)에서 1회 │ │ │ │ │
|
||
│ 확인 │ 2xx 429/503 4xx 5xx
|
||
│ ├ 다른 회선 성공 │ │ │ │ │
|
||
│ │ → IP 차단 확정 │ │ │ │ └─► C: Retry-After 대기
|
||
│ │ → 영구 중단 + 사람 │ │ │ │ 5회 실패 → 서킷 24h
|
||
│ └ 둘 다 실패 │ │ │ │
|
||
│ → 사이트 장애 가능성 │ │ │ └─► 3. 4xx 세부
|
||
│ → 다음날 재시도 │ │ │ ├ 401/407 → 인증 요구. 중단(우리는 인증 안 함)
|
||
└──────────────────────────┘ │ │ ├ 403 → 4. 본문 시그니처 검사로
|
||
│ │ ├ 404 → 사이트 개편. 파서/URL 갱신
|
||
│ │ └ 451 → 법적 차단. 즉시 중단 + 사람
|
||
│ │
|
||
│ └─► B/H: 레이트리밋
|
||
│ ├ Retry-After 있음 → 그만큼 대기
|
||
│ ├ 없음 → full jitter backoff
|
||
│ └ 다음 실행부터 MIN_INTERVAL_SEC 을 2배로
|
||
│
|
||
▼
|
||
┌────────────────────────────┐
|
||
│ 4. 본문 앞 20KB 시그니처 │
|
||
└──┬────────┬────────┬───────┘
|
||
│ │ │
|
||
captcha/ │ 차단안내 국내 │ Error 1010/1015/1020
|
||
Turnstile │ 문구(E) │ 또는 cf-ray
|
||
│ │ │ │
|
||
▼ │ ▼ ▼
|
||
┌────────────────┐ │ ┌──────────────────────┐
|
||
│ F: 캡차 챌린지 │ │ │ E/G/I: WAF 차단 │
|
||
│ → 명시적 봇 거부│ │ │ → 명시적 봇 거부 │
|
||
│ → 영구 서킷 OPEN│ │ │ → 영구 서킷 OPEN │
|
||
│ → Windows 알림 │ │ │ → Windows 알림 │
|
||
│ → 재시도 금지 │ │ │ → 재시도 금지 │
|
||
└────────────────┘ │ └──────────────────────┘
|
||
│
|
||
시그니처 없음 │
|
||
▼
|
||
┌──────────────────────────────┐
|
||
│ 5. 본문 크기 검사 │
|
||
├──────────────────────────────┤
|
||
│ < 1,500B & location.href 존재 │
|
||
│ → D: JS 리다이렉트/세션만료 │
|
||
│ → 쿠키 초기화 후 1회 재시도 │
|
||
│ → 또 실패 시 중단 + 알림 │
|
||
├──────────────────────────────┤
|
||
│ 정상 크기인데 파싱 실패 │
|
||
│ → L: 사이트 개편 │
|
||
│ → 차단 아님. 파서만 수정 │
|
||
│ → 크롤링 파라미터 유지 │
|
||
└──────────────────────────────┘
|
||
```
|
||
|
||
### 9.3 복구 절차 (에스컬레이션 사다리)
|
||
|
||
```
|
||
LEVEL 0 정상
|
||
│
|
||
│ 실패 1~2회 (transport / 5xx / 429)
|
||
▼
|
||
LEVEL 1 자동 재시도
|
||
- full jitter backoff (base 5s, factor 2, cap 300s), 최대 4회
|
||
- Retry-After 가 있으면 그것을 절대 우선
|
||
- 성공하면 LEVEL 0 복귀, fail_count 리셋
|
||
│
|
||
│ 연속 실패 5회
|
||
▼
|
||
LEVEL 2 서킷 OPEN (24시간)
|
||
- 이후 모든 요청을 보내지 않고 CircuitOpen 예외
|
||
- Windows 토스트 알림 1회
|
||
- 다음날 06:00 실행 시 자동으로 HALF-OPEN → 1건만 시험 요청
|
||
- 성공하면 CLOSED 복귀 / 실패하면 다시 24시간
|
||
│
|
||
│ 차단 시그니처(403/캡차/WAF 안내/1010/1020/451) 탐지
|
||
▼
|
||
LEVEL 3 영구 서킷 OPEN ★ 자동 복구 없음 ★
|
||
- permanent=1 로 SQLite 기록
|
||
- Windows 알림 (제목: "DMF Crawler 차단 감지 — 사람 확인 필요")
|
||
- 사람이 다음 4단계를 수행하기 전까지 어떤 요청도 나가지 않는다:
|
||
(1) data/diag/<ts>/ 의 원본 응답을 눈으로 확인
|
||
(2) 브라우저로 같은 URL 을 열어 사람 눈에도 차단인지 확인
|
||
(3) 차단이면 → 식약처에 문의(전화/민원). 우회 시도 금지.
|
||
차단이 아니면(일시 오류) → 원인 기록
|
||
(4) `dmf-crawler circuit reset nedrug.mfds.go.kr` 로 수동 해제
|
||
│
|
||
│ 사이트가 명시적으로 "크롤링 금지" 를 통보
|
||
▼
|
||
LEVEL 4 수집 영구 중단
|
||
- policy.yaml 의 path_allowlist 를 비운다
|
||
- 공식 OpenAPI 만으로 운영 (§8.6.3)
|
||
- 통보 문서를 docs/legal/ 에 보관
|
||
```
|
||
|
||
**⛔ 금지된 에스컬레이션 (코드에 존재해서는 안 되는 경로)**
|
||
|
||
```
|
||
LEVEL 2/3 에서:
|
||
✗ UA 를 브라우저로 바꿔 재시도 → 접근권한 우회 의도의 증거
|
||
✗ 프록시/VPN/모바일 테더링으로 재시도 → IP 차단 회피
|
||
✗ curl_cffi 로 TLS 지문 위장 → 보호조치 우회
|
||
✗ Playwright/patchright 로 전환 → 상동
|
||
✗ 캡차 솔버 서비스 호출 → 상동
|
||
✗ 요청 간격만 줄여 "빨리 끝내기" → 부하 증가
|
||
```
|
||
|
||
### 9.4 진단 도구 — `tools/diagnose.py` (완결, 사람이 수동 실행)
|
||
|
||
> 이 스크립트는 **운영 파이프라인이 아니라 사람이 손으로 돌리는 진단 도구**다. 브라우저 UA 테스트를 포함하지만, 이는 "무엇에 막혔는지 알기 위한 1회성 확인"이며 결과를 운영 코드에 반영하지 않는다.
|
||
|
||
```python
|
||
#!/usr/bin/env python3
|
||
"""DMF Crawler 차단 진단 도구.
|
||
|
||
사용법:
|
||
python tools/diagnose.py https://nedrug.mfds.go.kr/bbs/117
|
||
|
||
동작:
|
||
1) robots.txt 를 다시 읽어 변경 여부를 본다.
|
||
2) 정직한 UA(운영과 동일)로 1회 요청한다.
|
||
3) (진단 목적) 브라우저 UA 로 1회 요청해 UA 계층인지 가른다.
|
||
4) TLS 지문을 확인한다(tls.browserleaks.com).
|
||
5) 모든 원본을 data/diag/<timestamp>/ 에 저장하고 판정을 출력한다.
|
||
|
||
주의: 이 스크립트의 결과가 "브라우저 UA 면 통과" 여도 운영 코드에서 UA 를 바꾸지 않는다.
|
||
그 경우의 올바른 대응은 '사이트 운영자에게 문의' 또는 '공식 API 사용' 이다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import httpx
|
||
|
||
HONEST_UA = (
|
||
"DMF-Crawler/1.0 "
|
||
"(+mailto:yunchanpaca@gmail.com; internal regulatory monitoring; 1 req/2s; daily)"
|
||
)
|
||
BROWSER_UA = (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||
)
|
||
|
||
BLOCK_MARKERS = (
|
||
"비정상적인 접근", "접근이 차단", "차단되었습니다", "웹방화벽", "일시적으로 이용",
|
||
"Access Denied", "captcha", "recaptcha", "hcaptcha", "Turnstile",
|
||
"cf-error-details", "Error 1010", "Error 1015", "Error 1020",
|
||
)
|
||
|
||
VENDOR_HEADERS = ("cf-ray", "cf-mitigated", "x-datadome", "x-akamai-transformed", "server")
|
||
|
||
|
||
def classify(status: int, headers: dict[str, str], body: str) -> str:
|
||
lowered = body[:20000].lower()
|
||
hits = [m for m in BLOCK_MARKERS if m.lower() in lowered]
|
||
if hits:
|
||
return f"BLOCKED(marker) hits={hits}"
|
||
if status == 429:
|
||
return f"RATE_LIMITED retry_after={headers.get('retry-after')!r}"
|
||
if status == 503:
|
||
return f"SERVICE_UNAVAILABLE retry_after={headers.get('retry-after')!r}"
|
||
if status in (401, 407):
|
||
return "AUTH_REQUIRED (우리는 인증 영역에 접근하지 않는다 → 중단)"
|
||
if status == 451:
|
||
return "LEGAL_BLOCK (451) → 즉시 중단 + 사람 확인"
|
||
if status == 403:
|
||
return "FORBIDDEN(403) — 헤더/TLS 계층 의심"
|
||
if status == 404:
|
||
return "NOT_FOUND — 사이트 개편 가능성 (차단 아님)"
|
||
if 500 <= status < 600:
|
||
return f"SERVER_ERROR({status}) — 재시도 대상"
|
||
if status == 200 and len(body) < 1500 and "location.href" in lowered:
|
||
return "JS_REDIRECT_STUB — 세션 만료 또는 리다이렉트형 차단"
|
||
if status == 200:
|
||
return f"OK({len(body)} bytes)"
|
||
return f"UNEXPECTED({status})"
|
||
|
||
|
||
def probe(url: str, ua: str, out_dir: Path, tag: str) -> dict:
|
||
headers = {
|
||
"User-Agent": ua,
|
||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.5",
|
||
"Accept-Encoding": "gzip, deflate",
|
||
}
|
||
record: dict = {"tag": tag, "url": url, "ua": ua}
|
||
try:
|
||
with httpx.Client(timeout=httpx.Timeout(10.0, read=30.0),
|
||
follow_redirects=True, max_redirects=5) as c:
|
||
r = c.get(url, headers=headers)
|
||
body = r.text
|
||
record.update(
|
||
status=r.status_code,
|
||
elapsed=r.elapsed.total_seconds(),
|
||
bytes=len(r.content),
|
||
final_url=str(r.url),
|
||
vendor={h: r.headers.get(h) for h in VENDOR_HEADERS if r.headers.get(h)},
|
||
set_cookie=r.headers.get_list("set-cookie"),
|
||
verdict=classify(r.status_code, {k.lower(): v for k, v in r.headers.items()}, body),
|
||
)
|
||
(out_dir / f"{tag}_headers.txt").write_text(
|
||
"\n".join(f"{k}: {v}" for k, v in r.headers.multi_items()), encoding="utf-8"
|
||
)
|
||
(out_dir / f"{tag}_body.html").write_text(body, encoding="utf-8", errors="replace")
|
||
except httpx.HTTPError as exc:
|
||
record.update(error=repr(exc),
|
||
verdict="TRANSPORT_ERROR — IP 차단 또는 네트워크 문제 의심")
|
||
return record
|
||
|
||
|
||
def main() -> int:
|
||
if len(sys.argv) < 2:
|
||
print("usage: python tools/diagnose.py <url>", file=sys.stderr)
|
||
return 2
|
||
url = sys.argv[1]
|
||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
out_dir = Path("data/diag") / stamp
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
origin = httpx.URL(url)
|
||
robots_url = f"{origin.scheme}://{origin.host}/robots.txt"
|
||
|
||
results = [
|
||
probe(robots_url, HONEST_UA, out_dir, "robots"),
|
||
probe(url, HONEST_UA, out_dir, "honest_ua"),
|
||
probe(url, BROWSER_UA, out_dir, "browser_ua_DIAGNOSTIC_ONLY"),
|
||
probe("https://tls.browserleaks.com/json", HONEST_UA, out_dir, "tls_fingerprint"),
|
||
]
|
||
|
||
(out_dir / "summary.json").write_text(
|
||
json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
|
||
print(f"\n=== 진단 결과 (원본: {out_dir}) ===")
|
||
for r in results:
|
||
print(f"[{r['tag']:32}] {r.get('verdict')}")
|
||
if r.get("vendor"):
|
||
print(f"{'':34} vendor={r['vendor']}")
|
||
|
||
honest = results[1]
|
||
browser = results[2]
|
||
print("\n=== 판정 ===")
|
||
if honest.get("status") == 200:
|
||
print("정직한 UA 로 정상 응답. 차단이 아니다 → 파서/셀렉터 문제를 의심하라.")
|
||
elif browser.get("status") == 200 and honest.get("status") != 200:
|
||
print("브라우저 UA 로만 통과 → UA 블랙리스트 계층.")
|
||
print("!! 그렇다고 운영 UA 를 사칭으로 바꾸지 마라. 올바른 대응은:")
|
||
print(" (1) 공식 OpenAPI 로 전환, (2) 식약처에 문의, (3) 수집 중단.")
|
||
else:
|
||
print("두 UA 모두 실패 → 상위 계층(IP/TLS/WAF) 차단. 우회 금지, 사람이 판단하라.")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|
||
```
|
||
|
||
### 9.5 Windows 운영 연계 (스케줄러 죽음 감지)
|
||
|
||
프로젝트 요구사항인 "서비스가 죽으면 Windows 알림"과 차단 진단을 하나의 상태 머신으로 묶는다.
|
||
|
||
| 이벤트 | 감지 방법 | 알림 문구 |
|
||
|--------|-----------|-----------|
|
||
| 스케줄 실행 자체가 안 됨 | 마지막 성공 시각이 **30시간** 이상 과거 (`data/state.sqlite3` 의 `last_success_at`) | `DMF Crawler 가 30시간 이상 실행되지 않았습니다. 작업 스케줄러를 확인하세요.` |
|
||
| 서킷 24시간 OPEN | `circuit.opened_until > now` | `DMF Crawler 일시 중단 (연속 실패 N회). 남은 시간 HH:MM.` |
|
||
| 영구 차단 | `circuit.permanent = 1` | `DMF Crawler 차단 감지 — 사람 확인 필요. data/diag/<ts> 를 확인하세요.` |
|
||
| robots.txt 변경 | SHA-256 불일치 | `nedrug robots.txt 가 변경되었습니다. 수집 정책 재검토가 필요합니다.` |
|
||
| API 쿼터 임박 | 일 사용량 > 8,000 (개발계정 10,000 대비 80%) | `공공데이터포털 API 일일 한도의 80%를 사용했습니다.` |
|
||
|
||
---
|
||
|
||
## 10. 이 프로젝트의 최종 수집 아키텍처 결정
|
||
|
||
### 10.1 결정 요약표
|
||
|
||
| 결정 항목 | 값 | 근거 섹션 |
|
||
|-----------|-----|-----------|
|
||
| 1차 데이터 소스 | 공공데이터포털 OpenAPI `15057075` (`apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01`) | §8.6.3 |
|
||
| 2차 데이터 소스 | `nedrug.mfds.go.kr/bbs/117` 공고 게시판 (최신 5페이지) | §4.2.4 |
|
||
| HTTP 클라이언트 | `httpx` 0.28.1+ (동기, HTTP/1.1) | §7.2 |
|
||
| 브라우저 자동화 | **사용 안 함** | §7.2 |
|
||
| 스텔스 도구 | **전면 배제** (`curl_cffi`, `patchright`, `nodriver`, `camoufox`, `undetected-chromedriver`, `playwright-stealth`, `rebrowser-*`) | §7.2, §8.8 |
|
||
| 프록시 / IP 로테이션 | **사용 안 함** | §8.8 |
|
||
| User-Agent | `DMF-Crawler/1.0 (+mailto:yunchanpaca@gmail.com; internal regulatory monitoring; 1 req/2s; daily)` | §6.1 |
|
||
| 실행 주기 | 하루 1회, 06:00 KST + `uniform(0, 600)`초 | §6.1 |
|
||
| 동시성 | 1 (`httpx.Limits(max_connections=1)` + 전역 락) | §6.1 |
|
||
| 요청 간격 | `2.0 + uniform(0, 1.0)` 초 | §6.1 |
|
||
| 1회 실행 요청 상한 | 120건 | §6.1 |
|
||
| 타임아웃 | connect 10s / read 30s / write 10s / pool 5s | §6.1 |
|
||
| 재시도 | 최대 4회, full jitter (base 5s, factor 2, cap 300s), `Retry-After` 우선 | §6.1 |
|
||
| 조건부 요청 | `If-None-Match` + `If-Modified-Since` 상시, 폴백으로 본문 SHA-256 비교 | §6.4 |
|
||
| 서킷 브레이커 | 연속 5회 실패 → 24h OPEN. 차단 시그니처 → **영구 OPEN(수동 해제)** | §9.3 |
|
||
| AI 에이전트(`agy -p`) 역할 | **로컬 파일만 처리.** 네트워크 접속 금지 | §2.6 |
|
||
| 데이터 이용 범위 | **내부 xlsx 리포트 전용.** 재배포·재판매·외부 공개 금지 | §8.7 |
|
||
| 출처 표기 | 모든 리포트 첫 시트에 자동 삽입 | §8.8 |
|
||
| 원본 보관 | `data/raw/YYYY-MM-DD/` 에 응답 원본 그대로. 보관 3년 | §6.1 |
|
||
| 진단 산출물 | `data/diag/<timestamp>/` 에 request/response/meta 전량 | §9.2 |
|
||
|
||
### 10.2 한 장으로 보는 데이터 흐름
|
||
|
||
```
|
||
06:00 + rand(0,600)s
|
||
│
|
||
▼
|
||
┌──────────────────────────────────────────────────────────┐
|
||
│ 0. 사전 점검 │
|
||
│ - 서킷 상태 확인 (permanent? opened_until?) │
|
||
│ - robots.txt 재조회 + SHA-256 비교 │
|
||
│ - API 일일 쿼터 잔량 확인 │
|
||
└───────────────┬──────────────────────────────────────────┘
|
||
│ 통과
|
||
▼
|
||
┌──────────────────────────────────────────────────────────┐
|
||
│ 1. [주] OpenAPI 수집 │
|
||
│ GET apis.data.go.kr/1471000/MdcDmfInfoService01/ │
|
||
│ getMdcDmfList01?serviceKey=...&type=json&numOfRows= │
|
||
│ → DMF_PERMIT_NO / INGR_KOR_NAME / ENTP_NAME / │
|
||
│ MNFCTR_NAME / MNFCTR_PLACE / │
|
||
│ MANUF_COUNTRY_CODE_NM / DMF_PERMIT_DATE │
|
||
│ → data/raw/<date>/api_dmf_list.json │
|
||
└───────────────┬──────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────────────────────────────────────────┐
|
||
│ 2. [보조] 공고 게시판 수집 (최신 5페이지, 간격 2s+지터) │
|
||
│ GET nedrug.mfds.go.kr/bbs/117?page=1..5 │
|
||
│ (조건부 요청 → 304 면 즉시 스킵) │
|
||
│ → data/raw/<date>/bbs117_pageN.html │
|
||
└───────────────┬──────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────────────────────────────────────────┐
|
||
│ 3. 파싱 · 정규화 (네트워크 접속 없음) │
|
||
│ - selectolax 로 목록 테이블 추출 │
|
||
│ - 이전 스냅샷과 diff → 신규 / 변경 / 취하 판정 │
|
||
│ - data/state.sqlite3 에 스냅샷 저장 │
|
||
└───────────────┬──────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────────────────────────────────────────┐
|
||
│ 4. [선택] agy -p 로 공고문 요약 (로컬 파일만 입력) │
|
||
│ agy -p "다음 공고문에서 변경 사유를 3줄로 요약: ..." │
|
||
└───────────────┬──────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────────────────────────────────────────┐
|
||
│ 5. xlsx 리포트 생성 (탭 연동) │
|
||
│ 첫 시트 상단에 출처·수집일시·공공누리 표기 자동 삽입 │
|
||
└───────────────┬──────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
완료 / 실패 시 Windows 알림 (§9.5)
|
||
```
|
||
|
||
---
|
||
|
||
## 부록 A. 출처 목록
|
||
|
||
**범례** — 확인여부: ✅ WebFetch 로 실제 열어 내용 확인 / 🔎 검색 결과 목록에만 등장(본문 미확인) / ⛔ 열람 실패(404·403·리다이렉트·캡차) / 🖥️ 로컬 curl 로 직접 확인
|
||
|
||
### A.1 실측 · 대상 사이트
|
||
|
||
| # | 제목 | URL | 확인 |
|
||
|---|------|-----|------|
|
||
| 1 | nedrug robots.txt (User-agent: * / Disallow: /) | https://nedrug.mfds.go.kr/robots.txt | ✅🖥️ |
|
||
| 2 | 의약품안전나라 메인 | https://nedrug.mfds.go.kr/ | 🖥️ |
|
||
| 3 | 의약품안전나라 index | https://nedrug.mfds.go.kr/index | 🖥️ |
|
||
| 4 | 원료의약품등록(DMF) 정보 게시판 (총 710건 / totalPages=71) | https://nedrug.mfds.go.kr/bbs/117 | ✅🖥️ |
|
||
| 5 | MFDS Drug Safety Korea (영문) | https://nedrug.mfds.go.kr/eng/index | 🔎 |
|
||
| 6 | 의약품안전나라 안전사용정보 | https://nedrug.mfds.go.kr/safetyuseinfo | 🔎 |
|
||
| 7 | 의약품안전나라 > 공공데이터 개요 | https://nedrug.mfds.go.kr/cntnts/80 | 🔎 |
|
||
| 8 | TLS 지문 확인 (ja4/ja3/akamai) | https://tls.browserleaks.com/json | ✅🖥️ |
|
||
|
||
### A.2 공공데이터 · 라이선스 · 법령
|
||
|
||
| # | 제목 | URL | 확인 |
|
||
|---|------|-----|------|
|
||
| 9 | 식품의약품안전처_원료의약품등록(DMF)현황 OpenAPI (ID 15057075) | https://www.data.go.kr/data/15057075/openapi.do | ✅ |
|
||
| 10 | DMF OpenAPI 엔드포인트 | https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01 | 🔎 |
|
||
| 11 | 공공데이터 이용정책 | https://www.data.go.kr/ugs/selectPortalPolicyView.do | ✅ |
|
||
| 12 | 공공누리(KOGL) 이용조건 제1~4유형 | https://www.kogl.or.kr/info/license.do | ✅ |
|
||
| 13 | 식품의약품안전처_의약품 낱알식별 정보 | https://www.data.go.kr/data/15057639/openapi.do | 🔎 |
|
||
| 14 | 식품의약품안전처_의약품개요정보(e약은요) | https://www.data.go.kr/data/15075057/openapi.do | 🔎 |
|
||
| 15 | 식약처 연구관리 기술 분류 정보조회 서비스 | https://www.data.go.kr/data/15068423/openapi.do | 🔎 |
|
||
| 16 | 식의약 데이터 포털 | https://data.mfds.go.kr/ | 🔎 |
|
||
| 17 | 공공데이터 목록 및 이용안내 (식의약) | https://data.mfds.go.kr/cntnts/20 | 🔎 |
|
||
| 18 | 공공데이터 상세 (식의약 외부포털) | https://data.mfds.go.kr/OPCAA01F01/search?selectedTab=tab1&taskDivsCd=3&taskDivsDtlCd=7&rchSrvcKorNm=&btnSearch= | 🔎 |
|
||
| 19 | 공공데이터의 제공 및 이용 활성화에 관한 법률 (국가법령정보센터) | https://www.law.go.kr/lsInfoP.do?lsId=011895&ancYnChk=0 | 🔎 |
|
||
| 20 | 공공데이터 제공 (법제처) | https://www.moleg.go.kr/menu.es?mid=a10203010000 | 🔎 |
|
||
| 21 | Korea Open Government License (Wikipedia) | https://en.wikipedia.org/wiki/Korea_Open_Government_License | 🔎 |
|
||
| 22 | Drug Master File (Wikipedia) | https://en.wikipedia.org/wiki/Drug_Master_File | 🔎 |
|
||
|
||
### A.3 판례 · 법률 해설
|
||
|
||
| # | 제목 | URL | 확인 |
|
||
|---|------|-----|------|
|
||
| 23 | 대법원 2022. 5. 12. 선고 2021도1533 판결 (CaseNote 판결문) | https://casenote.kr/대법원/2021도1533 | ✅ |
|
||
| 24 | 대법원 2021도1533 (CaseNote, 인코딩 URL) | https://casenote.kr/%EB%8C%80%EB%B2%95%EC%9B%90/2021%EB%8F%841533 | 🔎 |
|
||
| 25 | 대법원 판례속보 2021도1533 (대법원 포털) — ⚠️ 요약이 판결문과 모순 | https://scourt.go.kr/portal/news/NewsViewAction.work?pageIndex=1&searchWord=&searchOption=&seqnum=8456&gubun=4&type=0 | ✅ |
|
||
| 26 | 대법원, 야놀자 정보 크롤링 한 여기어때 창업주 '무죄' (ZDNet) | https://zdnet.co.kr/view/?no=20220512180515 | ✅ |
|
||
| 27 | 크롤링 관련 최근 대법원 판결과 그 시사점 (신&김 뉴스레터 PDF, 2022.06.20) | https://www.shinkim.com/kor/media/newsletter/pdf/1843 | ✅ |
|
||
| 28 | '여기어때' 사건으로 살펴본 '크롤링'의 적법성 [긱스] (다음 미러) | https://v.daum.net/v/Fi3RbZBWW5 | ✅ |
|
||
| 29 | '여기어때' 사건으로 살펴본 '크롤링'의 적법성 [긱스] (한국경제 원문) | https://www.hankyung.com/article/202404242738i | ⛔ 403 |
|
||
| 30 | 서울중앙지법 2021. 8. 19. 선고 2018가합508729 (데이터베이스제작자의권리침해금지등) | https://www.law.go.kr/LSW/precInfoP.do?precSeq=226943 | ✅ |
|
||
| 31 | 야놀자, 여기어때 상대 민사 승소…法 10억 배상 (한국경제) | https://www.hankyung.com/article/202108230529Y | 🔎 |
|
||
| 32 | 야놀자 서버에 1594만 회 접근… 무죄 이유 (한경 매거진) | https://magazine.hankyung.com/business/article/202205249081b | 🔎 |
|
||
| 33 | '무단 크롤링'으로 야놀자 정보 빼간 여기어때 [법알못 판례 읽기] | https://v.daum.net/v/kpTVF0ahwV | 🔎 |
|
||
| 34 | "경쟁회사 채용정보 '무단 크롤링'해 게재하면 저작권 침해" (리걸타임즈) — 서울고법 2016나2019365 | https://www.legaltimes.co.kr/news/articleView.html?idxno=32614 | ✅ |
|
||
| 35 | 잡코리아 대리해 크롤링 저작권침해금지 소송 최종 승소 (법무법인 민후) | https://www.minwho.kr/kr/business/business_case_view.php?bgu=view&idx=32903 | ✅ |
|
||
| 36 | 잡코리아, 사람인 저작권침해 2심서 승소 (ZDNet) | https://zdnet.co.kr/view/?no=20170412162832 | 🔎 |
|
||
| 37 | "경쟁사 웹사이트 무단 크롤링은 데이터베이스권 침해" (ZDNet) | https://zdnet.co.kr/view/?no=20170927180839 | 🔎 |
|
||
| 38 | 데이터 크롤링 보호 대상에 관한 소고 (심석찬, 인하대 AI·데이터법센터) | https://ils.inha.ac.kr/bbs/ils/3464/99038/download.do | 🔎 |
|
||
| 39 | 데이터 크롤링의 한국법상 허용기준 (Mondaq) | https://www.mondaq.com/copyright/1266554/데이터-크롤링의-한국법상-허용기준 | 🔎 |
|
||
| 40 | 경쟁사 DB 크롤링 저작권 침해 여부 (KCOPA 법제지원부 김지수 PDF) | https://www.kcopa.or.kr/download.do?uuid=359cebb5-4de0-45bb-b64e-831136e390bf.pdf | 🔎 |
|
||
| 41 | [스타트업 법률 가이드] 스타트업과 데이터 크롤링 (뉴스프라임) | https://www.newsprime.co.kr/news/article/?no=623494 | 🔎 |
|
||
| 42 | [김경환 변호사의 IT법] 공개데이터 크롤링, 합법과 불법의 경계는? | https://v.daum.net/v/EyCb0hoKc4 | 🔎 |
|
||
| 43 | 크롤링, 저작권침해 VS 합법? (법무법인 민후) | https://minwho.kr/kr/dispute/dispute_view.php?idx=45828 | 🔎 |
|
||
| 44 | 정보통신망법 제48조 (CaseNote 법령, 한글 URL) | https://casenote.kr/법령/정보통신망_이용촉진_및_정보보호_등에_관한_법률/제48조 | ✅ |
|
||
| 45 | 정보통신망법 제48조 (CaseNote, 인코딩 URL) | https://casenote.kr/%EB%B2%95%EB%A0%B9/%EC%A0%95%EB%B3%B4%ED%86%B5%EC%8B%A0%EB%A7%9D_%EC%9D%B4%EC%9A%A9%EC%B4%89%EC%A7%84_%EB%B0%8F_%EC%A0%95%EB%B3%B4%EB%B3%B4%ED%98%B8_%EB%93%B1%EC%97%90_%EA%B4%80%ED%95%9C_%EB%B2%95%EB%A5%A0/%EC%A0%9C48%EC%A1%B0 | 🔎 |
|
||
| 46 | 정보통신망법 제71조(벌칙) (CaseNote) | https://casenote.kr/%EB%B2%95%EB%A0%B9/%EC%A0%95%EB%B3%B4%ED%86%B5%EC%8B%A0%EB%A7%9D_%EC%9D%B4%EC%9A%A9%EC%B4%89%EC%A7%84_%EB%B0%8F_%EC%A0%95%EB%B3%B4%EB%B3%B4%ED%98%B8_%EB%93%B1%EC%97%90_%EA%B4%80%ED%95%9C_%EB%B2%95%EB%A5%A0/%EC%A0%9C71%EC%A1%B0 | 🔎 |
|
||
| 47 | 정보통신망법 제48조 (LBOX) | https://lbox.kr/v2/statute/정보통신망이용촉진및정보보호등에관한법률/본문%20%3E%20제6장%20%3E%20제48조 | 🔎 |
|
||
| 48 | 정보통신망법 제71조 (LBOX) | https://lbox.kr/v2/statute/정보통신망이용촉진및정보보호등에관한법률/본문%20%3E%20제10장%20%3E%20제71조 | 🔎 |
|
||
| 49 | 정보통신망법 제71조 (BigCase, 2015-03-25 기준) | https://bigcase.ai/law/정보통신망이용촉진및정보보호등에관한법률/제71조?refDate=20150325 | 🔎 |
|
||
| 50 | 정보통신망법 제71조 (BigCase, 2021-07-15 기준) | https://bigcase.ai/law/정보통신망이용촉진및정보보호등에관한법률/제71조?refDate=20210715 | 🔎 |
|
||
| 51 | 정보통신망법 제48조 조문정보 (국가법령정보센터) | https://www.law.go.kr/LSW//lsLinkProc.do?lsNm=정보통신망+이용촉진+및+정보보호+등에+관한+법률&efYd=20091119&lsId=prec20091119&lsClsCd=L&mode=11&joNo=004800&lnkJoNo=undefined | 🔎 |
|
||
| 52 | 정보통신망법 제71조 조문정보 (국가법령정보센터) | https://www.law.go.kr/LSW/lsLinkProc.do?lsClsCd=L&lsNm=정보통신망+이용촉진+및+정보보호+등에+관한+법률&lsId=prec20170619&joNo=007100&efYd=20170619&mode=11&lnkJoNo=undefined | 🔎 |
|
||
| 53 | 정보통신망침해 판례 (국가법령정보센터) | https://www.law.go.kr/LSW/precInfoP.do?precSeq=599601 | 🔎 |
|
||
| 54 | 대법원 2021. 6. 24. 선고 2020도17860 판결 (CaseNote) | https://casenote.kr/%EB%8C%80%EB%B2%95%EC%9B%90/2020%EB%8F%8417860 | 🔎 |
|
||
| 55 | 정보통신망 침입 여부가 문제된 사건 (브런치) | https://brunch.co.kr/@@6nIm/730 | 🔎 |
|
||
| 56 | [특별기고-이용재 변호사] 정보통신망 침입과 접근권한 (데일리시큐) | https://www.dailysecu.com/news/articleView.html?idxno=20228 | 🔎 |
|
||
| 57 | 정보통신망법위반죄 제48조 제1항 타인 계정 양도 (Nepla) | https://www.nepla.ai/wiki/it-정보-방송통신/인터넷-방송-통신/-일문일답-정보통신망법위반죄-제48조-제1항-타인-계정-양도받는-것이-정보통신망-침입인가-2w0ndvqjnj7d | 🔎 |
|
||
| 58 | 개인정보 보호법 제71조(벌칙) (CaseNote) | https://casenote.kr/%EB%B2%95%EB%A0%B9/%EA%B0%9C%EC%9D%B8%EC%A0%95%EB%B3%B4_%EB%B3%B4%ED%98%B8%EB%B2%95/%EC%A0%9C71%EC%A1%B0 | 🔎 |
|
||
| 59 | 정보통신망법 전문 (서원대 자료) | https://seowon.seowon.ac.kr/websamp/raw.htm | 🔎 |
|
||
| 60 | 개인정보보호법 제71조 벌칙 (다음 카페) | https://m.cafe.daum.net/insuranceprofit/Bo3B/1576 | 🔎 |
|
||
| 61 | NAVER Shopping (2025) (브런치) | https://brunch.co.kr/@@4Scy/120 | 🔎 |
|
||
| 62 | GeekNews 크롤링 토픽 | https://news.hada.io/topic?id=15994 | 🔎 |
|
||
|
||
### A.4 hiQ v. LinkedIn (미국)
|
||
|
||
| # | 제목 | URL | 확인 |
|
||
|---|------|-----|------|
|
||
| 63 | HiQ Labs v. LinkedIn (Wikipedia) — 273 F. Supp. 3d 1099 / 938 F.3d 985 / 31 F.4th 1180 | https://en.wikipedia.org/wiki/HiQ_Labs_v._LinkedIn | ✅ |
|
||
| 64 | hiQ v. LinkedIn Wrapped Up: Web Scraping Lessons Learned (ZwillGen) | https://www.zwillgen.com/alternative-data/hiq-v-linkedin-wrapped-up-web-scraping-lessons-learned/ | 🔎 |
|
||
| 65 | Web scraping case law: HiQ v. LinkedIn (Apify) | https://blog.apify.com/hiq-v-linkedin/ | 🔎 |
|
||
| 66 | HiQ Labs Scrapes by Again: Ninth Circuit Reaffirms (Fenwick) | https://www.fenwick.com/insights/publications/hiq-labs-scrapes-by-again-the-ninth-circuit-reaffirms-that-data-scraping-does-not-violate-the-cfaa-1 | 🔎 |
|
||
| 67 | LinkedIn's Data Scraping Battle Ends with Proposed Judgment (Privacy World) | https://www.privacyworld.blog/2022/12/linkedins-data-scraping-battle-with-hiq-labs-ends-with-proposed-judgment/ | 🔎 |
|
||
| 68 | LinkedIn v. hiQ: Landmark Data Scraping Suit (Morgan Lewis) | https://www.morganlewis.com/blogs/sourcingatmorganlewis/2022/12/linkedin-v-hiq-landmark-data-scraping-suit-provides-guidance-to-data-scrapers-and-web-operators | 🔎 |
|
||
| 69 | Ninth Circuit Holds Data Scraping is Legal (California Lawyers Association) | https://calawyers.org/privacy-law/ninth-circuit-holds-data-scraping-is-legal-in-hiq-v-linkedin/ | 🔎 |
|
||
| 70 | What Recent Rulings Say About the Legality of Data Scraping (Farella Braun + Martel) | https://www.fbm.com/publications/what-recent-rulings-in-hiq-v-linkedin-and-other-cases-say-about-the-legality-of-data-scraping/ | 🔎 |
|
||
| 71 | hiQ and LinkedIn Reach Settlement (Proskauer New Media Law) | https://newmedialaw.proskauer.com/2022/12/08/hiq-and-linkedin-reach-proposed-settlement-in-landmark-scraping-case/ | 🔎 |
|
||
|
||
### A.5 표준 · 규범 · 학술
|
||
|
||
| # | 제목 | URL | 확인 |
|
||
|---|------|-----|------|
|
||
| 72 | RFC 9309: Robots Exclusion Protocol (본문) | https://www.rfc-editor.org/rfc/rfc9309.html | ✅ |
|
||
| 73 | RFC 9110 Retry-After / 조건부 요청 / 304 | https://www.rfc-editor.org/rfc/rfc9110.html#name-retry-after | ✅ |
|
||
| 74 | RFC 9309 info page (RFC Editor) | https://www.rfc-editor.org/info/rfc9309/ | 🔎 |
|
||
| 75 | RFC 9309 (IETF Datatracker) | https://datatracker.ietf.org/doc/html/rfc9309 | 🔎 |
|
||
| 76 | RFC 9309 PDF (IETF) | https://www.ietf.org/rfc/rfc9309.pdf | 🔎 |
|
||
| 77 | RFC 9309 (ACM DL, doi 10.17487/RFC9309) | https://dl.acm.org/doi/10.17487/RFC9309 | 🔎 |
|
||
| 78 | RFC 9309 일본어 번역 | https://tex2e.github.io/rfc-translater/html/rfc9309.html | 🔎 |
|
||
| 79 | RFC 9309 status/mechanics (AgentGrade) | https://agentgrade.com/standards/rfc-9309 | 🔎 |
|
||
| 80 | Robots.txt Is Now an Official IETF Internet Standard | https://www.searchengineworld.com/rfc9309-robots-txt-quietly-became-an-official-internet-standard | 🔎 |
|
||
| 81 | Robots.txt (Wikipedia) | https://en.wikipedia.org/wiki/Robots.txt | 🔎 |
|
||
| 82 | robots.txt (나무위키) | https://namu.wiki/w/robots.txt | 🔎 |
|
||
| 83 | robots.txt - Search Console 도움말 | https://support.google.com/webmasters/answer/12818275?hl=ko | 🔎 |
|
||
| 84 | robots.txt 파일 만들기 및 제출 (Google) | https://developers.google.com/crawling/docs/robots-txt/create-robots-txt | 🔎 |
|
||
| 85 | Robots.txt Introduction and Guide (Google Search Central) | https://developers.google.com/search/docs/crawling-indexing/robots/intro | 🔎 |
|
||
| 86 | robots.txt 총정리 가이드 (TBWA 데이터랩) | https://seo.tbwakorea.com/blog/robots-txt-complete-guide/ | 🔎 |
|
||
| 87 | Robots.txt (FoxData Glossary) | https://foxdata.com/en/glossary/robotstxt/ | 🔎 |
|
||
| 88 | `urllib.robotparser` — Python 3 docs | https://docs.python.org/3/library/urllib.robotparser.html | ✅ |
|
||
| 89 | **arXiv:2602.09606** — When Handshakes Tell the Truth: Detecting Web Bad Bots via TLS Fingerprints (Ghalia Jarad, Kemal Bicakci, 2026-02-10). JA4DB, CatBoost AUC 0.998 / F1 0.9734 / acc 0.9863 | https://arxiv.org/abs/2602.09606 | ✅ |
|
||
| 90 | arXiv:2602.09606 (HTML 판) | https://arxiv.org/html/2602.09606v1 | 🔎 |
|
||
| 91 | **arXiv:2606.30119** — On the Internet, Nobody Knows You're an LLM Bot (Fayolle, Bouhenniche, Pélissier, Laperdrix, Maurice, Rudametkin, 2026-06-29) | https://arxiv.org/abs/2606.30119 | ✅ |
|
||
| 92 | arXiv:2606.30119 (PDF) | https://arxiv.org/pdf/2606.30119 | 🔎 |
|
||
|
||
### A.6 봇 탐지 · WAF 벤더 문서
|
||
|
||
| # | 제목 | URL | 확인 |
|
||
|---|------|-----|------|
|
||
| 93 | Cloudflare — Bot detection engines (Heuristics / JSD / ML / AD, `__cf_bm`) | https://developers.cloudflare.com/bots/concepts/bot-detection-engines/ | ✅ |
|
||
| 94 | Cloudflare — JA3/JA4 fingerprint (ja4Signals 필드 전체) | https://developers.cloudflare.com/bots/additional-configurations/ja3-ja4-fingerprint/ | ✅ |
|
||
| 95 | Cloudflare — JavaScript Detections (`/cdn-cgi/challenge-platform/`, `cf_clearance`) | https://developers.cloudflare.com/bots/reference/javascript-detections/ | ✅ |
|
||
| 96 | Cloudflare — Challenges 개요 | https://developers.cloudflare.com/cloudflare-challenges/ | ✅ (해당 내용 없음) |
|
||
| 97 | Cloudflare — WAF Challenges 참조 | https://developers.cloudflare.com/waf/reference/cloudflare-challenges/ | ✅ (해당 내용 없음) |
|
||
| 98 | Cloudflare — Detecting a challenge page response | https://developers.cloudflare.com/cloudflare-challenges/reference/detecting-a-challenge-page-response/ | ⛔ 404 |
|
||
| 99 | Cloudflare Challenges llms.txt | https://developers.cloudflare.com/cloudflare-challenges/llms.txt | 🔎 |
|
||
| 100 | Cloudflare Turnstile | https://developers.cloudflare.com/turnstile/ | 🔎 |
|
||
| 101 | DataDome — How DataDome works | https://docs.datadome.co/docs/how-datadome-works | ⛔ 404 |
|
||
| 102 | Akamai — Bot Manager 문서 | https://techdocs.akamai.com/bot-manager/docs/welcome-bot-manager | ⛔ 302 → auth0 로그인 |
|
||
| 103 | Akamai techdocs 로그인 리다이렉트 | https://techdocs.akamai.com/auth/login/akamai-external?scope=openid%20profile%20email&redirect=%2fgo%2fakamai-docs%3fredirect%3d%252Fbot-manager%252Fdocs%252Fwelcome-bot-manager | ⛔ |
|
||
| 104 | Akamai auth0 authorize 엔드포인트 | https://akamai-readme.us.auth0.com/authorize?response_type=code&client_id=X0LWNlCRUAyPS3W7LnMc6dH6uVw6cfDE&connection=akamai-external&redirect_uri=https%3A%2F%2Ftechdocs.akamai.com%2Fauth%2Freadme%2Fcallback%3Fcustomer%3Dakamai-external%26redirect%3D%252Fbot-manager%252Fdocs%252Fwelcome-bot-manager&scope=openid%20profile%20email | ⛔ |
|
||
| 105 | Bypass Anti-Bot Protection (Scrapfly) | https://scrapfly.io/bypass | 🔎 |
|
||
| 106 | Akamai Bot Manager Reviews (PeerSpot) | https://www.peerspot.com/products/akamai-bot-manager-reviews | 🔎 |
|
||
| 107 | Best Bot Management Solutions 2025–2026 (Fastly) | https://www.fastly.com/blog/best-bot-management-solutions-2025-2026 | 🔎 |
|
||
| 108 | The Hidden Fingerprints of Bot Protection (Dima Kynal, Medium) | https://medium.com/@dimakynal/the-hidden-fingerprints-of-bot-protection-how-every-major-vendor-leaves-traces-in-your-browser-ae951e355606 | 🔎 |
|
||
| 109 | Akamai Bot Manager Review & Alternatives (ITT Systems) | https://www.ittsystems.com/akamai-bot-manager-review-alternatives/ | 🔎 |
|
||
| 110 | Akamai Bot Manager Review (Comparitech) | https://www.comparitech.com/net-admin/akamai-bot-manager-review/ | 🔎 |
|
||
| 111 | Akamai (Bot Manager) 용어집 (Decodo) | https://decodo.com/glossary/akamai | 🔎 |
|
||
| 112 | Best Bot Detection Software 2026 (Gitnux) | https://gitnux.org/best/bot-detection-software/ | 🔎 |
|
||
| 113 | Anti-Bot Detection in 2026 (KnowledgeSDK) | https://knowledgesdk.com/blog/anti-bot-detection-guide | 🔎 |
|
||
| 114 | Advanced Evasion Techniques & Cloudflare Architecture 2026 (Ayush Aggarwal, Medium) | https://medium.com/@ayushaggarwal42003/advanced-evasion-techniques-and-architecture-analysis-of-cloudflare-bot-management-systems-in-2026-1b4ba7cc3b22 | 🔎 |
|
||
|
||
### A.7 한국 웹방화벽 (WAPPLES / 펜타시큐리티)
|
||
|
||
| # | 제목 | URL | 확인 |
|
||
|---|------|-----|------|
|
||
| 115 | [뉴스레터 9월호] WAPPLES, 16년 연속 국내 웹방화벽 시장점유율 1위 | https://www.pentasecurity.co.kr/news-letter/202409/ | ✅ (제목만 확인) |
|
||
| 116 | 아시아 태평양 점유율 1위 웹방화벽 WAPPLES | https://www.pentasecurity.co.kr/wapples/ | 🔎 |
|
||
| 117 | WAPPLES SA for Cloud (API 보안 / Bot 완화 / DoS 방어) | https://www.pentasecurity.co.kr/wapples-sa-for-cloud/ | 🔎 |
|
||
| 118 | 펜타시큐리티, 웹방화벽 시장 점유율 1위 (뉴스레터 2020-02) | https://www.pentasecurity.co.kr/news-letter/newsletter-202002/ | 🔎 |
|
||
| 119 | 펜타시큐리티, '와플' 나라장터 웹방화벽 점유율 100% 달성 | https://www.pentasecurity.co.kr/press-release/펜타시큐리티-와플-나라장터-웹방화벽-점유율-100-달/ | 🔎 |
|
||
| 120 | WAAP 웹방화벽 '와플' 14년 연속 국내 시장 1위 (정보통신신문) | https://www.koit.co.kr/news/articleView.html?idxno=99803 | 🔎 |
|
||
| 121 | 펜타시큐리티, WAAP 웹방화벽 '와플' 14년 연속 1위 (데일리시큐) | https://www.dailysecu.com/news/articleView.html?idxno=138183 | 🔎 |
|
||
| 122 | 펜타시큐리티 FAQ | https://www.pentasecurity.co.kr/faq/ | 🔎 |
|
||
| 123 | 행정·공공기관 웹사이트 관리개선 추진계획 (행정안전부) | https://www.mois.go.kr/frt/bbs/type001/commonSelectBoardArticle.do;jsessionid=DY-IMiMKOI-itDmjGdN2SBzv.node10?bbsId=BBSMSTR_000000000015&nttId=68005 | 🔎 |
|
||
|
||
### A.8 세션 · 헤더 · 크롤링 차단 (한국어 자료)
|
||
|
||
| # | 제목 | URL | 확인 |
|
||
|---|------|-----|------|
|
||
| 124 | 크롤링을 막는 방법 (velog) | https://velog.io/@commicat/크롤링을-막는-방법 | 🔎 |
|
||
| 125 | 스크래핑 방지와 크롤링 방지 (제로 트러스트 웹 보안 가이드) | https://drminside.com/en/tech-insight/crawling-safe | 🔎 |
|
||
| 126 | 웹 스크래핑 전문가가 밝힌 차단 우회 5원칙 (해시스크래퍼) | https://blog.hashscraper.com/5-principles-for-bypassing-web-crawling-blocks/ | 🔎 |
|
||
| 127 | [WEB] JSESSIONID (SW Developer) | https://wonyong-jang.github.io/web/2020/05/10/Web-Jsessionid.html | 🔎 |
|
||
| 128 | 스프링 시큐리티의 Session Fixation 방어와 JSESSIONID 전달 방식 (velog) | https://velog.io/@bflykky/스프링-시큐리티의-Session-Fixation-공격-방어와-JSESSIONID-전달-방식-직접-살펴보기 | 🔎 |
|
||
| 129 | [개념정리] 웹에서의 인증 (velog) | https://velog.io/@flaxinger/개념정리-웹에서의-인증 | 🔎 |
|
||
| 130 | 세션 관련 보안 문제 질문 (인프런) | https://www.inflearn.com/community/questions/554285/세션-관련-보안-문제-질문 | 🔎 |
|
||
| 131 | JSESSIONID changing on every click (Adobe Community) | https://community.adobe.com/t5/coldfusion-discussions/jsessionid-changing-on-every-click-refresh-form-submit-etc/m-p/9416065/highlight/true | 🔎 |
|
||
| 132 | 웹사이트 액세스 허용 또는 차단하기 (Chrome Enterprise) | https://support.google.com/chrome/a/answer/7532419?hl=ko | 🔎 |
|
||
| 133 | `elevisor_for_j2ee_uid` 쿠키 검색 시도 | https://html.duckduckgo.com/html/?q=elevisor_for_j2ee_uid+cookie | ⛔ 캡차 |
|
||
|
||
### A.9 도구 저장소 · 패키지
|
||
|
||
| # | 제목 | URL | 확인 |
|
||
|---|------|-----|------|
|
||
| 134 | `lexiforest/curl_cffi` — 6.4k stars, 546 forks, 569 commits | https://github.com/lexiforest/curl_cffi | ✅ |
|
||
| 135 | `curl-cffi` PyPI — 0.16.3 (2026-09-02), Python ≥3.10 | https://pypi.org/project/curl-cffi/ | ✅ |
|
||
| 136 | `curl-cffi` 0.5.1 (구 버전 페이지) | https://pypi.org/project/curl-cffi/0.5.1/ | 🔎 |
|
||
| 137 | `Kaliiiiiiiiii-Vinyzu/patchright` — 4.2k stars, 204 forks, 1,020 commits | https://github.com/Kaliiiiiiiiii-Vinyzu/patchright | ✅ |
|
||
| 138 | `patchright` PyPI — 1.62.2 (2026-08-29) | https://pypi.org/project/patchright/ | ✅ |
|
||
| 139 | `rebrowser/rebrowser-patches` — 1.4k stars, Puppeteer 24.8.1 / Playwright 1.52.0 | https://github.com/rebrowser/rebrowser-patches | ✅ |
|
||
| 140 | `rebrowser-playwright` PyPI JSON — 1.52.0 (2025-05-09) | https://pypi.org/pypi/rebrowser-playwright/json | ✅ |
|
||
| 141 | `rebrowser-playwright` npm registry — latest 1.52.0, created 2024-09-28 | https://registry.npmjs.org/rebrowser-playwright | ✅ |
|
||
| 142 | `rebrowser-playwright` PyPI 페이지 | https://pypi.org/project/rebrowser-playwright/ | ⛔ 로드 실패 |
|
||
| 143 | `ultrafunkamsterdam/nodriver` — 4.7k stars, 166 commits, undetected-chromedriver 후속작 | https://github.com/ultrafunkamsterdam/nodriver | ✅ |
|
||
| 144 | `nodriver` PyPI — 0.50.3 (2026-05-13), Python ≥3.9 | https://pypi.org/project/nodriver/ | ✅ |
|
||
| 145 | `ultrafunkamsterdam/undetected-chromedriver` — 12.8k stars, v3.5.0 | https://github.com/ultrafunkamsterdam/undetected-chromedriver | ✅ |
|
||
| 146 | `daijro/camoufox` — 11.6k stars, 987 forks, 681 commits, 유지보수 공백 경고 | https://github.com/daijro/camoufox | ✅ |
|
||
| 147 | `camoufox` PyPI — 0.5.5 (2026-08-18), Python ≥3.10 <4.0 | https://pypi.org/project/camoufox/ | ✅ |
|
||
| 148 | `playwright-stealth` PyPI — 2.0.3 (2026-04-04), maintainer Mattwmaster58 | https://pypi.org/project/playwright-stealth/ | ✅ |
|
||
| 149 | `berstend/puppeteer-extra` — 7.4k stars | https://github.com/berstend/puppeteer-extra | ✅ |
|
||
| 150 | `luminati-io/curl_cffi-web-scraping` | https://github.com/luminati-io/curl_cffi-web-scraping | 🔎 |
|
||
| 151 | `undetected-geckodriver` PyPI | https://pypi.org/project/undetected-geckodriver | 🔎 |
|
||
| 152 | Playwright Issue #33566 — Changes in Chromium headless in v1.49 | https://github.com/microsoft/playwright/issues/33566 | ✅ |
|
||
| 153 | Playwright Issue #33960 — channel 'chromium' headless executable 버그 | https://github.com/microsoft/playwright/issues/33960 | 🔎 |
|
||
| 154 | Playwright Issue #34306 — Win11 v1.49.1 headless 실행 불가 | https://github.com/microsoft/playwright/issues/34306 | 🔎 |
|
||
| 155 | Playwright Browsers 문서 | https://playwright.dev/docs/browsers | 🔎 |
|
||
| 156 | Playwright Python Browsers 문서 | https://playwright.dev/python/docs/browsers | 🔎 |
|
||
| 157 | Playwright .NET Browsers 문서 | https://playwright.dev/dotnet/docs/browsers | 🔎 |
|
||
| 158 | nodriver 예제 대상 사이트 | https://www.nowsecure.nl | 🔎 |
|
||
| 159 | camoufox 예제 대상 사이트 | https://example.com | 🔎 |
|
||
|
||
### A.10 스텔스 · 우회 기법 해설 (참고용, 채택하지 않음)
|
||
|
||
| # | 제목 | URL | 확인 |
|
||
|---|------|-----|------|
|
||
| 160 | Playwright Stealth: Bypass Bot Detection (Scrapfly) | https://scrapfly.io/blog/posts/playwright-stealth-bypass-bot-detection | 🔎 |
|
||
| 161 | Playwright Stealth Mode in 2026: The 7 Patches That Actually Matter (DEV) | https://dev.to/vhub_systems_ed5641f65d59/playwright-stealth-mode-in-2026-the-7-patches-that-actually-matter-46bp | 🔎 |
|
||
| 162 | Best Playwright Stealth 2026: Patchright vs Camoufox vs noDriver | https://scrapewise.ai/blogs/playwright-stealth-2026 | 🔎 |
|
||
| 163 | Playwright Anti-Fingerprinting Alternatives 2026 (BotCloud) | https://botcloud.dev/blog/playwright-anti-fingerprinting-alternatives-2026/ | ⛔ 403 |
|
||
| 164 | Patchright vs Rebrowser-Patches 비교 2026 (DataResearchTools) | https://dataresearchtools.com/patchright-vs-rebrowser-patches-stealth-playwright-patches-compared-2026/ | 🔎 |
|
||
| 165 | Playwright Stealth Not Working in 2026 (HumanBrowser) | https://humanbrowser.cloud/blog/playwright-stealth-not-working-2026 | 🔎 |
|
||
| 166 | Playwright Stealth: What Works in 2026 (DiCloak) | https://dicloak.com/blog-detail/playwright-stealth-what-works-in-2026-and-where-it-falls-short | 🔎 |
|
||
| 167 | Best Stealth Browsers for Web Scraping in 2026 (Scrapfly) | https://scrapfly.io/blog/posts/best-stealth-browsers | 🔎 |
|
||
| 168 | Stealth browsers 2026: nodriver, Camoufox, Patchright 벤치마크 (ProxyCove) | https://proxycove.com/en/blog/stealth-browsers-2026-nodriver-camoufox-patchright-benchmark | 🔎 |
|
||
| 169 | The 6 best Patchright alternatives in 2026 (RoundProxies) | https://roundproxies.com/blog/best-patchright-alternatives/ | 🔎 |
|
||
| 170 | AI Browser Automation in 2026: Camoufox, Nodriver & Stealth MCP (PROXIES.SX) | https://www.proxies.sx/blog/ai-browser-automation-camoufox-nodriver-2026 | 🔎 |
|
||
| 171 | Best Undetected ChromeDriver Alternatives for 2026 (ZenRows) | https://www.zenrows.com/blog/undetected-chromedriver-alternatives | 🔎 |
|
||
| 172 | undetected_chromedriver: Guide to Avoid Detection (Decodo) | https://decodo.com/blog/undetected-chromedriver | 🔎 |
|
||
| 173 | How to Bypass Cloudflare When Web Scraping in 2026 (Scrapfly) | https://scrapfly.io/blog/posts/how-to-bypass-cloudflare-anti-scraping | 🔎 |
|
||
| 174 | How to Bypass Cloudflare when Scraping: 8 Best Methods (ZenRows) | https://www.zenrows.com/blog/bypass-cloudflare | 🔎 |
|
||
| 175 | How to ByPass Cloudflare Challenges using Selenium (BrowserStack) | https://www.browserstack.com/guide/selenium-cloudflare | 🔎 |
|
||
| 176 | Cloudflare Scraper: Bypass with ScrapingBee API | https://www.scrapingbee.com/blog/how-to-bypass-cloudflare-antibot-protection-at-scale/ | 🔎 |
|
||
| 177 | How to Bypass Cloudflare in 2026 (WebScrapingAPI) | https://www.webscrapingapi.com/how-to-bypass-cloudflare | 🔎 |
|
||
| 178 | Bypass Cloudflare Bot Protection: No Headless Browser (webclaw) | https://webclaw.io/blog/bypass-cloudflare-bot-protection-web-scraping | 🔎 |
|
||
| 179 | Headless Browser Detection Methods: Browser Isolation Guide 2026 (Sendwin) | https://blog.send.win/headless-browser-detection-methods-browser-isolation-guide-2026/ | 🔎 |
|
||
| 180 | How Sites Detect Headless Browsers (2026 Guide, DEV) | https://dev.to/vhub_systems_ed5641f65d59/how-sites-detect-headless-browsers-and-how-to-evade-each-signal-2026-guide-2jj0 | 🔎 |
|
||
| 181 | Making Chrome Headless Undetectable (Intoli) | https://intoli.com/blog/making-chrome-headless-undetectable/ | 🔎 |
|
||
| 182 | Detecting headless Chrome instrumented with Puppeteer (2024) | https://deviceandbrowserinfo.com/learning_zone/articles/detecting-headless-chrome-puppeteer-2024 | ✅ |
|
||
| 183 | Detecting headless Chrome instrumented with Selenium (2024) | https://deviceandbrowserinfo.com/learning_zone/articles/detecting-headless-chrome-selenium-2024 | 🔎 |
|
||
| 184 | undetected-chromedriver Headless Mode (DeepWiki) | https://deepwiki.com/ultrafunkamsterdam/undetected-chromedriver/4.3-headless-mode | 🔎 |
|
||
| 185 | Dive Into Headless Chrome (DEV / TestMu AI) | https://dev.to/testmuai/dive-into-headless-chrome-your-key-to-efficient-web-development-30g0 | 🔎 |
|
||
| 186 | Dive Into Headless Chrome (TestMu AI 원문) | https://www.testmuai.com/blog/headless-chrome/ | 🔎 |
|
||
| 187 | What Is a Headless Browser? (WebScraping.AI) | https://webscraping.ai/blog/headless-browser-guide | 🔎 |
|
||
| 188 | headless chromium 133 printToPdf 탐지 이슈 (GitLab) | https://gitlab.com/behat-chrome/chrome-mink-driver/-/issues/164 | 🔎 |
|
||
| 189 | When Tests Should Run Headless vs Headed in Playwright (Currents) | https://currents.dev/posts/when-tests-should-run-headless-vs-headed-in-playwright | 🔎 |
|
||
| 190 | Headless vs Headed in Playwright (TestDino) | https://testdino.com/blog/headless-vs-headed | 🔎 |
|
||
|
||
### A.11 TLS/HTTP 지문 · curl_cffi 해설
|
||
|
||
| # | 제목 | URL | 확인 |
|
||
|---|------|-----|------|
|
||
| 191 | TLS Fingerprinting: How It Works & How to Bypass It (Browserless) | https://www.browserless.io/blog/tls-fingerprinting-explanation-detection-and-bypassing-it-in-playwright-and-puppeteer | 🔎 |
|
||
| 192 | TLS Fingerprinting: The Bot Detection Method You Cannot Ignore (DEV) | https://dev.to/xavier_fok/tls-fingerprinting-the-bot-detection-method-you-cannot-ignore-504g | 🔎 |
|
||
| 193 | JA3/JA4 TLS Fingerprint 도구 (Scrapfly) | https://scrapfly.io/web-scraping-tools/ja3-fingerprint | 🔎 |
|
||
| 194 | Use Curl Impersonate to scrape as Chrome or Firefox (Scrapfly) | https://scrapfly.io/blog/posts/curl-impersonate-scrape-chrome-firefox-tls-http2-fingerprint | 🔎 |
|
||
| 195 | How to use curl_cffi for web scraping in Python (ScrapingBee) | https://www.scrapingbee.com/blog/how-to-use-curl-cffi/ | 🔎 |
|
||
| 196 | Web Scraping With curl_cffi and Python in 2026 (Bright Data) | https://brightdata.com/blog/web-data/web-scraping-with-curl-cffi | 🔎 |
|
||
| 197 | TLS Impersonation with curl_cffi: Beating JA3/JA4 Detection (ProxyHat) | https://proxyhat.com/blog/tls-impersonation-curl-cffi-guide | 🔎 |
|
||
| 198 | How to Solve TLS/JA3 Fingerprinting with curl_cffi (CapSolver) | https://www.capsolver.com/blog/All/web-scraping-with-curl-cffi | 🔎 |
|
||
|
||
### A.12 정중한 크롤링 · 레이트리밋 · 차단 진단
|
||
|
||
| # | 제목 | URL | 확인 |
|
||
|---|------|-----|------|
|
||
| 199 | What is polite crawling (Firecrawl Glossary) | https://www.firecrawl.dev/glossary/web-crawling-apis/what-is-polite-crawling | 🔎 |
|
||
| 200 | Requests at Scale — Exponential Backoff with Jitter (Tito Adeoye, Medium) | https://medium.com/@titoadeoye/requests-at-scale-exponential-backoff-with-jitter-with-examples-4d0521891923 | 🔎 |
|
||
| 201 | Design a Web Crawler (Hello Interview) | https://www.hellointerview.com/learn/system-design/problem-breakdowns/web-crawler | 🔎 |
|
||
| 202 | Respecting robots.txt and Crawl-Delay (Evomi) | https://evomi.com/blog/respecting-robots.txt-and-crawl-delay-ethical-scraping-that-still-scales | 🔎 |
|
||
| 203 | How to Overcome Rate Limiting in Web Scraping 2026 (ScrapeHero) | https://www.scrapehero.com/rate-limiting-in-web-scraping/ | 🔎 |
|
||
| 204 | Guide to Distributed Web Crawling (Bright Data) | https://brightdata.com/blog/web-data/distributed-web-crawling | 🔎 |
|
||
| 205 | Dealing with Rate Limiting Using Exponential Backoff (The Web Scraping Club) | https://substack.thewebscraping.club/p/rate-limit-scraping-exponential-backoff | 🔎 |
|
||
| 206 | Mastering Webhook Retry Logic (SparkCo) | https://sparkco.ai/blog/mastering-webhook-retry-logic-strategies-and-best-practices | 🔎 |
|
||
| 207 | Designing a Web Crawler at Scale (OneNoughtOne) | https://www.onenoughtone.com/learn/web-crawler/3 | 🔎 |
|
||
| 208 | How Headers Are Used to Block Web Scrapers (Scrapfly) — 헤더 순서, requests vs httpx | https://scrapfly.io/blog/posts/how-to-avoid-web-scraping-blocking-headers | ✅ |
|
||
| 209 | Why websites block scrapers (Web Scraper) | https://webscraper.io/blog/why-websites-block-scrapers | 🔎 |
|
||
| 210 | Web Scraping Without Getting Blocked: 2026 Guide (ScrapingBee) | https://www.scrapingbee.com/blog/web-scraping-without-getting-blocked/ | 🔎 |
|
||
| 211 | 8 Tips To Avoid Getting Blocked While Web Scraping (Scrapingdog) | https://www.scrapingdog.com/blog/how-to-avoid-getting-blocked-while-scraping/ | 🔎 |
|
||
| 212 | Web Scraping Without Getting Banned in 2026 (DEV) | https://dev.to/vhub_systems_ed5641f65d59/web-scraping-without-getting-banned-in-2026-the-complete-anti-bot-bypass-guide-297h | 🔎 |
|
||
| 213 | 10 Best Tips on How to Not Get Blocked (ScraperAPI) | https://www.scraperapi.com/blog/10-tips-for-web-scraping/ | 🔎 |
|
||
| 214 | 14 Ways for Web Scraping Without Getting Blocked (ZenRows) | https://www.zenrows.com/blog/web-scraping-without-getting-blocked | 🔎 |
|
||
| 215 | Web Scraping Without Getting Blocked: 12 Techniques (Bright Data) | https://brightdata.com/blog/web-data/web-scraping-without-getting-blocked | 🔎 |
|
||
| 216 | 15 Methods to Not Get Blocked Web Scraping (RoundProxies) | https://roundproxies.com/blog/web-scraping-without-getting-blocked/ | 🔎 |
|
||
| 217 | What is Error 1015 (Cloudflare) and How to Fix it (Scrapfly) | https://scrapfly.io/blog/posts/what-is-cloudflare-1015-error-and-how-to-fix-it | 🔎 |
|
||
| 218 | what is error 1015 cloudflare (DEV / Scrapfly) | https://dev.to/scrapfly/what-is-error-1015-cloudflare-and-how-to-fix-it-46cl | 🔎 |
|
||
| 219 | How to Fix Cloudflare Blocking: Errors 1020, 1015 (iTechGuides) | https://www.itechguides.com/how-to-fix-cloudflare-blocking/ | 🔎 |
|
||
| 220 | Cloudflare Error 1020: What It Means and How to Fix It (MarsProxies) | https://marsproxies.com/blog/cloudflare-error-1020/ | 🔎 |
|
||
| 221 | Cloudflare Error 1015: How to Fix Rate Limited (Unknown Proxies) | https://unknownproxies.com/blog/error-codes/cloudflare-error-1015-you-are-being-rate-limited | 🔎 |
|
||
| 222 | Cloudflare Error 1015 (IPOASIS) | https://www.ipoasis.com/blog/cloudflare-error-1015-how-to-fix-you-are-being-rate-limited | 🔎 |
|
||
| 223 | Cloudflare Error Codes Explained: 1015, 1020 (SpyderProxy) | https://spyderproxy.com/blog/cloudflare-error-codes-explained | 🔎 |
|
||
| 224 | Cloudflare Error 1020 Access Denied: Fix Guide (NSLSolver) | https://nslsolver.com/blog/cloudflare-1020-access-denied | 🔎 |
|
||
|
||
**총 224개 URL** (raw dump 에서 추출된 전량. 조사 과정의 자체 참조 URL 은 제외).
|
||
|
||
---
|
||
|
||
## 부록 B. 미해결 질문 / 실측 필요 항목
|
||
|
||
### B.1 대상 사이트 구조 (구현 착수 전 필수)
|
||
|
||
- [ ] `/bbs/117` 목록에서 **개별 공고 상세 URL 패턴**을 확정한다. (이번 grep 은 `--- item links ---` 공란)
|
||
- [ ] **페이지네이션 실제 요청 형태**를 확정한다. GET `?page=N` 인지, hidden form POST 인지, `ctgryNo`/`limit`/`searchYn` 이 함께 필요한지.
|
||
- [ ] `registTsStart` / `registTsEnd` 파라미터로 **날짜 범위 필터**가 가능한지 확인. 가능하면 "최근 7일" 만 조회해 요청 수를 더 줄인다.
|
||
- [ ] 공고 **첨부파일(xlsx/hwp/pdf)** 의 존재 여부·다운로드 URL·`Referer` 요구 여부를 확인한다.
|
||
- [ ] 목록 HTML 에서 **조회수 등 매번 변하는 필드**를 식별해 정규화 해시 대상에서 제외한다.
|
||
- [ ] `/bbs/117` 이 실제로 `If-Modified-Since` / `If-None-Match` 에 **304 를 반환하는지** 실측한다.
|
||
- [ ] nedrug 이 **ALPN 으로 h2 를 협상할 수 있는지** 확인 (`curl --http2 -v`). 현재는 HTTP/1.1 로만 확인됨.
|
||
- [ ] `elevisor_for_j2ee_uid` 쿠키가 어떤 제품(APM/모니터링)의 것인지 확정한다. (DuckDuckGo 캡차로 실패)
|
||
- [ ] **nedrug 이용약관에 크롤링/자동수집 금지 조항이 있는지** 확인한다. → §8.7.1 의 유일한 미확정 리스크.
|
||
- [ ] `robots.txt` 의 `Disallow: /` 가 의도인지 실수인지 **식약처에 직접 문의**할지 결정한다.
|
||
|
||
### B.2 공식 API
|
||
|
||
- [ ] `getMdcDmfList01` 의 **갱신주기**를 확인한다. (data.go.kr 페이지에 미명시)
|
||
- [ ] API 가 **취하/변경 이력**을 제공하는지 확인. 제공하지 않으면 "우리가 매일 스냅샷을 떠서 diff" 하는 방식이 유일하다.
|
||
- [ ] API 응답의 **전체 레코드 수**와 `numOfRows` 최대값을 확인해 1회 실행 요청 수를 산정한다.
|
||
- [ ] 운영계정 전환 조건과 한도 증설 절차를 확인한다.
|
||
- [ ] API 데이터에 붙는 **공공누리 유형 번호**를 확정한다. ("이용허락범위 제한 없음"이 몇 유형에 해당하는지)
|
||
- [ ] API 로 얻는 필드(7개)와 게시판 공고문 내용의 **차이(delta)** 를 정량화해, 게시판 수집이 정말 필요한지 재검토한다.
|
||
|
||
### B.3 법률
|
||
|
||
- [ ] 야놀자 v. 여기어때 **민사 항소심·상고심 결과와 확정 여부** (검색 예산 소진으로 미확인)
|
||
- [ ] 잡코리아 v. 사람인 **대법원 사건번호와 선고일** (상고기각 확정이라는 서술만 확보)
|
||
- [ ] `scourt.go.kr` 판례속보(FETCH #12) 요약이 판결문과 모순되는 이유 — 원 페이지를 사람이 직접 열어 확인
|
||
- [ ] 신&김 뉴스레터 PDF(FETCH #4) 의 "실질적 접근 가능성" 표현이 판결문 어디에 대응하는지 확인
|
||
- [ ] 공공누리 **AI유형**의 정확한 이용조건 (data.go.kr 정책 페이지에 언급만 있음)
|
||
- [ ] 내부 리포트를 **사내 다른 부서**에 배포하는 것이 §8.7.2 의 시나리오 A 인지 C 인지 판단 기준 명확화
|
||
|
||
### B.4 봇 탐지 기술 (참고 지식의 공백)
|
||
|
||
- [ ] Cloudflare 의 **`cf-mitigated` 헤더 값과 챌린지 페이지 상태 코드** — 공식 문서 404 로 미확인
|
||
- [ ] Akamai Bot Manager 의 **`_abck` 쿠키 / sensor data / tarpit 액션** — 공식 문서 로그인 벽으로 미확인
|
||
- [ ] DataDome 의 **`x-datadome` 헤더와 403 캡차 응답 형태** — 공식 문서 404 로 미확인
|
||
- [ ] `aiohttp` 의 **헤더 순서 보존 여부**
|
||
- [ ] **HTTP/2 헤더 케이싱 규약**이 지문에 미치는 영향
|
||
- [ ] Playwright **신 헤드리스의 UA 에 `HeadlessChrome` 가 남는지**
|
||
- [ ] arXiv:2606.30119 이 평가한 **웹 에이전트 6종의 구체적 제품명과 탐지 정확도 수치**
|
||
- [ ] `puppeteer-extra-plugin-stealth` 의 **2025-02 deprecation 여부** (README 와 2차 자료가 상충)
|
||
- [ ] WAPPLES 가 **nedrug 앞단에 실제로 배치되어 있는지** (헤더로는 식별 불가)
|
||
- [ ] WAPPLES 차단 시 반환하는 **응답의 정확한 형태**(상태 코드/본문 문구) — §9.1 항목 E 의 시그니처 정확도를 높이기 위해 필요
|
||
|
||
### B.5 운영
|
||
|
||
- [ ] Windows 작업 스케줄러가 **재부팅 후 자동 복구**되는지 실측 (`/RU SYSTEM` + `/RL HIGHEST` + `/DELAY`)
|
||
- [ ] `agy -p` headless 호출이 **비대화형 세션에서 인증을 요구하지 않는지** 확인
|
||
- [ ] `data/state.sqlite3` 의 **WAL 파일이 백업/동기화 도구와 충돌하지 않는지** 확인
|
||
- [ ] Windows 토스트 알림이 **잠금화면/로그아웃 상태에서도 표시되는지** 확인 (안 되면 이메일 폴백)
|
||
- [ ] 서킷 브레이커 **수동 해제 CLI**(`dmf-crawler circuit reset <host>`) 구현
|
||
- [ ] `data/raw/` 3년 보관 시 **디스크 사용량 추정** (1일 약 2 MB × 365 × 3 ≈ 2.2 GB)
|
||
|
||
---
|
||
|
||
*문서 최종 갱신: 2026-09-02. 실측 데이터의 유효기간은 길지 않다. 사이트 구조·robots.txt·도구 버전은 구현 착수 시점에 §9.4 의 `tools/diagnose.py` 로 재확인할 것.*
|