Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,88 @@ http://localhost:8000/static/remote.html

> Note: By default, `API_KEY` is set to `dev-test-key`. You might need to configure headers if using the UI directly, or disable auth in `docker-compose.yml` for testing.

## 🧪 Local Testing (Non-Docker)

For local development and testing without Docker, you can start all services manually.

### Quick Start (One Command)

```bash
python scripts/start_local_test.py
```

This script will:
- ✅ Check and start Redis (if not running)
- ✅ Start 2 Worker nodes (ports 8001, 8002)
- ✅ Start Gateway (port 8000)
- ✅ Run automatic API tests
- ✅ Display access URLs

Press `Ctrl+C` to stop all services.

### Manual Setup (Step by Step)

**1. Start Redis** (if not installed):

```bash
# macOS
brew install redis
brew services start redis

# Linux
sudo apt-get install redis-server
sudo systemctl start redis
```

**2. Start Worker Node 1** (Terminal 1):

```bash
export HTTP_PORT=8001
export MAX_SESSIONS=5
export REDIS_URL=redis://localhost:6379/0
export CLUSTER_SECRET=dev-cluster-secret
export NODE_HOST=localhost
python http_server.py
```

**3. Start Worker Node 2** (Terminal 2):

```bash
export HTTP_PORT=8002
export MAX_SESSIONS=5
export REDIS_URL=redis://localhost:6379/0
export CLUSTER_SECRET=dev-cluster-secret
export NODE_HOST=localhost
python http_server.py
```

**4. Start Gateway** (Terminal 3):

```bash
export REDIS_URL=redis://localhost:6379/0
export API_KEY=dev-test-key
export CLUSTER_SECRET=dev-cluster-secret
python gateway.py
```

**5. Test the Setup**:

```bash
python scripts/test_gateway_local.py
```

### Access URLs

- **Gateway**: http://localhost:8000
- **Remote Control UI**: http://localhost:8000/static/remote.html
- **API Documentation**: http://localhost:8000/docs

### Notes

- For local testing, `NODE_HOST` should be set to `localhost` (not container names)
- Ensure Redis is running before starting services
- The script will automatically check Redis availability

## 📦 Installation (Local Development)

