chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:12:13 +08:00
commit 0446c45d8e
898 changed files with 328024 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
"""
Unit tests for antibot_detector.is_blocked().
Tests are organized into:
- TRUE POSITIVES: Real block pages that MUST be detected
- TRUE NEGATIVES: Legitimate pages that MUST NOT be flagged
- EDGE CASES: Boundary conditions
"""
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
from crawl4ai.antibot_detector import is_blocked
PASS = 0
FAIL = 0
def check(name, result, expected_blocked, expected_substr=None):
global PASS, FAIL
blocked, reason = result
ok = blocked == expected_blocked
if expected_substr and blocked:
ok = ok and expected_substr.lower() in reason.lower()
status = "PASS" if ok else "FAIL"
if not ok:
FAIL += 1
print(f" {status}: {name}")
print(f" got blocked={blocked}, reason={reason!r}")
print(f" expected blocked={expected_blocked}" +
(f", substr={expected_substr!r}" if expected_substr else ""))
else:
PASS += 1
if blocked:
print(f" {status}: {name} -> {reason}")
else:
print(f" {status}: {name} -> not blocked")
# =========================================================================
# TRUE POSITIVES — real block pages that MUST be detected
# =========================================================================
print("\n=== TRUE POSITIVES (must detect as blocked) ===\n")
# --- Akamai ---
check("Akamai Reference #",
is_blocked(403, '<html><body>Access Denied\nYour request was blocked.\nReference #18.2d351ab8.1557333295.a4e16ab</body></html>'),
True, "Akamai")
check("Akamai Pardon Our Interruption",
is_blocked(403, '<html><head><title>Pardon Our Interruption</title></head><body><p>Please verify you are human</p></body></html>'),
True, "Pardon")
check("Akamai 403 short Access Denied",
is_blocked(403, '<html><body><h1>Access Denied</h1></body></html>'),
True) # Detected via near-empty 403 or Access Denied pattern
# --- Cloudflare ---
check("Cloudflare challenge form",
is_blocked(403, '''<html><body>
<form id="challenge-form" action="/cdn-cgi/l/chk_jschl?__cf_chl_f_tk=abc123">
<input type="hidden" name="jschl_vc" value="test"/>
</form></body></html>'''),
True, "Cloudflare challenge")
check("Cloudflare error 1020",
is_blocked(403, '''<html><body>
<div class="cf-wrapper"><span class="cf-error-code">1020</span></div>
<p>Access denied</p></body></html>'''),
True, "Cloudflare firewall")
check("Cloudflare IUAM script",
is_blocked(403, '<html><script src="/cdn-cgi/challenge-platform/h/g/orchestrate/jsch/v1"></script></html>'),
True, "Cloudflare JS challenge")
check("Cloudflare Just a moment",
is_blocked(403, '<html><head><title>Just a moment...</title></head><body>Checking your browser</body></html>'),
True) # Detected via near-empty 403 or Cloudflare pattern
check("Cloudflare Checking your browser (short 503)",
is_blocked(503, '<html><body>Checking your browser before accessing the site.</body></html>'),
True, "503")
# --- PerimeterX ---
check("PerimeterX block page",
is_blocked(403, '''<html><head><title>Access to This Page Has Been Blocked</title></head>
<body><div id="px-captcha"></div>
<script>window._pxAppId = 'PX12345';</script></body></html>'''),
True, "PerimeterX")
check("PerimeterX captcha CDN",
is_blocked(403, '<html><body><script src="https://captcha.px-cdn.net/PX12345/captcha.js"></script></body></html>'),
True, "PerimeterX captcha")
# --- DataDome ---
check("DataDome captcha delivery",
is_blocked(403, '''<html><body><script>
var dd = {'rt':'i','cid':'AHrlq...','host':'geo.captcha-delivery.com'};
</script></body></html>'''),
True, "DataDome")
# --- Imperva/Incapsula ---
check("Imperva Incapsula Resource",
is_blocked(403, '<html><body><iframe src="/_Incapsula_Resource?incident_id=123&sess_id=abc"></iframe></body></html>'),
True, "Imperva")
check("Imperva incident ID",
is_blocked(200, '<html><body>Request unsuccessful. Incapsula incident ID: 12345-67890</body></html>'),
True, "Incapsula incident")
# --- Sucuri ---
check("Sucuri firewall",
is_blocked(403, '<html><body><h1>Sucuri WebSite Firewall - Access Denied</h1></body></html>'),
True, "Sucuri")
# --- Kasada ---
check("Kasada challenge",
is_blocked(403, '<html><script>KPSDK.scriptStart = KPSDK.now();</script></html>'),
True, "Kasada")
# --- Reddit / Network Security ---
check("Reddit blocked by network security (small)",
is_blocked(403, '<html><body>You\'ve been blocked by network security.</body></html>'),
True, "Network security block")
check("Reddit blocked by network security (190KB SPA shell)",
is_blocked(403, '<html><body><style>' + 'x' * 180000 + '</style>' +
'You\'ve been blocked by network security. Log in to continue.</body></html>'),
True, "Network security block")
check("Network security block on HTTP 200 (buried in large page)",
is_blocked(200, '<html><body><style>' + 'a:b;' * 30000 + '</style>' +
'<p>blocked by network security</p></body></html>'),
True, "Network security block")
# --- HTTP 429 ---
check("HTTP 429 rate limit",
is_blocked(429, '<html><body>Rate limit exceeded</body></html>'),
True, "429")
check("HTTP 429 empty body",
is_blocked(429, ''),
True, "429")
# --- Empty 200 ---
check("HTTP 200 empty page",
is_blocked(200, ''),
True, "empty")
check("HTTP 200 whitespace only",
is_blocked(200, ' \n\n '),
True, "empty")
# --- 403 near-empty ---
check("HTTP 403 near-empty (10 bytes)",
is_blocked(403, '<html></html>'),
True, "403")
# =========================================================================
# TRUE NEGATIVES — legitimate pages that MUST NOT be flagged
# =========================================================================
print("\n=== TRUE NEGATIVES (must NOT detect as blocked) ===\n")
# --- Normal pages ---
check("Normal 200 page (example.com size)",
is_blocked(200, '<html><head><title>Example</title></head><body><p>' + 'x' * 500 + '</p></body></html>'),
False)
check("Normal 200 large page",
is_blocked(200, '<html><body>' + '<p>Some content here.</p>\n' * 5000 + '</body></html>'),
False)
# --- Security articles (false positive trap!) ---
check("Article about bot detection (large page)",
is_blocked(200, '<html><head><title>How to Detect Bots</title></head><body>' +
'<h1>How to Detect Bots on Your Website</h1>' +
'<p>Anti-bot solutions like DataDome, PerimeterX, and Cloudflare ' +
'help detect and block bot traffic. When a bot is detected, ' +
'services show a CAPTCHA or Access Denied page. ' +
'Common signals include blocked by security warnings.</p>' +
'<p>The g-recaptcha and h-captcha widgets are used for challenges.</p>' +
'<p>' + 'More article content. ' * 500 + '</p>' +
'</body></html>'),
False)
check("DataDome marketing page (large)",
is_blocked(200, '<html><body><h1>DataDome Bot Protection</h1>' +
'<p>DataDome protects websites from bot attacks. ' +
'Our solution detects automated traffic using advanced fingerprinting. ' +
'Competitors like PerimeterX use window._pxAppId for tracking.</p>' +
'<p>' + 'Marketing content. ' * 1000 + '</p>' +
'</body></html>'),
False)
# --- Login pages with CAPTCHA (not a block!) ---
check("Login page with reCAPTCHA (large page)",
is_blocked(200, '<html><head><title>Sign In</title></head><body>' +
'<nav>Home | Products | Contact</nav>' +
'<form action="/login" method="POST">' +
'<input name="email" type="email"/>' +
'<input name="password" type="password"/>' +
'<div class="g-recaptcha" data-sitekey="abc123"></div>' +
'<button type="submit">Sign In</button>' +
'</form>' +
'<footer>Copyright 2024</footer>' +
'<p>' + 'Page content. ' * 500 + '</p>' +
'</body></html>'),
False)
check("Signup page with hCaptcha (large page)",
is_blocked(200, '<html><body>' +
'<h1>Create Account</h1>' +
'<form><div class="h-captcha" data-sitekey="xyz"></div></form>' +
'<p>' + 'Registration info. ' * 500 + '</p>' +
'</body></html>'),
False)
# --- 403 pages — ALL non-data 403 HTML is now treated as blocked ---
# Rationale: 403 is never the content the user wants. Even for legitimate
# auth errors (Apache/Nginx), the fallback will also get 403 and we report
# failure correctly. False positives are cheap; false negatives are catastrophic.
check("Apache directory listing denied (403, large-ish)",
is_blocked(403, '<html><head><title>403 Forbidden</title></head><body>' +
'<h1>Forbidden</h1>' +
'<p>You don\'t have permission to access this resource on this server.</p>' +
'<hr><address>Apache/2.4.41 (Ubuntu) Server at example.com Port 80</address>' +
'<p>' + 'Server info. ' * 500 + '</p>' +
'</body></html>'),
True, "403")
check("Nginx 403 (large page)",
is_blocked(403, '<html><head><title>403 Forbidden</title></head><body>' +
'<center><h1>403 Forbidden</h1></center>' +
'<hr><center>nginx/1.18.0</center>' +
'<p>' + 'Content. ' * 500 + '</p>' +
'</body></html>'),
True, "403")
check("API 403 auth required (JSON)",
is_blocked(403, '{"error": "Forbidden", "message": "Invalid API key", "code": 403}'),
False)
# --- Cloudflare-served normal pages (not blocked!) ---
check("Cloudflare-served normal page with footer",
is_blocked(200, '<html><body>' +
'<h1>Welcome to Our Site</h1>' +
'<p>This is a normal page served through Cloudflare CDN.</p>' +
'<footer>Performance & security by Cloudflare</footer>' +
'<p>' + 'Normal content. ' * 500 + '</p>' +
'</body></html>'),
False)
# --- Small but legitimate pages ---
check("Small valid 200 page (with content element)",
is_blocked(200, '<html><head><title>OK</title></head><body><p>Your request was processed successfully. Everything is fine.</p></body></html>'),
False)
check("Small JSON 200 response",
is_blocked(200, '{"status": "ok", "data": {"id": 123, "name": "test"}, "timestamp": "2024-01-01T00:00:00Z"}'),
False)
check("Redirect page 200",
is_blocked(200, '<html><head><meta http-equiv="refresh" content="0;url=/dashboard"></head><body><p>Redirecting to your dashboard. Please wait while we prepare your personalized experience.</p></body></html>'),
False)
# --- 503 pages — ALL non-data 503 HTML is now treated as blocked ---
# Same rationale as 403: 503 is never desired content. Fallback rescues false positives.
check("503 maintenance page (treated as blocked)",
is_blocked(503, '<html><body><h1>Service Temporarily Unavailable</h1>' +
'<p>We are performing scheduled maintenance. Please try again later.</p>' +
'<p>' + 'Maintenance info. ' * 500 + '</p>' +
'</body></html>'),
True, "503")
# --- 200 with short but real content ---
check("Short thank you page (200, 120 bytes)",
is_blocked(200, '<html><body><h1>Thank You!</h1><p>Your order has been placed. Confirmation email sent.</p></body></html>'),
False)
# =========================================================================
# EDGE CASES
# =========================================================================
print("\n=== EDGE CASES ===\n")
check("None status code + empty html",
is_blocked(None, ''),
True, "no <body>")
check("None status code + block content",
is_blocked(None, '<html><body>Reference #18.2d351ab8.1557333295.a4e16ab</body></html>'),
True, "Akamai")
check("200 + tier1 pattern (Imperva deceptive 200)",
is_blocked(200, '<html><body>Request unsuccessful. Incapsula incident ID: 555-999</body></html>'),
True, "Incapsula")
check("403 + 4999 bytes (just under threshold)",
is_blocked(403, '<html><body>Access Denied' + 'x' * 4950 + '</body></html>'),
True, "Access Denied")
check("403 + 5001 bytes (over old threshold, now blocked)",
is_blocked(403, '<html><body>Some error page' + 'x' * 4960 + '</body></html>'),
True, "403")
check("403 + 9999 bytes with generic block text",
is_blocked(403, '<html><body>blocked by security' + 'x' * 9950 + '</body></html>'),
True, "Blocked by security")
check("403 + 10001 bytes with generic block text (now detected regardless of size)",
is_blocked(403, '<html><body>blocked by security' + 'x' * 9970 + '</body></html>'),
True, "Blocked by security")
check("200 + whitespace-padded but 89 bytes content (above threshold for meaningful)",
is_blocked(200, ' ' * 10 + 'x' * 89 + ' ' * 10),
True, "empty")
check("200 + exactly 100 bytes stripped (at threshold, no body = structural fail)",
is_blocked(200, 'x' * 100),
True, "no <body>")
# =========================================================================
# SUMMARY
# =========================================================================
print(f"\n{'=' * 60}")
print(f"RESULTS: {PASS} passed, {FAIL} failed out of {PASS + FAIL} tests")
print(f"{'=' * 60}")
if FAIL > 0:
print("SOME TESTS FAILED!")
sys.exit(1)
else:
print("ALL TESTS PASSED!")
+112
View File
@@ -0,0 +1,112 @@
"""
Test: Chanel.com anti-bot bypass via crawl4ai
Requires env vars:
MASSIVE_USERNAME — Massive residential proxy username
MASSIVE_PASSWORD — Massive residential proxy password
Optional:
--cdp URL Connect to external browser via CDP (e.g. http://localhost:9223)
--attempts N Number of attempts per test (default 3)
Usage:
export MASSIVE_USERNAME="your_user"
export MASSIVE_PASSWORD="your_pass"
.venv/bin/python tests/proxy/test_chanel_cdp_proxy.py
.venv/bin/python tests/proxy/test_chanel_cdp_proxy.py --cdp http://localhost:9223
"""
import asyncio
import os
import sys
import re
import tempfile
import shutil
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
from crawl4ai.async_configs import ProxyConfig
URL = "https://www.chanel.com/us/fashion/handbags/c/1x1x1/"
MASSIVE_USERNAME = os.environ.get("MASSIVE_USERNAME", "")
MASSIVE_PASSWORD = os.environ.get("MASSIVE_PASSWORD", "")
MASSIVE_SERVER = "https://network.joinmassive.com:65535"
def get_proxy_config():
if not MASSIVE_USERNAME or not MASSIVE_PASSWORD:
print("ERROR: Set MASSIVE_USERNAME and MASSIVE_PASSWORD env vars")
sys.exit(1)
return ProxyConfig(
server=MASSIVE_SERVER,
username=MASSIVE_USERNAME,
password=MASSIVE_PASSWORD,
)
async def test_isolated_context(cdp_url: str = None, attempts: int = 3):
"""Test with isolated context (works with both Playwright and CDP)."""
mode = f"CDP ({cdp_url})" if cdp_url else "Playwright Chromium"
print(f"\n{'='*60}")
print(f"Mode: Isolated context — {mode}")
print(f"{'='*60}\n")
kwargs = dict(
enable_stealth=True,
create_isolated_context=True,
viewport_width=1920,
viewport_height=1080,
)
if cdp_url:
kwargs["cdp_url"] = cdp_url
else:
kwargs["headless"] = True
config = BrowserConfig(**kwargs)
run_config = CrawlerRunConfig(
magic=True,
simulate_user=True,
override_navigator=True,
proxy_config=get_proxy_config(),
page_timeout=120000,
wait_until="load",
delay_before_return_html=15.0,
)
passed = 0
async with AsyncWebCrawler(config=config) as crawler:
for i in range(attempts):
result = await crawler.arun(URL, config=run_config)
ok = result.status_code == 200 and len(result.html) > 10000
title = ""
if ok:
passed += 1
m = re.search(r"<title>(.*?)</title>", result.html)
title = f" title={m.group(1)}" if m else ""
print(f" Attempt {i+1}: status={result.status_code} html={len(result.html):>10,} bytes {'PASS' if ok else 'FAIL'}{title}")
print(f"\nResult: {passed}/{attempts} passed")
return passed > 0
async def main():
cdp_url = None
attempts = 3
args = sys.argv[1:]
for j, arg in enumerate(args):
if arg == "--cdp" and j + 1 < len(args):
cdp_url = args[j + 1]
if arg == "--attempts" and j + 1 < len(args):
attempts = int(args[j + 1])
ok = await test_isolated_context(cdp_url=cdp_url, attempts=attempts)
print(f"\n{'='*60}")
print(f"Result: {'PASS' if ok else 'FAIL'}")
print(f"{'='*60}")
return ok
if __name__ == "__main__":
ok = asyncio.run(main())
sys.exit(0 if ok else 1)
+68
View File
@@ -0,0 +1,68 @@
import asyncio
import os
import shutil
import uuid
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
from crawl4ai.async_configs import ProxyConfig
async def crawl_chanel(url: str):
profile_dir = os.path.expanduser(f"~/.crawl4ai/chanel_{uuid.uuid4().hex[:8]}")
os.makedirs(profile_dir, exist_ok=True)
browser_config = BrowserConfig(
headless=True,
enable_stealth=True,
use_persistent_context=True,
user_data_dir=profile_dir,
viewport_width=1920,
viewport_height=1080,
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
},
proxy_config=ProxyConfig(
server="https://network.joinmassive.com:65535",
username="mpuQHs4sWZ-country-US",
password="D0yWxVQo8wQ05RWqz1Bn",
),
)
run_config = CrawlerRunConfig(
magic=True,
simulate_user=True,
override_navigator=True,
page_timeout=120000,
wait_until="load",
delay_before_return_html=10.0,
)
try:
async with AsyncWebCrawler(config=browser_config) as crawler:
result = await crawler.arun(url, config=run_config)
return result
finally:
shutil.rmtree(profile_dir, ignore_errors=True)
async def main():
url = "https://www.chanel.com/us/fashion/handbags/c/1x1x1/"
result = await crawl_chanel(url)
print(f"Status: {result.status_code}")
print(f"Success: {result.success}")
print(f"HTML: {len(result.html):,} bytes")
if result.markdown:
md_len = len(result.markdown.raw_markdown)
print(f"Markdown: {md_len:,} chars")
if md_len > 500:
print(f"\nFirst 500 chars of markdown:\n{result.markdown.raw_markdown[:500]}")
if result.error_message:
print(f"Error: {result.error_message}")
asyncio.run(main())
+582
View File
@@ -0,0 +1,582 @@
"""
Comprehensive test suite for ProxyConfig in different forms:
1. String form (ip:port:username:password)
2. Dict form (dictionary with keys)
3. Object form (ProxyConfig instance)
4. Environment variable form (from env vars)
Tests cover all possible scenarios and edge cases using pytest.
"""
import asyncio
import os
import pytest
import tempfile
from unittest.mock import patch
from crawl4ai import AsyncWebCrawler, BrowserConfig
from crawl4ai.async_configs import CrawlerRunConfig, ProxyConfig
from crawl4ai.cache_context import CacheMode
class TestProxyConfig:
"""Comprehensive test suite for ProxyConfig functionality."""
# Test data for different scenarios
# get free proxy server from from webshare.io https://www.webshare.io/?referral_code=3sqog0y1fvsl
TEST_PROXY_DATA = {
"server": "",
"username": "",
"password": "",
"ip": ""
}
def setup_method(self):
"""Setup for each test method."""
self.test_url = "https://httpbin.org/ip" # Use httpbin for testing
# ==================== OBJECT FORM TESTS ====================
def test_proxy_config_object_creation_basic(self):
"""Test basic ProxyConfig object creation."""
proxy = ProxyConfig(server="127.0.0.1:8080")
assert proxy.server == "127.0.0.1:8080"
assert proxy.username is None
assert proxy.password is None
assert proxy.ip == "127.0.0.1" # Should auto-extract IP
def test_proxy_config_object_creation_full(self):
"""Test ProxyConfig object creation with all parameters."""
proxy = ProxyConfig(
server=f"http://{self.TEST_PROXY_DATA['server']}",
username=self.TEST_PROXY_DATA['username'],
password=self.TEST_PROXY_DATA['password'],
ip=self.TEST_PROXY_DATA['ip']
)
assert proxy.server == f"http://{self.TEST_PROXY_DATA['server']}"
assert proxy.username == self.TEST_PROXY_DATA['username']
assert proxy.password == self.TEST_PROXY_DATA['password']
assert proxy.ip == self.TEST_PROXY_DATA['ip']
def test_proxy_config_object_ip_extraction(self):
"""Test automatic IP extraction from server URL."""
test_cases = [
("http://192.168.1.1:8080", "192.168.1.1"),
("https://10.0.0.1:3128", "10.0.0.1"),
("192.168.1.100:8080", "192.168.1.100"),
("proxy.example.com:8080", "proxy.example.com"),
]
for server, expected_ip in test_cases:
proxy = ProxyConfig(server=server)
assert proxy.ip == expected_ip, f"Failed for server: {server}"
def test_proxy_config_object_invalid_server(self):
"""Test ProxyConfig with invalid server formats."""
# Should not raise exception but may not extract IP properly
proxy = ProxyConfig(server="invalid-format")
assert proxy.server == "invalid-format"
# IP extraction might fail but object should still be created
# ==================== DICT FORM TESTS ====================
def test_proxy_config_from_dict_basic(self):
"""Test creating ProxyConfig from basic dictionary."""
proxy_dict = {"server": "127.0.0.1:8080"}
proxy = ProxyConfig.from_dict(proxy_dict)
assert proxy.server == "127.0.0.1:8080"
assert proxy.username is None
assert proxy.password is None
def test_proxy_config_from_dict_full(self):
"""Test creating ProxyConfig from complete dictionary."""
proxy_dict = {
"server": f"http://{self.TEST_PROXY_DATA['server']}",
"username": self.TEST_PROXY_DATA['username'],
"password": self.TEST_PROXY_DATA['password'],
"ip": self.TEST_PROXY_DATA['ip']
}
proxy = ProxyConfig.from_dict(proxy_dict)
assert proxy.server == proxy_dict["server"]
assert proxy.username == proxy_dict["username"]
assert proxy.password == proxy_dict["password"]
assert proxy.ip == proxy_dict["ip"]
def test_proxy_config_from_dict_missing_keys(self):
"""Test creating ProxyConfig from dictionary with missing keys."""
proxy_dict = {"server": "127.0.0.1:8080", "username": "user"}
proxy = ProxyConfig.from_dict(proxy_dict)
assert proxy.server == "127.0.0.1:8080"
assert proxy.username == "user"
assert proxy.password is None
assert proxy.ip == "127.0.0.1" # Should auto-extract
def test_proxy_config_from_dict_empty(self):
"""Test creating ProxyConfig from empty dictionary."""
proxy_dict = {}
proxy = ProxyConfig.from_dict(proxy_dict)
assert proxy.server is None
assert proxy.username is None
assert proxy.password is None
assert proxy.ip is None
def test_proxy_config_from_dict_none_values(self):
"""Test creating ProxyConfig from dictionary with None values."""
proxy_dict = {
"server": "127.0.0.1:8080",
"username": None,
"password": None,
"ip": None
}
proxy = ProxyConfig.from_dict(proxy_dict)
assert proxy.server == "127.0.0.1:8080"
assert proxy.username is None
assert proxy.password is None
assert proxy.ip == "127.0.0.1" # Should auto-extract despite None
# ==================== STRING FORM TESTS ====================
def test_proxy_config_from_string_full_format(self):
"""Test creating ProxyConfig from full string format (ip:port:username:password)."""
proxy_str = f"{self.TEST_PROXY_DATA['ip']}:6114:{self.TEST_PROXY_DATA['username']}:{self.TEST_PROXY_DATA['password']}"
proxy = ProxyConfig.from_string(proxy_str)
assert proxy.server == f"http://{self.TEST_PROXY_DATA['ip']}:6114"
assert proxy.username == self.TEST_PROXY_DATA['username']
assert proxy.password == self.TEST_PROXY_DATA['password']
assert proxy.ip == self.TEST_PROXY_DATA['ip']
def test_proxy_config_from_string_ip_port_only(self):
"""Test creating ProxyConfig from string with only ip:port."""
proxy_str = "192.168.1.1:8080"
proxy = ProxyConfig.from_string(proxy_str)
assert proxy.server == "http://192.168.1.1:8080"
assert proxy.username is None
assert proxy.password is None
assert proxy.ip == "192.168.1.1"
def test_proxy_config_from_string_invalid_format(self):
"""Test creating ProxyConfig from invalid string formats."""
invalid_formats = [
"invalid",
"ip:port:user", # Missing password (3 parts)
"ip:port:user:pass:extra", # Too many parts (5 parts)
"",
"::", # Empty parts but 3 total (invalid)
"::::", # Empty parts but 5 total (invalid)
]
for proxy_str in invalid_formats:
with pytest.raises(ValueError, match="Invalid proxy string format"):
ProxyConfig.from_string(proxy_str)
def test_proxy_config_from_string_edge_cases_that_work(self):
"""Test string formats that should work but might be edge cases."""
# These cases actually work as valid formats
edge_cases = [
(":", "http://:", ""), # ip:port format with empty values
(":::", "http://:", ""), # ip:port:user:pass format with empty values
]
for proxy_str, expected_server, expected_ip in edge_cases:
proxy = ProxyConfig.from_string(proxy_str)
assert proxy.server == expected_server
assert proxy.ip == expected_ip
def test_proxy_config_from_string_edge_cases(self):
"""Test string parsing edge cases."""
# Test with different port numbers
proxy_str = "10.0.0.1:3128:user:pass"
proxy = ProxyConfig.from_string(proxy_str)
assert proxy.server == "http://10.0.0.1:3128"
# Test with special characters in credentials
proxy_str = "10.0.0.1:8080:user@domain:pass:word"
with pytest.raises(ValueError): # Should fail due to extra colon in password
ProxyConfig.from_string(proxy_str)
# ==================== ENVIRONMENT VARIABLE TESTS ====================
def test_proxy_config_from_env_single_proxy(self):
"""Test loading single proxy from environment variable."""
proxy_str = f"{self.TEST_PROXY_DATA['ip']}:6114:{self.TEST_PROXY_DATA['username']}:{self.TEST_PROXY_DATA['password']}"
with patch.dict(os.environ, {'TEST_PROXIES': proxy_str}):
proxies = ProxyConfig.from_env('TEST_PROXIES')
assert len(proxies) == 1
proxy = proxies[0]
assert proxy.ip == self.TEST_PROXY_DATA['ip']
assert proxy.username == self.TEST_PROXY_DATA['username']
assert proxy.password == self.TEST_PROXY_DATA['password']
def test_proxy_config_from_env_multiple_proxies(self):
"""Test loading multiple proxies from environment variable."""
proxy_list = [
"192.168.1.1:8080:user1:pass1",
"192.168.1.2:8080:user2:pass2",
"10.0.0.1:3128" # No auth
]
proxy_str = ",".join(proxy_list)
with patch.dict(os.environ, {'TEST_PROXIES': proxy_str}):
proxies = ProxyConfig.from_env('TEST_PROXIES')
assert len(proxies) == 3
# Check first proxy
assert proxies[0].ip == "192.168.1.1"
assert proxies[0].username == "user1"
assert proxies[0].password == "pass1"
# Check second proxy
assert proxies[1].ip == "192.168.1.2"
assert proxies[1].username == "user2"
assert proxies[1].password == "pass2"
# Check third proxy (no auth)
assert proxies[2].ip == "10.0.0.1"
assert proxies[2].username is None
assert proxies[2].password is None
def test_proxy_config_from_env_empty_var(self):
"""Test loading from empty environment variable."""
with patch.dict(os.environ, {'TEST_PROXIES': ''}):
proxies = ProxyConfig.from_env('TEST_PROXIES')
assert len(proxies) == 0
def test_proxy_config_from_env_missing_var(self):
"""Test loading from missing environment variable."""
# Ensure the env var doesn't exist
with patch.dict(os.environ, {}, clear=True):
proxies = ProxyConfig.from_env('NON_EXISTENT_VAR')
assert len(proxies) == 0
def test_proxy_config_from_env_with_empty_entries(self):
"""Test loading proxies with empty entries in the list."""
proxy_str = "192.168.1.1:8080:user:pass,,10.0.0.1:3128,"
with patch.dict(os.environ, {'TEST_PROXIES': proxy_str}):
proxies = ProxyConfig.from_env('TEST_PROXIES')
assert len(proxies) == 2 # Empty entries should be skipped
assert proxies[0].ip == "192.168.1.1"
assert proxies[1].ip == "10.0.0.1"
def test_proxy_config_from_env_with_invalid_entries(self):
"""Test loading proxies with some invalid entries."""
proxy_str = "192.168.1.1:8080:user:pass,invalid_proxy,10.0.0.1:3128"
with patch.dict(os.environ, {'TEST_PROXIES': proxy_str}):
# Should handle errors gracefully and return valid proxies
proxies = ProxyConfig.from_env('TEST_PROXIES')
# Depending on implementation, might return partial list or empty
# This tests error handling
assert isinstance(proxies, list)
# ==================== SERIALIZATION TESTS ====================
def test_proxy_config_to_dict(self):
"""Test converting ProxyConfig to dictionary."""
proxy = ProxyConfig(
server=f"http://{self.TEST_PROXY_DATA['server']}",
username=self.TEST_PROXY_DATA['username'],
password=self.TEST_PROXY_DATA['password'],
ip=self.TEST_PROXY_DATA['ip']
)
result_dict = proxy.to_dict()
expected = {
"server": f"http://{self.TEST_PROXY_DATA['server']}",
"username": self.TEST_PROXY_DATA['username'],
"password": self.TEST_PROXY_DATA['password'],
"ip": self.TEST_PROXY_DATA['ip']
}
assert result_dict == expected
def test_proxy_config_clone(self):
"""Test cloning ProxyConfig with modifications."""
original = ProxyConfig(
server="http://127.0.0.1:8080",
username="user",
password="pass"
)
# Clone with modifications
cloned = original.clone(username="new_user", password="new_pass")
# Original should be unchanged
assert original.username == "user"
assert original.password == "pass"
# Clone should have new values
assert cloned.username == "new_user"
assert cloned.password == "new_pass"
assert cloned.server == original.server # Unchanged value
def test_proxy_config_roundtrip_serialization(self):
"""Test that ProxyConfig can be serialized and deserialized without loss."""
original = ProxyConfig(
server=f"http://{self.TEST_PROXY_DATA['server']}",
username=self.TEST_PROXY_DATA['username'],
password=self.TEST_PROXY_DATA['password'],
ip=self.TEST_PROXY_DATA['ip']
)
# Serialize to dict and back
serialized = original.to_dict()
deserialized = ProxyConfig.from_dict(serialized)
assert deserialized.server == original.server
assert deserialized.username == original.username
assert deserialized.password == original.password
assert deserialized.ip == original.ip
# ==================== INTEGRATION TESTS ====================
@pytest.mark.asyncio
async def test_crawler_with_proxy_config_object(self):
"""Test AsyncWebCrawler with ProxyConfig object."""
proxy_config = ProxyConfig(
server=f"http://{self.TEST_PROXY_DATA['server']}",
username=self.TEST_PROXY_DATA['username'],
password=self.TEST_PROXY_DATA['password']
)
browser_config = BrowserConfig(headless=True)
# Test that the crawler accepts the ProxyConfig object without errors
async with AsyncWebCrawler(config=browser_config) as crawler:
try:
# Note: This might fail due to actual proxy connection, but should not fail due to config issues
result = await crawler.arun(
url=self.test_url,
config=CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
proxy_config=proxy_config,
page_timeout=10000 # Short timeout for testing
)
)
# If we get here, proxy config was accepted
assert result is not None
except Exception as e:
# We expect connection errors with test proxies, but not config errors
error_msg = str(e).lower()
assert "attribute" not in error_msg, f"Config error: {e}"
assert "proxy_config" not in error_msg, f"Proxy config error: {e}"
@pytest.mark.asyncio
async def test_crawler_with_proxy_config_dict(self):
"""Test AsyncWebCrawler with ProxyConfig from dictionary."""
proxy_dict = {
"server": f"http://{self.TEST_PROXY_DATA['server']}",
"username": self.TEST_PROXY_DATA['username'],
"password": self.TEST_PROXY_DATA['password']
}
proxy_config = ProxyConfig.from_dict(proxy_dict)
browser_config = BrowserConfig(headless=True)
async with AsyncWebCrawler(config=browser_config) as crawler:
try:
result = await crawler.arun(
url=self.test_url,
config=CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
proxy_config=proxy_config,
page_timeout=10000
)
)
assert result is not None
except Exception as e:
error_msg = str(e).lower()
assert "attribute" not in error_msg, f"Config error: {e}"
@pytest.mark.asyncio
async def test_crawler_with_proxy_config_from_string(self):
"""Test AsyncWebCrawler with ProxyConfig from string."""
proxy_str = f"{self.TEST_PROXY_DATA['ip']}:6114:{self.TEST_PROXY_DATA['username']}:{self.TEST_PROXY_DATA['password']}"
proxy_config = ProxyConfig.from_string(proxy_str)
browser_config = BrowserConfig(headless=True)
async with AsyncWebCrawler(config=browser_config) as crawler:
try:
result = await crawler.arun(
url=self.test_url,
config=CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
proxy_config=proxy_config,
page_timeout=10000
)
)
assert result is not None
except Exception as e:
error_msg = str(e).lower()
assert "attribute" not in error_msg, f"Config error: {e}"
# ==================== EDGE CASES AND ERROR HANDLING ====================
def test_proxy_config_with_none_server(self):
"""Test ProxyConfig behavior with None server."""
proxy = ProxyConfig(server=None)
assert proxy.server is None
assert proxy.ip is None # Should not crash
def test_proxy_config_with_empty_string_server(self):
"""Test ProxyConfig behavior with empty string server."""
proxy = ProxyConfig(server="")
assert proxy.server == ""
assert proxy.ip is None or proxy.ip == ""
def test_proxy_config_special_characters_in_credentials(self):
"""Test ProxyConfig with special characters in username/password."""
special_chars_tests = [
("user@domain.com", "pass!@#$%"),
("user_123", "p@ssw0rd"),
("user-test", "pass-word"),
]
for username, password in special_chars_tests:
proxy = ProxyConfig(
server="http://127.0.0.1:8080",
username=username,
password=password
)
assert proxy.username == username
assert proxy.password == password
def test_proxy_config_unicode_handling(self):
"""Test ProxyConfig with unicode characters."""
proxy = ProxyConfig(
server="http://127.0.0.1:8080",
username="ユーザー", # Japanese characters
password="пароль" # Cyrillic characters
)
assert proxy.username == "ユーザー"
assert proxy.password == "пароль"
# ==================== PERFORMANCE TESTS ====================
def test_proxy_config_creation_performance(self):
"""Test that ProxyConfig creation is reasonably fast."""
import time
start_time = time.time()
for i in range(1000):
proxy = ProxyConfig(
server=f"http://192.168.1.{i % 255}:8080",
username=f"user{i}",
password=f"pass{i}"
)
end_time = time.time()
# Should be able to create 1000 configs in less than 1 second
assert (end_time - start_time) < 1.0
def test_proxy_config_from_env_performance(self):
"""Test that loading many proxies from env is reasonably fast."""
import time
# Create a large list of proxy strings
proxy_list = [f"192.168.1.{i}:8080:user{i}:pass{i}" for i in range(100)]
proxy_str = ",".join(proxy_list)
with patch.dict(os.environ, {'PERF_TEST_PROXIES': proxy_str}):
start_time = time.time()
proxies = ProxyConfig.from_env('PERF_TEST_PROXIES')
end_time = time.time()
assert len(proxies) == 100
# Should be able to parse 100 proxies in less than 1 second
assert (end_time - start_time) < 1.0
# ==================== STANDALONE TEST FUNCTIONS ====================
@pytest.mark.asyncio
async def test_dict_proxy():
"""Original test function for dict proxy - kept for backward compatibility."""
proxy_config = {
"server": "23.95.150.145:6114",
"username": "cfyswbwn",
"password": "1gs266hoqysi"
}
proxy_config_obj = ProxyConfig.from_dict(proxy_config)
browser_config = BrowserConfig(headless=True)
async with AsyncWebCrawler(config=browser_config) as crawler:
try:
result = await crawler.arun(url="https://httpbin.org/ip", config=CrawlerRunConfig(
stream=False,
cache_mode=CacheMode.BYPASS,
proxy_config=proxy_config_obj,
page_timeout=10000
))
print("Dict proxy test passed!")
print(result.markdown[:200] if result and result.markdown else "No result")
except Exception as e:
print(f"Dict proxy test error (expected): {e}")
@pytest.mark.asyncio
async def test_string_proxy():
"""Test function for string proxy format."""
proxy_str = "23.95.150.145:6114:cfyswbwn:1gs266hoqysi"
proxy_config_obj = ProxyConfig.from_string(proxy_str)
browser_config = BrowserConfig(headless=True)
async with AsyncWebCrawler(config=browser_config) as crawler:
try:
result = await crawler.arun(url="https://httpbin.org/ip", config=CrawlerRunConfig(
stream=False,
cache_mode=CacheMode.BYPASS,
proxy_config=proxy_config_obj,
page_timeout=10000
))
print("String proxy test passed!")
print(result.markdown[:200] if result and result.markdown else "No result")
except Exception as e:
print(f"String proxy test error (expected): {e}")
@pytest.mark.asyncio
async def test_env_proxy():
"""Test function for environment variable proxy."""
# Set environment variable
os.environ['TEST_PROXIES'] = "23.95.150.145:6114:cfyswbwn:1gs266hoqysi"
proxies = ProxyConfig.from_env('TEST_PROXIES')
if proxies:
proxy_config_obj = proxies[0] # Use first proxy
browser_config = BrowserConfig(headless=True)
async with AsyncWebCrawler(config=browser_config) as crawler:
try:
result = await crawler.arun(url="https://httpbin.org/ip", config=CrawlerRunConfig(
stream=False,
cache_mode=CacheMode.BYPASS,
proxy_config=proxy_config_obj,
page_timeout=10000
))
print("Environment proxy test passed!")
print(result.markdown[:200] if result and result.markdown else "No result")
except Exception as e:
print(f"Environment proxy test error (expected): {e}")
else:
print("No proxies loaded from environment")
if __name__ == "__main__":
print("Running comprehensive ProxyConfig tests...")
print("=" * 50)
# Run the standalone test functions
print("\n1. Testing dict proxy format...")
asyncio.run(test_dict_proxy())
print("\n2. Testing string proxy format...")
asyncio.run(test_string_proxy())
print("\n3. Testing environment variable proxy format...")
asyncio.run(test_env_proxy())
print("\n" + "=" * 50)
print("To run the full pytest suite, use: pytest " + __file__)
print("=" * 50)
+42
View File
@@ -0,0 +1,42 @@
import warnings
import pytest
from crawl4ai.async_configs import BrowserConfig, ProxyConfig
def test_browser_config_proxy_string_emits_deprecation_and_autoconverts():
warnings.simplefilter("always", DeprecationWarning)
proxy_str = "23.95.150.145:6114:username:password"
with warnings.catch_warnings(record=True) as caught:
cfg = BrowserConfig(proxy=proxy_str, headless=True)
dep_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert dep_warnings, "Expected DeprecationWarning when using BrowserConfig(proxy=...)"
assert cfg.proxy is None, "cfg.proxy should be None after auto-conversion"
assert isinstance(cfg.proxy_config, ProxyConfig), "cfg.proxy_config should be ProxyConfig instance"
assert cfg.proxy_config.username == "username"
assert cfg.proxy_config.password == "password"
assert cfg.proxy_config.server.startswith("http://")
assert cfg.proxy_config.server.endswith(":6114")
def test_browser_config_with_proxy_config_emits_no_deprecation():
warnings.simplefilter("always", DeprecationWarning)
with warnings.catch_warnings(record=True) as caught:
cfg = BrowserConfig(
headless=True,
proxy_config={
"server": "http://127.0.0.1:8080",
"username": "u",
"password": "p",
},
)
dep_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert not dep_warnings, "Did not expect DeprecationWarning when using proxy_config"
assert cfg.proxy is None
assert isinstance(cfg.proxy_config, ProxyConfig)
+96
View File
@@ -0,0 +1,96 @@
"""Regression tests for proxy fix:
1. Persistent context + proxy (new path via launch_persistent_context)
2. Persistent context WITHOUT proxy (should still use launch_persistent_context)
3. Non-persistent + proxy on CrawlerRunConfig (existing path, must not break)
4. Non-persistent, no proxy (basic crawl, must not break)
"""
import asyncio
import os
import shutil
import uuid
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
from crawl4ai.async_configs import ProxyConfig
TEST_URL = "https://httpbin.org/ip" # Simple endpoint, returns IP
async def test(label, browser_config, run_config=None):
print(f"\n{'='*60}")
print(f"Test: {label}")
print(f"{'='*60}")
run_config = run_config or CrawlerRunConfig()
try:
async with AsyncWebCrawler(config=browser_config) as crawler:
result = await crawler.arun(TEST_URL, config=run_config)
print(f" Status: {result.status_code}")
print(f" HTML bytes: {len(result.html)}")
if result.markdown:
# httpbin.org/ip returns JSON with "origin" key
md = result.markdown.raw_markdown.strip()
print(f" Content: {md[:200]}")
if result.error_message:
print(f" ERROR: {result.error_message}")
return result
except Exception as e:
print(f" EXCEPTION: {e}")
return None
async def main():
proxy = ProxyConfig(
server="https://network.joinmassive.com:65535",
username="mpuQHs4sWZ-country-US",
password="D0yWxVQo8wQ05RWqz1Bn",
)
# 1. Persistent context + proxy (the fixed path)
pd = os.path.expanduser(f"~/.crawl4ai/test_{uuid.uuid4().hex[:8]}")
os.makedirs(pd, exist_ok=True)
try:
await test(
"Persistent + proxy (launch_persistent_context)",
BrowserConfig(
headless=True,
use_persistent_context=True,
user_data_dir=pd,
proxy_config=proxy,
),
)
finally:
shutil.rmtree(pd, ignore_errors=True)
# 2. Persistent context WITHOUT proxy
pd2 = os.path.expanduser(f"~/.crawl4ai/test_{uuid.uuid4().hex[:8]}")
os.makedirs(pd2, exist_ok=True)
try:
await test(
"Persistent, no proxy (launch_persistent_context)",
BrowserConfig(
headless=True,
use_persistent_context=True,
user_data_dir=pd2,
),
)
finally:
shutil.rmtree(pd2, ignore_errors=True)
# 3. Non-persistent + proxy on CrawlerRunConfig
await test(
"Non-persistent + proxy on RunConfig",
BrowserConfig(headless=True),
CrawlerRunConfig(
proxy_config=proxy,
),
)
# 4. Basic crawl - no proxy, no persistent
await test(
"Basic crawl (no proxy, no persistent)",
BrowserConfig(headless=True),
)
print("\n" + "="*60)
print("All regression tests complete.")
asyncio.run(main())
+109
View File
@@ -0,0 +1,109 @@
"""
Verify proxies are working and check what IPs they resolve to.
Then test Chanel through NST proxy (different provider).
"""
import requests
# Check our real IP
def check_ip(label, proxy=None):
print(f"\n--- {label} ---")
try:
kwargs = {"url": "https://httpbin.org/ip", "timeout": 15}
if proxy:
kwargs["proxies"] = {"https": proxy, "http": proxy}
resp = requests.get(**kwargs)
print(f" IP: {resp.json()}")
except Exception as e:
print(f" ERROR: {e}")
# Get NST proxy credentials
def get_nst_proxy(channel_id):
token = "NSTPROXY-DA9C7A614946EA8FCEFDA9FD3B3F4A9D"
api_url = f"https://api.nstproxy.com/api/v1/generate/apiproxies?count=1&country=US&protocol=http&sessionDuration=0&channelId={channel_id}&token={token}"
print(f"\nFetching NST proxy ({channel_id[:8]}...):")
print(f" URL: {api_url}")
try:
resp = requests.get(api_url, timeout=15)
print(f" HTTP {resp.status_code}")
print(f" Body: {resp.text[:500]}")
data = resp.json()
if data.get("code") == 200 and data.get("data"):
proxy_str = data["data"][0]
parts = proxy_str.split(":")
if len(parts) == 4:
ip, port, user, pwd = parts
proxy_url = f"http://{user}:{pwd}@{ip}:{port}"
print(f" Proxy URL: http://{user[:10]}...@{ip}:{port}")
return proxy_url
except Exception as e:
print(f" ERROR: {e}")
return None
# Test Chanel
def test_chanel(label, proxy=None, use_cffi=False):
url = "https://www.chanel.com/us/fashion/handbags/c/1x1x1/"
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
print(f"\n{'='*60}")
print(f"TEST: {label}")
try:
if use_cffi:
from curl_cffi import requests as cffi_requests
kwargs = {"url": url, "headers": headers, "impersonate": "chrome", "timeout": 30, "allow_redirects": True}
if proxy:
kwargs["proxies"] = {"https": proxy, "http": proxy}
resp = cffi_requests.get(**kwargs)
else:
kwargs = {"url": url, "headers": headers, "timeout": 30, "allow_redirects": True}
if proxy:
kwargs["proxies"] = {"https": proxy, "http": proxy}
resp = requests.get(**kwargs)
blocked = "Access Denied" in resp.text
print(f" Status: {resp.status_code}")
print(f" Size: {len(resp.text):,} bytes")
print(f" Result: {'BLOCKED' if blocked else 'SUCCESS' if resp.status_code == 200 and len(resp.text) > 10000 else 'UNCLEAR'}")
if not blocked and resp.status_code == 200:
print(f" First 300 chars: {resp.text[:300]}")
except Exception as e:
print(f" ERROR: {e}")
if __name__ == "__main__":
MASSIVE_RES = "https://mpuQHs4sWZ-country-US:D0yWxVQo8wQ05RWqz1Bn@network.joinmassive.com:65535"
MASSIVE_DC = "http://mpuQHs4sWZ-country-US:D0yWxVQo8wQ05RWqz1Bn@isp.joinmassive.com:8000"
# Step 1: Verify IPs
print("="*60)
print("STEP 1: Verify proxy IPs")
check_ip("Direct (Hetzner)")
check_ip("Massive Residential", MASSIVE_RES)
check_ip("Massive Datacenter/ISP", MASSIVE_DC)
# Step 2: Get NST proxies
print("\n" + "="*60)
print("STEP 2: Get NST proxy credentials")
nst_res = get_nst_proxy("7864DDA266D5899C") # residential
nst_dc = get_nst_proxy("AE0C3B5547F8A021") # datacenter
if nst_res:
check_ip("NST Residential", nst_res)
if nst_dc:
check_ip("NST Datacenter", nst_dc)
# Step 3: Test Chanel with all available proxies
print("\n" + "="*60)
print("STEP 3: Test Chanel.com")
if nst_res:
test_chanel("curl_cffi + NST residential", proxy=nst_res, use_cffi=True)
test_chanel("plain requests + NST residential", proxy=nst_res, use_cffi=False)
if nst_dc:
test_chanel("curl_cffi + NST datacenter", proxy=nst_dc, use_cffi=True)
# Also try Massive ISP/datacenter (different from residential)
test_chanel("curl_cffi + Massive ISP", proxy=MASSIVE_DC, use_cffi=True)
+569
View File
@@ -0,0 +1,569 @@
"""
Comprehensive test suite for Sticky Proxy Sessions functionality.
Tests cover:
1. Basic sticky session - same proxy for same session_id
2. Different sessions get different proxies
3. Session release
4. TTL expiration
5. Thread safety / concurrent access
6. Integration tests with AsyncWebCrawler
"""
import asyncio
import os
import time
import pytest
from unittest.mock import patch
from crawl4ai import AsyncWebCrawler, BrowserConfig
from crawl4ai.async_configs import CrawlerRunConfig, ProxyConfig
from crawl4ai.proxy_strategy import RoundRobinProxyStrategy
from crawl4ai.cache_context import CacheMode
class TestRoundRobinProxyStrategySession:
"""Test suite for RoundRobinProxyStrategy session methods."""
def setup_method(self):
"""Setup for each test method."""
self.proxies = [
ProxyConfig(server=f"http://proxy{i}.test:8080")
for i in range(5)
]
# ==================== BASIC STICKY SESSION TESTS ====================
@pytest.mark.asyncio
async def test_sticky_session_same_proxy(self):
"""Verify same proxy is returned for same session_id."""
strategy = RoundRobinProxyStrategy(self.proxies)
# First call - acquires proxy
proxy1 = await strategy.get_proxy_for_session("session-1")
# Second call - should return same proxy
proxy2 = await strategy.get_proxy_for_session("session-1")
# Third call - should return same proxy
proxy3 = await strategy.get_proxy_for_session("session-1")
assert proxy1 is not None
assert proxy1.server == proxy2.server == proxy3.server
@pytest.mark.asyncio
async def test_different_sessions_different_proxies(self):
"""Verify different session_ids can get different proxies."""
strategy = RoundRobinProxyStrategy(self.proxies)
proxy_a = await strategy.get_proxy_for_session("session-a")
proxy_b = await strategy.get_proxy_for_session("session-b")
proxy_c = await strategy.get_proxy_for_session("session-c")
# All should be different (round-robin)
servers = {proxy_a.server, proxy_b.server, proxy_c.server}
assert len(servers) == 3
@pytest.mark.asyncio
async def test_sticky_session_with_regular_rotation(self):
"""Verify sticky sessions don't interfere with regular rotation."""
strategy = RoundRobinProxyStrategy(self.proxies)
# Acquire a sticky session
session_proxy = await strategy.get_proxy_for_session("sticky-session")
# Regular rotation should continue independently
regular_proxy1 = await strategy.get_next_proxy()
regular_proxy2 = await strategy.get_next_proxy()
# Sticky session should still return same proxy
session_proxy_again = await strategy.get_proxy_for_session("sticky-session")
assert session_proxy.server == session_proxy_again.server
# Regular proxies should rotate
assert regular_proxy1.server != regular_proxy2.server
# ==================== SESSION RELEASE TESTS ====================
@pytest.mark.asyncio
async def test_session_release(self):
"""Verify session can be released and reacquired."""
strategy = RoundRobinProxyStrategy(self.proxies)
# Acquire session
proxy1 = await strategy.get_proxy_for_session("session-1")
assert strategy.get_session_proxy("session-1") is not None
# Release session
await strategy.release_session("session-1")
assert strategy.get_session_proxy("session-1") is None
# Reacquire - should get a new proxy (next in round-robin)
proxy2 = await strategy.get_proxy_for_session("session-1")
assert proxy2 is not None
# After release, next call gets the next proxy in rotation
# (not necessarily the same as before)
@pytest.mark.asyncio
async def test_release_nonexistent_session(self):
"""Verify releasing non-existent session doesn't raise error."""
strategy = RoundRobinProxyStrategy(self.proxies)
# Should not raise
await strategy.release_session("nonexistent-session")
@pytest.mark.asyncio
async def test_release_twice(self):
"""Verify releasing session twice doesn't raise error."""
strategy = RoundRobinProxyStrategy(self.proxies)
await strategy.get_proxy_for_session("session-1")
await strategy.release_session("session-1")
await strategy.release_session("session-1") # Should not raise
# ==================== GET SESSION PROXY TESTS ====================
@pytest.mark.asyncio
async def test_get_session_proxy_existing(self):
"""Verify get_session_proxy returns proxy for existing session."""
strategy = RoundRobinProxyStrategy(self.proxies)
acquired = await strategy.get_proxy_for_session("session-1")
retrieved = strategy.get_session_proxy("session-1")
assert retrieved is not None
assert acquired.server == retrieved.server
def test_get_session_proxy_nonexistent(self):
"""Verify get_session_proxy returns None for non-existent session."""
strategy = RoundRobinProxyStrategy(self.proxies)
result = strategy.get_session_proxy("nonexistent-session")
assert result is None
# ==================== TTL EXPIRATION TESTS ====================
@pytest.mark.asyncio
async def test_session_ttl_not_expired(self):
"""Verify session returns same proxy when TTL not expired."""
strategy = RoundRobinProxyStrategy(self.proxies)
# Acquire with 10 second TTL
proxy1 = await strategy.get_proxy_for_session("session-1", ttl=10)
# Immediately request again - should return same proxy
proxy2 = await strategy.get_proxy_for_session("session-1", ttl=10)
assert proxy1.server == proxy2.server
@pytest.mark.asyncio
async def test_session_ttl_expired(self):
"""Verify new proxy acquired after TTL expires."""
strategy = RoundRobinProxyStrategy(self.proxies)
# Acquire with 1 second TTL
proxy1 = await strategy.get_proxy_for_session("session-1", ttl=1)
# Wait for TTL to expire
await asyncio.sleep(1.1)
# Request again - should get new proxy due to expiration
proxy2 = await strategy.get_proxy_for_session("session-1", ttl=1)
# May or may not be same server depending on round-robin state,
# but session should have been recreated
assert proxy2 is not None
@pytest.mark.asyncio
async def test_get_session_proxy_ttl_expired(self):
"""Verify get_session_proxy returns None after TTL expires."""
strategy = RoundRobinProxyStrategy(self.proxies)
await strategy.get_proxy_for_session("session-1", ttl=1)
# Wait for expiration
await asyncio.sleep(1.1)
# Should return None for expired session
result = strategy.get_session_proxy("session-1")
assert result is None
@pytest.mark.asyncio
async def test_cleanup_expired_sessions(self):
"""Verify cleanup_expired_sessions removes expired sessions."""
strategy = RoundRobinProxyStrategy(self.proxies)
# Create sessions with short TTL
await strategy.get_proxy_for_session("short-ttl-1", ttl=1)
await strategy.get_proxy_for_session("short-ttl-2", ttl=1)
# Create session without TTL (should not be cleaned up)
await strategy.get_proxy_for_session("no-ttl")
# Wait for TTL to expire
await asyncio.sleep(1.1)
# Cleanup
removed = await strategy.cleanup_expired_sessions()
assert removed == 2
assert strategy.get_session_proxy("short-ttl-1") is None
assert strategy.get_session_proxy("short-ttl-2") is None
assert strategy.get_session_proxy("no-ttl") is not None
# ==================== GET ACTIVE SESSIONS TESTS ====================
@pytest.mark.asyncio
async def test_get_active_sessions(self):
"""Verify get_active_sessions returns all active sessions."""
strategy = RoundRobinProxyStrategy(self.proxies)
await strategy.get_proxy_for_session("session-a")
await strategy.get_proxy_for_session("session-b")
await strategy.get_proxy_for_session("session-c")
active = strategy.get_active_sessions()
assert len(active) == 3
assert "session-a" in active
assert "session-b" in active
assert "session-c" in active
@pytest.mark.asyncio
async def test_get_active_sessions_excludes_expired(self):
"""Verify get_active_sessions excludes expired sessions."""
strategy = RoundRobinProxyStrategy(self.proxies)
await strategy.get_proxy_for_session("short-ttl", ttl=1)
await strategy.get_proxy_for_session("no-ttl")
# Before expiration
active = strategy.get_active_sessions()
assert len(active) == 2
# Wait for TTL to expire
await asyncio.sleep(1.1)
# After expiration
active = strategy.get_active_sessions()
assert len(active) == 1
assert "no-ttl" in active
assert "short-ttl" not in active
# ==================== THREAD SAFETY TESTS ====================
@pytest.mark.asyncio
async def test_concurrent_session_access(self):
"""Verify thread-safe access to sessions."""
strategy = RoundRobinProxyStrategy(self.proxies)
async def acquire_session(session_id: str):
proxy = await strategy.get_proxy_for_session(session_id)
await asyncio.sleep(0.01) # Simulate work
return proxy.server
# Acquire same session from multiple coroutines
results = await asyncio.gather(*[
acquire_session("shared-session") for _ in range(10)
])
# All should get same proxy
assert len(set(results)) == 1
@pytest.mark.asyncio
async def test_concurrent_different_sessions(self):
"""Verify concurrent acquisition of different sessions works correctly."""
strategy = RoundRobinProxyStrategy(self.proxies)
async def acquire_session(session_id: str):
proxy = await strategy.get_proxy_for_session(session_id)
await asyncio.sleep(0.01)
return (session_id, proxy.server)
# Acquire different sessions concurrently
results = await asyncio.gather(*[
acquire_session(f"session-{i}") for i in range(5)
])
# Each session should have a consistent proxy
session_proxies = dict(results)
assert len(session_proxies) == 5
# Verify each session still returns same proxy
for session_id, expected_server in session_proxies.items():
actual = await strategy.get_proxy_for_session(session_id)
assert actual.server == expected_server
@pytest.mark.asyncio
async def test_concurrent_session_acquire_and_release(self):
"""Verify concurrent acquire and release operations work correctly."""
strategy = RoundRobinProxyStrategy(self.proxies)
async def acquire_and_release(session_id: str):
proxy = await strategy.get_proxy_for_session(session_id)
await asyncio.sleep(0.01)
await strategy.release_session(session_id)
return proxy.server
# Run multiple acquire/release cycles concurrently
await asyncio.gather(*[
acquire_and_release(f"session-{i}") for i in range(10)
])
# All sessions should be released
active = strategy.get_active_sessions()
assert len(active) == 0
# ==================== EMPTY PROXY POOL TESTS ====================
@pytest.mark.asyncio
async def test_empty_proxy_pool_session(self):
"""Verify behavior with empty proxy pool."""
strategy = RoundRobinProxyStrategy() # No proxies
result = await strategy.get_proxy_for_session("session-1")
assert result is None
@pytest.mark.asyncio
async def test_add_proxies_after_session(self):
"""Verify adding proxies after session creation works."""
strategy = RoundRobinProxyStrategy()
# No proxies initially
result1 = await strategy.get_proxy_for_session("session-1")
assert result1 is None
# Add proxies
strategy.add_proxies(self.proxies)
# Now should work
result2 = await strategy.get_proxy_for_session("session-2")
assert result2 is not None
class TestCrawlerRunConfigSession:
"""Test CrawlerRunConfig with sticky session parameters."""
def test_config_has_session_fields(self):
"""Verify CrawlerRunConfig has sticky session fields."""
config = CrawlerRunConfig(
proxy_session_id="test-session",
proxy_session_ttl=300,
proxy_session_auto_release=True
)
assert config.proxy_session_id == "test-session"
assert config.proxy_session_ttl == 300
assert config.proxy_session_auto_release is True
def test_config_session_defaults(self):
"""Verify default values for session fields."""
config = CrawlerRunConfig()
assert config.proxy_session_id is None
assert config.proxy_session_ttl is None
assert config.proxy_session_auto_release is False
class TestCrawlerStickySessionIntegration:
"""Integration tests for AsyncWebCrawler with sticky sessions."""
def setup_method(self):
"""Setup for each test method."""
self.proxies = [
ProxyConfig(server=f"http://proxy{i}.test:8080")
for i in range(3)
]
self.test_url = "https://httpbin.org/ip"
@pytest.mark.asyncio
async def test_crawler_sticky_session_without_proxy(self):
"""Test that crawler works when proxy_session_id set but no strategy."""
browser_config = BrowserConfig(headless=True)
config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
proxy_session_id="test-session",
page_timeout=15000
)
async with AsyncWebCrawler(config=browser_config) as crawler:
result = await crawler.arun(url=self.test_url, config=config)
# Should work without errors (no proxy strategy means no proxy)
assert result is not None
@pytest.mark.asyncio
async def test_crawler_sticky_session_basic(self):
"""Test basic sticky session with crawler."""
strategy = RoundRobinProxyStrategy(self.proxies)
config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
proxy_rotation_strategy=strategy,
proxy_session_id="integration-test",
page_timeout=10000
)
browser_config = BrowserConfig(headless=True)
async with AsyncWebCrawler(config=browser_config) as crawler:
# First request
try:
result1 = await crawler.arun(url=self.test_url, config=config)
except Exception:
pass # Proxy connection may fail, but session should be tracked
# Verify session was created
session_proxy = strategy.get_session_proxy("integration-test")
assert session_proxy is not None
# Cleanup
await strategy.release_session("integration-test")
@pytest.mark.asyncio
async def test_crawler_rotating_vs_sticky(self):
"""Compare rotating behavior vs sticky session behavior."""
strategy = RoundRobinProxyStrategy(self.proxies)
# Config WITHOUT sticky session - should rotate
rotating_config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
proxy_rotation_strategy=strategy,
page_timeout=5000
)
# Config WITH sticky session - should use same proxy
sticky_config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
proxy_rotation_strategy=strategy,
proxy_session_id="sticky-test",
page_timeout=5000
)
browser_config = BrowserConfig(headless=True)
async with AsyncWebCrawler(config=browser_config) as crawler:
# Track proxy configs used
rotating_proxies = []
sticky_proxies = []
# Try rotating requests (may fail due to test proxies, but config should be set)
for _ in range(3):
try:
await crawler.arun(url=self.test_url, config=rotating_config)
except Exception:
pass
rotating_proxies.append(rotating_config.proxy_config.server if rotating_config.proxy_config else None)
# Try sticky requests
for _ in range(3):
try:
await crawler.arun(url=self.test_url, config=sticky_config)
except Exception:
pass
sticky_proxies.append(sticky_config.proxy_config.server if sticky_config.proxy_config else None)
# Rotating should have different proxies (or cycle through them)
# Sticky should have same proxy for all requests
if all(sticky_proxies):
assert len(set(sticky_proxies)) == 1, "Sticky session should use same proxy"
await strategy.release_session("sticky-test")
class TestStickySessionRealWorld:
"""Real-world scenario tests for sticky sessions.
Note: These tests require actual proxy servers to verify IP consistency.
They are marked to be skipped if no proxy is configured.
"""
@pytest.mark.asyncio
@pytest.mark.skipif(
not os.environ.get('TEST_PROXY_1'),
reason="Requires TEST_PROXY_1 environment variable"
)
async def test_verify_ip_consistency(self):
"""Verify that sticky session actually uses same IP.
This test requires real proxies set in environment variables:
TEST_PROXY_1=ip:port:user:pass
TEST_PROXY_2=ip:port:user:pass
"""
import re
# Load proxies from environment
proxy_strs = [
os.environ.get('TEST_PROXY_1', ''),
os.environ.get('TEST_PROXY_2', '')
]
proxies = [ProxyConfig.from_string(p) for p in proxy_strs if p]
if len(proxies) < 2:
pytest.skip("Need at least 2 proxies for this test")
strategy = RoundRobinProxyStrategy(proxies)
# Config WITH sticky session
config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
proxy_rotation_strategy=strategy,
proxy_session_id="ip-verify-session",
page_timeout=30000
)
browser_config = BrowserConfig(headless=True)
async with AsyncWebCrawler(config=browser_config) as crawler:
ips = []
for i in range(3):
result = await crawler.arun(
url="https://httpbin.org/ip",
config=config
)
if result and result.success and result.html:
# Extract IP from response
ip_match = re.search(r'"origin":\s*"([^"]+)"', result.html)
if ip_match:
ips.append(ip_match.group(1))
await strategy.release_session("ip-verify-session")
# All IPs should be same for sticky session
if len(ips) >= 2:
assert len(set(ips)) == 1, f"Expected same IP, got: {ips}"
# ==================== STANDALONE TEST FUNCTIONS ====================
@pytest.mark.asyncio
async def test_sticky_session_simple():
"""Simple test for sticky session functionality."""
proxies = [
ProxyConfig(server=f"http://proxy{i}.test:8080")
for i in range(3)
]
strategy = RoundRobinProxyStrategy(proxies)
# Same session should return same proxy
p1 = await strategy.get_proxy_for_session("test")
p2 = await strategy.get_proxy_for_session("test")
p3 = await strategy.get_proxy_for_session("test")
assert p1.server == p2.server == p3.server
print(f"Sticky session works! All requests use: {p1.server}")
# Cleanup
await strategy.release_session("test")
if __name__ == "__main__":
print("Running Sticky Session tests...")
print("=" * 50)
asyncio.run(test_sticky_session_simple())
print("\n" + "=" * 50)
print("To run the full pytest suite, use: pytest " + __file__)
print("=" * 50)