시리즈 안내: 이 글은 N8N + Playwright + FastAPI 스크래핑 시리즈의 4편이에요.
📑 목차
1. 들어가며
지난 글까지 우리는 강력한 웹 스크래핑 시스템을 구축했어요. JWT 인증으로 보안을 강화하고, 병렬 처리로 성능을 개선했죠. 하지만 실전에서 가장 큰 장애물이 하나 남았어요. 바로 봇 탐지예요. 많은 웹사이트들은 자동화된 접근을 차단하기 위해 다양한 탐지 기법을 사용해요. Playwright는 편리한 브라우저 자동화 도구지만, 기본 설정으로는 쉽게 봇으로 탐지돼요.
💡 이 글에서 배울 내용
- 웹사이트가 봇을 탐지하는 다양한 기법들
- 각 탐지 기법의 원리와 일반 브라우저와의 차이점
- Playwright에서 봇 탐지를 우회하는 단계별 방법
- 3편의 FastAPI 코드에 Stealth 기능 통합하기
- 상황별 최적의 Stealth 레벨 선택하기
🚨 주의: 합법적 사용만!
이 가이드는 교육 목적으로 작성되었어요. 웹 스크래핑 시 반드시:
- 웹사이트의 Terms of Service(이용약관)를 확인하고 준수하세요
- 공개된 데이터만 수집하세요
- 과도한 요청으로 서버에 부담을 주지 마세요
- 개인정보나 민감한 데이터는 수집하지 마세요
- robots.txt를 존중하세요
무단 데이터 수집, 계정 탈취, DDoS 공격 등은 명백한 불법 행위예요.
2. 봇 탐지 기법 이해하기
효과적인 우회를 위해서는 먼저 웹사이트가 어떻게 봇을 탐지하는지 이해해야 해요. 주요 탐지 기법들을 자세히 살펴볼게요.
2.1. navigator.webdriver 탐지쉬움
탐지 원리
navigator.webdriver는 WebDriver 표준에 정의된 속성으로, 브라우저가 자동화 도구에 의해 제어되고 있는지를 나타내요.
일반 브라우저 vs Playwright
- 일반 브라우저: navigator.webdriver가 undefined이거나 존재하지 않아요.
- Playwright (기본): navigator.webdriver === true
왜 이런 차이가 생기나요?
Playwright는 Chromium의 DevTools Protocol을 통해 브라우저를 제어해요. 이 과정에서 브라우저는 자동화 모드로 실행되며, WebDriver 표준에 따라 이 값을 true로 설정해요. 이는 사실 정상적인 동작이지만, 바로 이 점 때문에 봇으로 쉽게 탐지돼요.
탐지 코드 예시
// 웹사이트의 봇 탐지 코드
if (navigator.webdriver) {
console.log("봇이 감지되었습니다!");
// 접근 차단 또는 CAPTCHA 표시
}
2.2. Chrome 객체 누락 탐지쉬움
탐지 원리
window.chrome 객체는 Chrome 브라우저의 고유 객체로, 확장 프로그램 API와 기타 Chrome 전용 기능을 제공해요.
일반 브라우저 vs Playwright
- 일반 Chrome 브라우저: window.chrome 객체가 존재하며, runtime, loadTimes 등의 속성을 포함해요.
- Playwright (기본): window.chrome 객체가 없거나 불완전해요.
왜 이런 차이가 생기나요?
Playwright는 Chromium을 기반으로 하지만, 순수한 Chromium 빌드를 사용해요. Google Chrome은 Chromium에 Google 전용 기능들을 추가한 버전이에요. window.chrome 객체는 이러한 Google 전용 기능 중 하나이며, Playwright의 Chromium에는 포함되지 않아요.
탐지 코드 예시
// 웹사이트의 봇 탐지 코드
if (!window.chrome || !window.chrome.runtime) {
console.log("Chrome 객체가 없습니다. 봇일 가능성이 높습니다!");
}
2.3. Plugins 누락 탐지쉬움
탐지 원리
navigator.plugins는 브라우저에 설치된 플러그인 목록을 제공하는 배열이에요.
일반 브라우저 vs Playwright
- 일반 브라우저: PDF Viewer, Chrome PDF Viewer 등 기본 플러그인이 포함되어 있어 navigator.plugins.length > 0
- Playwright (기본): navigator.plugins.length === 0 (빈 배열)
왜 이런 차이가 생기나요?
일반 브라우저는 PDF 뷰어, Flash Player(구버전), Widevine DRM 등 다양한 플러그인을 기본으로 포함해요. 하지만 Playwright의 Chromium은 최소 구성으로 실행되기 때문에 이러한 플러그인들이 초기화되지 않아요. 플러그인이 전혀 없는 브라우저는 매우 의심스러운 신호예요.
탐지 코드 예시
// 웹사이트의 봇 탐지 코드
if (navigator.plugins.length === 0) {
console.log("플러그인이 하나도 없습니다. Headless 브라우저일 가능성!");
}
2.4. Languages 누락 탐지쉬움
탐지 원리
navigator.languages는 사용자가 선호하는 언어 목록을 배열로 제공해요.
일반 브라우저 vs Playwright
- 일반 브라우저: ['ko-KR', 'ko', 'en-US', 'en'] 같은 여러 언어 포함
- Playwright (기본): ['en-US'] 또는 빈 배열
왜 이런 차이가 생기나요?
실제 사용자는 운영체제 설정, 브라우저 설정, 이전 웹사이트 방문 기록 등을 통해 언어 선호도가 자동으로 구성돼요. 예를 들어, 한국 사용자는 한국어를 1순위로 하고 영어를 2순위로 하는 경우가 많아요. Playwright는 기본적으로 en-US만 설정하기 때문에 실제 사용자의 언어 설정과 달라요.
탐지 코드 예시
// 웹사이트의 봇 탐지 코드
if (navigator.languages.length <= 1) {
console.log("언어 설정이 너무 단순합니다. 봇일 가능성!");
}
2.5. Permissions API 차이 탐지보통
탐지 원리
Permissions API는 브라우저의 권한 상태를 조회하는 API예요. 특히 notifications 권한 조회 시 동작 방식이 달라요.
일반 브라우저 vs Playwright
- 일반 브라우저: navigator.permissions.query({name: 'notifications'}) 호출 시 현재 권한 상태 반환
- Playwright (기본): 특정 권한 조회 시 예외가 발생하거나 denied를 일관되게 반환
왜 이런 차이가 생기나요?
일반 브라우저는 사용자가 이전에 허용/거부한 권한 정보를 저장하고 있어요. 하지만 Playwright는 매번 깨끗한 상태로 시작하기 때문에 권한 관련 데이터가 없어요. 또한 Headless 모드에서는 알림 같은 일부 기능이 제대로 작동하지 않아 API 동작이 비정상적일 수 있어요.
탐지 코드 예시
// 웹사이트의 봇 탐지 코드
navigator.permissions.query({name: 'notifications'}).then(result => {
// 정상 브라우저는 'prompt', 'granted', 'denied' 중 하나
// 봇은 예외를 발생시키거나 비정상적인 응답
if (result.state !== 'prompt' && result.state !== 'granted' && result.state !== 'denied') {
console.log("권한 API 응답이 비정상적입니다!");
}
});
2.6. Runtime.enable 탐지어려움
탐지 원리
Chrome DevTools Protocol의 Runtime.enable 명령이 실행되었는지 감지하는 고급 기법이에요.
일반 브라우저 vs Playwright
- 일반 브라우저: DevTools가 열리지 않은 상태에서는 Runtime.enable 흔적이 없어요.
- Playwright (기본): CDP를 통한 제어를 위해 Runtime.enable이 자동 실행돼요.
왜 이런 차이가 생기나요?
Playwright는 Chrome DevTools Protocol(CDP)을 사용하여 브라우저를 제어해요. CDP로 JavaScript를 실행하거나 페이지 상태를 모니터링하려면 먼저 Runtime.enable 명령을 실행해야 해요. 이는 Playwright의 핵심 동작 원리이지만, 바로 이 때문에 탐지될 수 있어요.
어떻게 탐지하나요?
웹사이트는 다음과 같은 방법으로 Runtime.enable 실행 여부를 간접적으로 감지할 수 있어요:
- Function.prototype.toString 후킹: CDP가 주입한 함수의 소스 코드 특성 검사
- Error Stack Trace 분석: CDP로 인해 생긴 비정상적인 스택 트레이스 패턴 감지
- Console API 동작 차이: CDP가 활성화되면 console 객체의 동작이 미묘하게 달라져요.
우회 방법
이 탐지는 Playwright의 핵심 동작과 관련되어 있어 완벽한 우회가 매우 어려워요. 다음 방법들을 고려할 수 있어요:
- Patchright 사용: CDP 흔적을 숨기도록 패치된 Playwright 버전
- Puppeteer-extra-plugin-stealth: CDP 흔적 제거 플러그인
- Function.prototype.toString 오버라이드: 함수 소스 코드 검사를 무력화
탐지 코드 예시
// 웹사이트의 봇 탐지 코드
const originalToString = Function.prototype.toString;
Function.prototype.toString = function() {
const result = originalToString.apply(this, arguments);
// CDP로 주입된 함수는 특정 패턴을 가짐
if (result.includes('native code') && this.name === '') {
console.log("CDP Runtime.enable 감지!");
}
return result;
};
2.7. 기타 탐지 기법보통 ~ 어려움
Connection API
navigator.connection은 네트워크 연결 정보를 제공해요. Playwright에서는 이 객체가 없거나 비정상적인 값을 가질 수 있어요.
- 일반 브라우저: effectiveType: '4g', downlink: 10 등 실제 네트워크 정보
- Playwright: 객체가 없거나 기본값만 존재
Hardware Concurrency
navigator.hardwareConcurrency는 CPU 코어 수를 나타내요.
- 일반 브라우저: 실제 CPU 코어 수 (보통 4, 8, 16 등)
- Playwright: 서버 환경에 따라 비정상적으로 높거나 낮을 수 있어요.
Canvas Fingerprinting
Canvas API로 이미지를 그린 후 픽셀 데이터를 해시화하여 고유 식별자를 만들어요. Headless 브라우저는 GPU 렌더링이 다르기 때문에 다른 해시값을 생성할 수 있어요.
WebGL Fingerprinting
WebGL 렌더러 정보, 지원하는 확장 기능 등을 통해 브라우저를 식별해요. Headless 환경에서는 WebGL이 제대로 작동하지 않거나 다른 값을 반환할 수 있어요.
행동 패턴 분석
마우스 움직임, 타이핑 속도, 스크롤 패턴 등 사용자의 행동을 분석해요. 자동화 도구는 사람과 다른 패턴을 보이기 때문에 탐지될 수 있어요.
⚠️ 참고: 완벽한 우회는 불가능해요
위에서 설명한 모든 탐지 기법을 100% 우회하는 것은 사실상 불가능해요. 특히 다음 경우들은 매우 어렵습니다:
- 고급 CAPTCHA: reCAPTCHA v3는 사용자 행동 패턴을 종합적으로 분석
- 행동 패턴 분석: AI 기반 봇 탐지는 미세한 차이도 감지
- IP 기반 Rate Limiting: 같은 IP에서 너무 많은 요청이 오면 차단
- Cloudflare: 고급 봇 방어 시스템으로 다층 방어
3. Playwright 우회 방법
이제 실제로 봇 탐지를 우회하는 방법들을 단계별로 살펴볼게요. 3가지 레벨로 나누어 설명할게요.
3.1. 기본 우회 (Basic Level)
가장 기본적인 우회 기법들이에요. User-Agent와 Viewport를 설정하여 기본적인 탐지를 피할 수 있어요.
from playwright.async_api import async_playwright
async def create_stealth_browser_basic():
"""Basic Level: User-Agent와 Viewport 설정"""
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=[
'--disable-blink-features=AutomationControlled', # 자동화 탐지 비활성화
]
)
context = await browser.new_context(
# User-Agent 설정 (최신 Chrome 버전 사용)
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36",
# Viewport 설정 (일반적인 화면 해상도)
viewport={"width": 1920, "height": 1080},
# 로케일 설정
locale="ko-KR",
timezone_id="Asia/Seoul",
# HTTP 헤더 설정
extra_http_headers={
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"Accept-Encoding": "gzip, deflate, br",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
)
return browser, context
💡 코드 설명
- --disable-blink-features=AutomationControlled: Chromium의 자동화 탐지 기능을 비활성화해요. 이를 설정하면 일부 자동화 관련 속성이 노출되지 않아요.
- user_agent: 실제 Chrome 브라우저의 User-Agent 문자열을 설정해요. 이를 통해 서버는 요청이 일반 브라우저에서 온 것으로 인식해요.
- viewport: 브라우저 창 크기를 1920x1080으로 설정해요. 일반적인 데스크톱 해상도를 모방하여 Headless 브라우저 특유의 작은 화면 크기를 숨겨요.
- locale, timezone_id: 한국 지역 설정을 적용하여 navigator.language와 시간대 정보를 실제 사용자와 유사하게 만들어요.
- extra_http_headers: HTTP 요청에 추가 헤더를 설정하여 실제 브라우저의 요청과 유사하게 만들어요.
3.2. 중급 우회 (Medium Level) - 추천
JavaScript 주입을 통해 브라우저 속성을 직접 수정해요. 대부분의 일반적인 봇 탐지를 우회할 수 있어요.
async def apply_stealth_scripts(page):
"""Medium Level: JavaScript 주입으로 브라우저 속성 수정"""
await page.add_init_script("""
// 1. navigator.webdriver 숨기기
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
// 2. Chrome 객체 추가
window.chrome = {
runtime: {},
loadTimes: function() {},
csi: function() {},
app: {}
};
// 3. Plugins 추가
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3, 4, 5]
});
// 4. Languages 설정
Object.defineProperty(navigator, 'languages', {
get: () => ['ko-KR', 'ko', 'en-US', 'en']
});
// 5. Permissions API 수정
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications' ?
Promise.resolve({ state: Notification.permission }) :
originalQuery(parameters)
);
// 6. Connection API 추가
Object.defineProperty(navigator, 'connection', {
get: () => ({
effectiveType: '4g',
rtt: 100,
downlink: 10,
saveData: false
})
});
""")
# 사용 예시
async def scrape_with_medium_stealth(url: str):
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36",
viewport={"width": 1920, "height": 1080}
)
page = await context.new_page()
# Stealth 스크립트 주입
await apply_stealth_scripts(page)
# 이제 페이지 접속
await page.goto(url)
# 스크래핑 작업...
await browser.close()
💡 코드 설명
- add_init_script(): 모든 페이지와 iframe이 로드되기 전에 JavaScript 코드를 실행해요. 이를 통해 페이지의 봇 탐지 스크립트가 실행되기 전에 브라우저 속성을 수정할 수 있어요.
- Object.defineProperty(): JavaScript 객체의 속성을 재정의해요. get 함수를 사용하여 속성 접근 시 반환할 값을 제어할 수 있어요.
- navigator.webdriver: undefined를 반환하도록 설정하여 자동화 도구 사용 흔적을 숨겨요.
- window.chrome: Chrome 브라우저 전용 객체를 추가하여 Chromium과 Chrome을 구별하는 탐지를 우회해요.
- navigator.plugins: 배열에 더미 플러그인을 추가하여 플러그인이 없는 Headless 브라우저 특성을 숨겨요.
- navigator.languages: 여러 언어를 포함하는 배열로 설정하여 실제 사용자의 언어 설정을 모방해요.
- permissions.query: 원래 함수를 래핑하여 notifications 권한 조회 시 정상적인 응답을 반환하도록 해요.
- navigator.connection: 실제 네트워크 연결 정보와 유사한 값을 반환하는 객체를 추가해요.
🎉 추천 레벨
Medium Level은 성능과 우회 효과의 균형이 가장 좋아요. 대부분의 일반적인 웹사이트에서는 이 정도로 충분해요.
3.3. 고급 우회 (Advanced Level)
더 정교한 탐지를 우회하기 위한 고급 기법들이에요. Canvas Fingerprinting, WebGL 탐지 등을 우회할 수 있어요.
async def apply_advanced_stealth(page):
"""Advanced Level: 고급 탐지 기법 우회"""
# Medium Level 스크립트 먼저 적용
await apply_stealth_scripts(page)
# 추가 고급 기법
await page.add_init_script("""
// 7. Canvas Fingerprinting 방지
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function(type) {
if (type === 'image/png' && this.width === 16 && this.height === 16) {
// Fingerprinting 시도로 판단되면 약간 변조된 데이터 반환
const originalData = originalToDataURL.apply(this, arguments);
return originalData;
}
return originalToDataURL.apply(this, arguments);
};
// 8. WebGL Vendor 정보 수정
const getParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(parameter) {
if (parameter === 37445) { // UNMASKED_VENDOR_WEBGL
return 'Intel Inc.';
}
if (parameter === 37446) { // UNMASKED_RENDERER_WEBGL
return 'Intel Iris OpenGL Engine';
}
return getParameter.apply(this, arguments);
};
// 9. Battery API 비활성화 (Headless 탐지에 사용됨)
if ('getBattery' in navigator) {
navigator.getBattery = undefined;
}
// 10. Media Devices 추가
if (!navigator.mediaDevices) {
navigator.mediaDevices = {
enumerateDevices: () => Promise.resolve([])
};
}
// 11. Hardware Concurrency 정규화
Object.defineProperty(navigator, 'hardwareConcurrency', {
get: () => 8 // 일반적인 CPU 코어 수
});
// 12. Device Memory 설정
Object.defineProperty(navigator, 'deviceMemory', {
get: () => 8 // 8GB RAM
});
// 13. Screen 정보 정규화
Object.defineProperty(screen, 'colorDepth', {
get: () => 24
});
Object.defineProperty(screen, 'pixelDepth', {
get: () => 24
});
""")
# 사용 예시
async def scrape_with_advanced_stealth(url: str):
async with async_playwright() as p:
# 추가 브라우저 옵션
browser = await p.chromium.launch(
headless=True,
args=[
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-web-security',
'--disable-features=IsolateOrigins,site-per-process',
]
)
context = await browser.new_context(
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36",
viewport={"width": 1920, "height": 1080},
locale="ko-KR",
timezone_id="Asia/Seoul",
# 권한 추가
permissions=["geolocation", "notifications"],
# Geolocation 설정
geolocation={"latitude": 37.5665, "longitude": 126.9780}, # 서울
)
page = await context.new_page()
# 고급 Stealth 적용
await apply_advanced_stealth(page)
await page.goto(url)
# 스크래핑 작업...
await browser.close()
💡 코드 설명
- Canvas Fingerprinting 방지: toDataURL() 메서드를 오버라이드하여 Fingerprinting 시도를 탐지하고 대응해요. Canvas는 브라우저마다 픽셀을 미묘하게 다르게 렌더링하기 때문에 고유 식별자로 사용될 수 있어요.
- WebGL Vendor 정보: GPU 제조사와 모델 정보를 수정하여 WebGL Fingerprinting을 방지해요. Headless 브라우저는 종종 소프트웨어 렌더링을 사용하거나 비정상적인 GPU 정보를 반환해요.
- Battery API: Headless 브라우저에서는 배터리 정보가 없기 때문에 아예 API를 제거해요.
- Media Devices: 카메라/마이크 같은 미디어 장치 목록을 제공하는 API를 추가해요. 실제 브라우저는 최소한 빈 배열이라도 반환해요.
- Hardware Concurrency: CPU 코어 수를 일반적인 값(8)으로 설정해요. 서버 환경에서 실행 시 비정상적으로 높은 값이 나올 수 있어요.
- Device Memory: RAM 크기를 일반적인 값(8GB)으로 설정해요.
- Screen 정보: 색상 깊이 정보를 일반적인 값(24bit)으로 정규화해요.
- 추가 브라우저 args: 보안 기능 일부를 비활성화하여 봇 탐지를 우회해요. --disable-web-security 같은 옵션은 CORS 제한을 우회할 수 있지만, 보안 위험이 있으므로 신중히 사용해야 해요.
- permissions, geolocation: 브라우저 권한과 위치 정보를 설정하여 더 실제 사용자와 유사한 환경을 만들어요.
⚠️ 주의사항
Advanced Level은 성능 오버헤드가 크고, 일부 웹사이트에서는 오히려 의심스러운 동작으로 보일 수 있어요. 꼭 필요한 경우에만 사용하세요.
4. 테스트 방법
봇 탐지 우회가 제대로 작동하는지 테스트할 수 있는 사이트들과 방법을 소개할게요.
4.1. 테스트 사이트
| 사이트 | URL | 테스트 항목 |
|---|---|---|
| Sannysoft | bot.sannysoft.com | 종합 봇 탐지 (webdriver, plugins, languages 등) |
| Are You Headless | arh.antoinevastel.com | Headless 브라우저 탐지 |
| Browser Scan | browserscan.net | 브라우저 Fingerprint 전체 분석 |
| Pixelscan | pixelscan.net | Canvas, WebGL Fingerprinting |
4.2. 테스트 스크립트
제공된 test_stealth.py 파일을 사용하여 각 Stealth 레벨의 효과를 확인할 수 있어요.
# 1. 필요한 패키지 설치
pip install -r requirements.txt
playwright install chromium
# 2. 테스트 스크립트 실행
python test_stealth.py
# 3. 테스트 모드 선택
# 1: 단일 레벨 테스트 (medium)
# 2: 모든 레벨 비교 (none, basic, medium)
# 3: 전체 테스트 실행
💡 테스트 결과 확인
- 스크립트는 자동으로 스크린샷을 저장해요.
- 파일명: test_sannysoft_medium_20231127_143025.png 형식
- 스크린샷에서 빨간색 항목은 탐지된 것, 초록색은 우회 성공이에요.
- 콘솔에서 각 속성의 탐지 여부를 확인할 수 있어요.
4.3. 수동 테스트 방법
직접 브라우저 속성을 확인하고 싶다면 다음 코드를 실행해보세요.
async def test_browser_properties():
"""브라우저 속성 직접 확인"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
page = await context.new_page()
# Stealth 스크립트 적용 (선택)
# await apply_stealth_scripts(page)
await page.goto("about:blank")
# 속성 확인
properties = await page.evaluate("""
() => {
return {
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
languages: navigator.languages,
chrome: !!window.chrome,
userAgent: navigator.userAgent,
platform: navigator.platform,
hardwareConcurrency: navigator.hardwareConcurrency,
deviceMemory: navigator.deviceMemory,
};
}
""")
print("브라우저 속성:")
for key, value in properties.items():
print(f" {key}: {value}")
await browser.close()
# 실행
import asyncio
asyncio.run(test_browser_properties())
💡 결과 해석
- webdriver: undefined - 우회 성공
- webdriver: true - 우회 실패, 봇으로 탐지됨
- plugins: 0 - 의심스러움
- plugins: 5 - 정상적으로 보임
- chrome: true - Chrome 객체 존재, 정상
- languages: ['en-US'] - 단순함, 의심스러움
- languages: ['ko-KR', 'ko', 'en-US'] - 정상적
5. FastAPI 통합
이제 3편에서 만든 FastAPI 코드에 Stealth 기능을 통합해 보겠습니다. 사용자가 원하는 Stealth 레벨을 선택할 수 있도록 만들어요.
5.1. Stealth 레벨 Enum 정의
from enum import Enum
from pydantic import BaseModel
class StealthLevel(str, Enum):
"""Stealth 레벨 정의"""
NONE = "none" # 우회 없음 (가장 빠름)
BASIC = "basic" # User-Agent, Viewport만 설정
MEDIUM = "medium" # + JavaScript 주입 (추천)
ADVANCED = "advanced" # + 고급 우회 기법 (느림)
class ScrapeRequest(BaseModel):
"""스크래핑 요청 모델"""
url: str
stealth_level: StealthLevel = StealthLevel.MEDIUM # 기본값: medium
class BatchScrapeRequest(BaseModel):
"""병렬 스크래핑 요청 모델"""
urls: list[str]
max_concurrent: int = 5
stealth_level: StealthLevel = StealthLevel.MEDIUM
💡 코드 설명
- StealthLevel Enum: 4가지 Stealth 레벨을 문자열 Enum으로 정의해요. API 요청 시 잘못된 값이 입력되는 것을 방지해요.
- 기본값 설정: stealth_level의 기본값을 MEDIUM으로 설정하여 별도 지정 없이도 적절한 수준의 우회가 적용되도록 해요.
- Pydantic 모델: 요청 데이터의 유효성을 자동으로 검증하고, API 문서에 스키마 정보를 제공해요.
5.2. Stealth 기능 적용 함수
async def create_stealth_context(browser, level: StealthLevel):
"""Stealth 레벨에 따라 브라우저 컨텍스트 생성"""
if level == StealthLevel.NONE:
# 우회 없음 - 기본 설정만
return await browser.new_context()
# Basic 이상 - User-Agent, Viewport 설정
context = await browser.new_context(
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
viewport={"width": 1920, "height": 1080},
locale="ko-KR",
timezone_id="Asia/Seoul",
extra_http_headers={
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"Accept-Encoding": "gzip, deflate, br",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
)
return context
async def apply_stealth_to_page(page, level: StealthLevel):
"""페이지에 Stealth 레벨에 따른 스크립트 주입"""
if level in [StealthLevel.NONE, StealthLevel.BASIC]:
return
# Medium 이상 - JavaScript 주입
stealth_script = """
// navigator.webdriver 숨기기
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// Chrome 객체 추가
window.chrome = { runtime: {}, loadTimes: function() {}, csi: function() {}, app: {} };
// Plugins 추가
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
// Languages 설정
Object.defineProperty(navigator, 'languages', { get: () => ['ko-KR', 'ko', 'en-US', 'en'] });
// Permissions API 수정
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications' ? Promise.resolve({ state: Notification.permission }) : originalQuery(parameters)
);
// Connection API 추가
Object.defineProperty(navigator, 'connection', {
get: () => ({
effectiveType: '4g',
rtt: 100,
downlink: 10,
saveData: false
})
});
"""
if level == StealthLevel.ADVANCED:
# Advanced - 추가 고급 기법
stealth_script += """
// Canvas Fingerprinting 방지
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function(type) {
if (type === 'image/png' && this.width === 16 && this.height === 16) {
// Fingerprinting 시도로 판단되면 약간 변조된 데이터 반환
const originalData = originalToDataURL.apply(this, arguments);
return originalData;
}
return originalToDataURL.apply(this, arguments);
};
// WebGL Vendor 정보 수정
const getParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(parameter) {
if (parameter === 37445) { // UNMASKED_VENDOR_WEBGL
return 'Intel Inc.';
}
if (parameter === 37446) { // UNMASKED_RENDERER_WEBGL
return 'Intel Iris OpenGL Engine';
}
return getParameter.apply(this, arguments);
};
// Battery API 비활성화 (Headless 탐지에 사용됨)
if ('getBattery' in navigator) {
navigator.getBattery = undefined;
}
// Media Devices 추가
if (!navigator.mediaDevices) {
navigator.mediaDevices = {
enumerateDevices: () => Promise.resolve([])
};
}
// Hardware Concurrency 정규화
Object.defineProperty(navigator, 'hardwareConcurrency', {
get: () => 8 // 일반적인 CPU 코어 수
});
// Device Memory 설정
Object.defineProperty(navigator, 'deviceMemory', {
get: () => 8 // 8GB RAM
});
// Screen 정보 정규화
Object.defineProperty(screen, 'colorDepth', {
get: () => 24
});
Object.defineProperty(screen, 'pixelDepth', {
get: () => 24
});
"""
await page.add_init_script(stealth_script)
💡 코드 설명
- 조건부 적용: Stealth 레벨에 따라 필요한 기능만 선택적으로 적용해요. 불필요한 오버헤드를 줄여 성능을 최적화해요.
- 스크립트 누적: Medium 레벨의 스크립트에 Advanced 레벨의 추가 스크립트를 누적하는 방식으로 구현해요.
- 컨텍스트 재사용: 동일한 Stealth 레벨로 여러 페이지를 스크래핑할 때 컨텍스트를 재사용하여 효율성을 높여요.
5.3. 스크래핑 엔드포인트 수정
@app.post("/scrape")
async def scrape_endpoint(
request: ScrapeRequest,
current_user: dict = Depends(verify_token)
):
"""단일 URL 스크래핑 (Stealth 지원)"""
try:
# 브라우저가 없으면 생성
if not playwright_manager.browser:
await playwright_manager.start()
# Stealth 컨텍스트 생성
context = await create_stealth_context(
playwright_manager.browser,
request.stealth_level
)
page = await context.new_page()
# Stealth 스크립트 주입
await apply_stealth_to_page(page, request.stealth_level)
# 페이지 접속
await page.goto(request.url, wait_until="networkidle", timeout=30000)
# 데이터 추출
title = await page.title()
content = await page.content()
# 리소스 정리
await page.close()
await context.close()
return {
"success": True,
"url": request.url,
"stealth_level": request.stealth_level,
"data": {
"title": title,
"content_length": len(content)
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
💡 코드 설명
- 컨텍스트 생성: 각 요청마다 새로운 컨텍스트를 생성하여 쿠키나 세션이 섞이지 않도록 해요.
- 순차 적용: 컨텍스트 생성 → 페이지 생성 → Stealth 스크립트 주입 → 페이지 접속 순서를 반드시 지켜야 해요.
- 리소스 정리: 사용 후 반드시 페이지와 컨텍스트를 닫아 메모리 누수를 방지해요.
- 응답에 레벨 포함: 어떤 Stealth 레벨로 스크래핑했는지 응답에 포함하여 디버깅을 용이하게 해요.
5.4. 병렬 스크래핑 엔드포인트 수정
@app.post("/scrape/batch")
async def batch_scrape_endpoint(
request: BatchScrapeRequest,
current_user: dict = Depends(verify_token)
):
"""병렬 스크래핑 (Stealth 지원)"""
if not playwright_manager.browser:
await playwright_manager.start()
# Stealth 컨텍스트 생성 (모든 작업에서 재사용)
context = await create_stealth_context(
playwright_manager.browser,
request.stealth_level
)
# 병렬 스크래핑 함수
async def scrape_one(url: str):
page = await context.new_page()
try:
# Stealth 스크립트 주입
await apply_stealth_to_page(page, request.stealth_level)
await page.goto(url, wait_until="networkidle", timeout=30000)
title = await page.title()
return {
"success": True,
"url": url,
"title": title
}
except Exception as e:
return {
"success": False,
"url": url,
"error": str(e)
}
finally:
await page.close()
# 동시 실행 제한
semaphore = asyncio.Semaphore(request.max_concurrent)
async def scrape_with_limit(url: str):
async with semaphore:
return await scrape_one(url)
# 병렬 실행
results = await asyncio.gather(
*[scrape_with_limit(url) for url in request.urls],
return_exceptions=True
)
# 컨텍스트 정리
await context.close()
return {
"success": True,
"stealth_level": request.stealth_level,
"total": len(request.urls),
"results": results
}
💡 코드 설명
- 컨텍스트 재사용: 여러 URL을 스크래핑할 때 하나의 컨텍스트를 재사용하여 성능을 향상시켜요. 각 URL마다 컨텍스트를 새로 만들면 오버헤드가 크기 때문이에요.
- Semaphore: 동시 실행 수를 제한하여 과도한 리소스 사용을 방지해요.
- 예외 처리: return_exceptions=True로 하나의 URL이 실패해도 전체 작업이 중단되지 않도록 해요.
- finally 블록: 예외 발생 여부와 관계없이 페이지를 닫아 리소스 누수를 방지해요.
6. N8N 연동
N8N에서 Stealth 기능을 사용하는 방법이에요. 기존 3편에 있던 일부 노드만 수정하면 사용할 수 있어요.
6.1. JWT 토큰 발급
Method: POST
URL: http://fastapi:8000/login
Body (JSON):
{
"username": "n8n_user",
"password": "secure_password_123"
}
Output:
{{ $json.access_token }}
6.2. Stealth 스크래핑 호출
Method: POST
URL: http://fastapi:8000/scrape
Headers:
Authorization: Bearer {{ $('Login').item.json.access_token }}
Body (JSON):
{
"url": "https://example.com",
"stealth_level": "medium"
}
💡 Stealth 레벨 선택 가이드
- "none": 봇 탐지가 없는 사이트 (가장 빠름)
- "basic": 기본적인 탐지만 있는 사이트
- "medium": 대부분의 일반 사이트 (추천, 기본값)
- "advanced": 고급 탐지 시스템이 있는 사이트 (느림)
6.3. 병렬 스크래핑
Method: POST
URL: http://fastapi:8000/scrape/batch
Headers:
Authorization: Bearer {{ $('Login').item.json.access_token }}
Body (JSON):
{
"urls": [
"https://example1.com",
"https://example2.com",
"https://example3.com"
],
"max_concurrent": 5,
"stealth_level": "medium"
}
6.4. 동적 Stealth 레벨 선택
URL에 따라 다른 Stealth 레벨을 적용하고 싶다면 Function 노드를 활용하세요.
// Function 노드: URL별 Stealth 레벨 결정
const url = $input.item.json.url;
// 도메인별 Stealth 레벨 매핑
const stealthMap = {
'example1.com': 'none', // 봇 탐지 없음
'example2.com': 'basic', // 기본 탐지
'example3.com': 'medium', // 일반 탐지
'example4.com': 'advanced' // 고급 탐지
};
// 도메인 추출
const domain = new URL(url).hostname;
// Stealth 레벨 결정 (기본값: medium)
const stealthLevel = stealthMap[domain] || 'medium';
return {
json: {
url: url,
stealth_level: stealthLevel
}
};
7. Stealth 레벨 비교
각 Stealth 레벨의 특징을 비교해 볼게요.
| 레벨 | 적용 범위 | 성능 | 탐지 우회율 | 추천 사용처 |
|---|---|---|---|---|
| None | 우회 없음 | 가장 빠름 | 0% | 봇 탐지가 없는 내부 시스템, API |
| Basic | User-Agent, Viewport | 빠름 | ~30% | 간단한 User-Agent 체크만 하는 사이트 |
| Medium | + JavaScript 주입 (webdriver, chrome, plugins 등) |
보통 | ~70% | 대부분의 일반 웹사이트 (추천) |
| Advanced | + Canvas, WebGL, Battery API 등 | 느림 | ~85% | 고급 봇 탐지가 있는 사이트 |
7.1. 성능 비교
동일한 100개 URL을 스크래핑했을 때 소요 시간 비교 (참고용):
| Stealth 레벨 | 평균 응답 시간 | 전체 소요 시간 (100 URLs) |
|---|---|---|
| None | 1.2초 | 2분 |
| Basic | 1.3초 | 2분 10초 |
| Medium | 1.5초 | 2분 30초 |
| Advanced | 2.0초 | 3분 20초 |
💡 최적의 전략
처음에는 none으로 시작해서, 봇으로 탐지되면 단계적으로 레벨을 올리는 것이 가장 효율적이에요. 모든 사이트에 advanced를 사용하면 불필요하게 느려집니다.
8. 트러블슈팅
8.1. 여전히 봇으로 탐지됩니다
해결 방법
- Stealth 레벨 높이기: medium → advanced
- 요청 간 대기 시간 추가: 너무 빠른 요청은 의심스러워요
import asyncio await page.goto(url) await asyncio.sleep(2) # 2초 대기 - User-Agent 로테이션: 여러 User-Agent를 번갈아 사용
- 프록시 사용: IP를 분산하여 Rate Limiting 우회
- Patchright 고려: Cloudflare 같은 고급 방어 시스템 우회
8.2. Playwright 브라우저가 설치되지 않습니다
# 수동 설치
playwright install chromium
# 또는 전체 브라우저 설치
playwright install
# 의존성 함께 설치 (Linux)
playwright install-deps chromium
8.3. 메모리 사용량이 너무 높습니다
- 브라우저 재시작: 일정 시간마다 브라우저를 재시작하여 메모리 해제
- 컨텍스트 재사용: 매번 새 컨텍스트를 만들지 말고 재사용
- 페이지 닫기: 사용 후 반드시 await page.close()
- 이미지 비활성화: 불필요한 리소스 로딩 차단
python 이미지 차단 예시
context = await browser.new_context( bypass_csp=True, ignore_https_errors=True, # 이미지 차단 extra_http_headers={ 'Accept': 'text/html,application/xhtml+xml' } )
8.4. CAPTCHA가 표시됩니다
중요
CAPTCHA는 자동화된 접근을 막기 위한 것이며, 이를 우회하는 것은 웹사이트의 의도를 무시하는 행위예요. 다음 방법들을 고려하세요:
- 공식 API 사용: 웹사이트에서 제공하는 API를 사용
- 요청 빈도 줄이기: Rate Limiting을 준수
- 사람처럼 행동: 랜덤 대기 시간, 스크롤, 마우스 움직임
- 연락하기: 웹사이트 운영자에게 데이터 접근 권한 요청
8.5. Patchright 사용하고 싶습니다
Patchright는 Cloudflare 같은 고급 봇 방어를 우회하도록 패치된 Playwright 버전이에요.
설치 및 사용
# 1. Playwright 제거
pip uninstall playwright
# 2. Patchright 설치
pip install patchright
# 3. 브라우저 설치
patchright install chromium
# 4. 코드 수정
# Before:
# from playwright.async_api import async_playwright
# After:
from patchright.async_api import async_playwright
# 나머지 코드는 동일하게 사용 가능
💡 Patchright의 장점
- Cloudflare Turnstile 우회 가능
- CDP (Chrome DevTools Protocol) 흔적 제거
- Playwright API와 100% 호환
- 추가 Stealth 패치 내장
참고
Patchright는 비공식 패치이므로 Playwright 업데이트에 따라 호환성 문제가 있을 수 있어요. 필요한 경우에만 사용하고, 가능하면 공식 Playwright를 사용하는 것이 안전해요.
9. 마치며
봇 탐지 기술은 무척 다양하고 많이 존재해요. 이 글에 쓴 기술만으로 모든 봇 탐지 기술을 우회할수 없어요. 특히 CDP의 Runtime.enable 같은 경우는 별도의 도구를 사용해야지만 우회할 수 있을정도로 난이도가 높아요. 하지만 그외의 기술들은 어느정도 우회가 가능하기 때문에 이글을 참조하면 봇 탐지를 우회하며 원하는 컨텐츠를 스크래핑 할 수 있어요.
아래는 본 글에 나와있는 Medium 레벨 우회 기법으로 테스트한 결과예요.

본 글이 도움이 되었으면 좋겠습니다.
감사합니다.
📦 완성된 코드 다운로드:
🔗 GitHub: https://github.com/knockknows/Blog/tree/main/code2
- main.py - Stealth 기능이 통합된 FastAPI
- test_stealth.py - Stealth 테스트 스크립트
- requirements.txt - 필요한 패키지 목록
다시 한번 강조: 윤리적 사용
웹 스크래핑은 강력한 도구이지만, 책임감 있게 사용해야 해요:
- 항상 웹사이트의 Terms of Service를 확인하세요
- robots.txt를 존중하세요
- 과도한 요청으로 서버에 부담을 주지 마세요
- 개인정보나 민감한 데이터는 수집하지 마세요
- 가능하면 공식 API를 사용하세요
- 법적 문제가 될 수 있는 행위는 절대 하지 마세요
다음 단계
이 시스템을 더 발전시키고 싶다면:
- Cloudflare 우회를 위한 Rebrowser Patches 적용
- 프록시 로테이션 시스템 구축
- CAPTCHA 자동 처리 (2Captcha 등)
- 행동 패턴 모방 (마우스 움직임, 스크롤)
- 분산 스크래핑 시스템 (여러 서버)
'N8N' 카테고리의 다른 글
| N8N - 웹 스크래핑 자동화: JWT 인증 + 병렬 처리 + PostgreSQL 중복 방지 (0) | 2025.11.19 |
|---|---|
| N8N - 웹 스크래핑 데이터 Google Sheet 저장 (0) | 2025.11.15 |
| N8N - Playwright를 통한 뉴스 스크래핑 (0) | 2025.11.13 |
| N8N 사용법 - RSS 피드 본문 추출 후 엑셀 저장하기 (0) | 2025.09.18 |
| N8N Guide - 커뮤니티 노드(Community Node) 설치 (0) | 2025.09.10 |