### 1. Install Dependencies
Expand Down
124 changes: 118 additions & 6 deletions cdp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,15 +153,41 @@ async def initialize(
# 启动浏览器
self.browser = await self.playwright.chromium.launch(**launch_options)

# 创建上下文
# 创建上下文(增强反检测配置)
# 使用真实的 Chrome User-Agent(如果没有提供)
default_user_agent = user_agent or (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
)

context_options = {
'viewport': {'width': width, 'height': height},
'bypass_csp': True,
'user_agent': default_user_agent,
# 设置更真实的 HTTP headers
'extra_http_headers': {
'Accept-Language': 'zh-CN,zh;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,image/webp,*/*;q=0.8',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Cache-Control': 'max-age=0',
},
# 设置更真实的屏幕参数
'screen': {
'width': width,
'height': height
},
# 设置时区(中国时区)
'timezone_id': 'Asia/Shanghai',
# 设置语言
'locale': 'zh-CN',
}

if user_agent:
context_options['user_agent'] = user_agent

if proxy:
context_options['proxy'] = proxy

Expand Down Expand Up @@ -196,6 +222,69 @@ async def initialize(
# 应用 playwright-stealth 的完整反检测补丁
await stealth_async(self.page)

# 增强反检测:额外的指纹伪装
await self.page.add_init_script("""
// Canvas 指纹随机化(添加噪声)
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function(type) {
const context = this.getContext('2d');
if (context) {
const imageData = context.getImageData(0, 0, this.width, this.height);
for (let i = 0; i < imageData.data.length; i += 4) {
// 添加微小的随机噪声(不影响视觉)
imageData.data[i] += Math.random() * 0.01;
}
context.putImageData(imageData, 0, 0);
}
return originalToDataURL.apply(this, arguments);
};

// WebGL 指纹伪装
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);
};

// 完善 navigator 属性
Object.defineProperty(navigator, 'hardwareConcurrency', {
get: () => 8
});
Object.defineProperty(navigator, 'deviceMemory', {
get: () => 8
});
Object.defineProperty(navigator, 'platform', {
get: () => 'Win32'
});

// 设置语言
Object.defineProperty(navigator, 'language', {
get: () => 'zh-CN'
});
Object.defineProperty(navigator, 'languages', {
get: () => ['zh-CN', 'zh', 'en-US', 'en']
});

// 完善 screen 对象
Object.defineProperty(screen, 'availWidth', {
get: () => window.innerWidth || 1920
});
Object.defineProperty(screen, 'availHeight', {
get: () => window.innerHeight || 1080
});
Object.defineProperty(screen, 'colorDepth', {
get: () => 24
});
Object.defineProperty(screen, 'pixelDepth', {
get: () => 24
});
""")

# 调试 webdriver 状态
info = await self.page.evaluate("""
() => ({
Expand All @@ -220,12 +309,35 @@ async def initialize(
raise

async def navigate(self, url: str, timeout: int = 30) -> str:
"""导航到指定 URL"""
"""导航到指定 URL(增强反检测:添加人类行为模拟)"""
if not self.page:
raise RuntimeError("浏览器会话未初始化")

try:
response = await self.page.goto(url, timeout=timeout * 1000, wait_until='domcontentloaded')
# 添加随机延迟,模拟人类行为(0.5-2秒)
import random
delay = random.uniform(0.5, 2.0)
await asyncio.sleep(delay)

# 导航时设置更真实的 referer(如果是首次访问则没有 referer)
response = await self.page.goto(
url,
timeout=timeout * 1000,
wait_until='domcontentloaded',
referer=None # 首次访问不设置 referer
)

# 页面加载后,模拟一些人类行为(随机鼠标移动)
try:
await asyncio.sleep(random.uniform(0.3, 1.0))
# 随机移动鼠标到页面中心附近
await self.page.mouse.move(
random.randint(400, 800),
random.randint(300, 600)
)
except Exception as e:
logger.debug(f"模拟鼠标移动失败(可忽略): {e}")

self.last_activity = time.time()
return self.page.url
except Exception as e:
Expand Down
27 changes: 24 additions & 3 deletions core/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,32 @@ def __init__(self, redis_url: str):
self.is_running = False

def _get_local_ip(self):
"""获取本机 IP 或容器名(Docker 环境)"""
# 优先使用环境变量(Docker Compose 可以设置)
node_host = os.getenv('NODE_HOST')
if node_host:
return node_host

# 在 Docker 环境中,尝试使用容器名(hostname)
# 这样 Gateway 可以通过容器名访问 Worker
try:
# 获取本机IP,在Docker中这通常是容器IP
# 在某些网络配置下可能需要手动指定
hostname = socket.gethostname()
return socket.gethostbyname(hostname)
# 检查是否在 Docker 中(hostname 通常是容器名)
# 如果 hostname 不是 localhost 或 127.0.0.1,很可能是容器名
if hostname and hostname not in ('localhost', '127.0.0.1'):
# 尝试解析,如果失败则直接使用 hostname(容器名)
try:
ip = socket.gethostbyname(hostname)
# 如果解析出的是 127.0.0.1,说明不在 Docker 网络中,使用 hostname
if ip == '127.0.0.1':
return hostname
return ip
except:
# 解析失败,直接使用 hostname(容器名)
return hostname
else:
# 非 Docker 环境,使用 IP
return socket.gethostbyname(hostname) if hostname else '127.0.0.1'
except:
return '127.0.0.1'

Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ services:
- MAX_SESSIONS=5
- REDIS_URL=redis://redis:6379/0
- CLUSTER_SECRET=dev-cluster-secret
- NODE_HOST=browser-node-1 # 使用容器名,Gateway 可以通过此名称访问
volumes:
- ./log:/app/log
depends_on:
Expand All @@ -41,6 +42,7 @@ services:
- MAX_SESSIONS=5
- REDIS_URL=redis://redis:6379/0
- CLUSTER_SECRET=dev-cluster-secret
- NODE_HOST=browser-node-2 # 使用容器名,Gateway 可以通过此名称访问
volumes:
- ./log:/app/log
depends_on:
Expand Down
Loading