Add archived/ozon-ru-scraper
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
---
|
||||
name: ozon-ru-scraper
|
||||
description: 爬取Ozon.ru商品页面 — 俄罗斯代理+CamouFox+滑块验证码自动求解。含反爬策略、代理选型、验证码算法、避坑指南。
|
||||
version: 0.1
|
||||
tags: [ozon, russia, scraping, captcha, camoufox, proxy]
|
||||
---
|
||||
|
||||
# Ozon.ru 商品爬取
|
||||
|
||||
## 目标
|
||||
爬取 `https://www.ozon.ru/product/{id}` 商品页,提取名称、价格、描述、评分等信息。
|
||||
|
||||
## 三层防御(必须理解)
|
||||
|
||||
### 第1层:GeoIP 检测
|
||||
Ozon 对非俄罗斯 IP 直接返回 307 重定向循环或 Access Denied。
|
||||
|
||||
**对策**:CamouFox + 俄罗斯出口代理 + `geoip=True`
|
||||
|
||||
### 第2层:Antibot Captcha(自研滑块拼图)
|
||||
标题变为 `Antibot Captcha`,页面显示:
|
||||
- 背景图(400×300px,含缺口)
|
||||
- 拼图块(~133×100px,PNG with alpha)
|
||||
- 滑块条(slider-background 512px 宽,slider 40px 宽)
|
||||
- 提示文字:"Сопоставьте пазл, двигая ползунок"(移动滑块对齐拼图)
|
||||
|
||||
### 第3层:IP 快速封禁
|
||||
验证码失败 2-3 次 → IP 被临时封("Доступ ограничен"),封禁时间约 10-30 分钟。页面会显示被封 IP 地址和时间戳。
|
||||
|
||||
## 代码模板
|
||||
|
||||
### 完整爬取流程
|
||||
|
||||
```python
|
||||
from camoufox.sync_api import Camoufox
|
||||
import cv2, numpy as np, time, json, subprocess
|
||||
|
||||
PROXY = {"server": "socks4://RU_PROXY_IP:PORT"}
|
||||
TARGET = "https://www.ozon.ru/product/PRODUCT_ID"
|
||||
|
||||
with Camoufox(headless=True, geoip=True, humanize=True, proxy=PROXY) as browser:
|
||||
page = browser.new_page()
|
||||
|
||||
# 直接访问商品页(不要先访问首页,减少请求次数)
|
||||
page.goto(TARGET, timeout=25000)
|
||||
page.wait_for_load_state("networkidle", timeout=15000)
|
||||
time.sleep(2)
|
||||
|
||||
title = page.title()
|
||||
if "ограничен" in title:
|
||||
print("IP被封,换代理")
|
||||
elif "Captcha" in title:
|
||||
# 解验证码(见下方)
|
||||
pass
|
||||
else:
|
||||
# 直接提取商品信息
|
||||
pass
|
||||
```
|
||||
|
||||
### 滑块验证码求解
|
||||
|
||||
```python
|
||||
def solve_puzzle(bg_path, puzzle_path):
|
||||
"""用 OpenCV 模板匹配计算滑块偏移量"""
|
||||
bg = cv2.imread(bg_path, cv2.IMREAD_UNCHANGED)
|
||||
puzzle = cv2.imread(puzzle_path, cv2.IMREAD_UNCHANGED)
|
||||
|
||||
bg_rgb = bg[:, :, :3]
|
||||
puzzle_rgb = puzzle[:, :, :3]
|
||||
|
||||
# 关键:利用拼图块的alpha通道作为掩码
|
||||
mask = (puzzle[:, :, 3] > 10).astype(np.uint8) * 255 if puzzle.shape[2] == 4 else None
|
||||
|
||||
# TM_CCORR_NORMED + mask 是最佳方法(相似度 0.84-0.86)
|
||||
result = cv2.matchTemplate(bg_rgb, puzzle_rgb, cv2.TM_CCORR_NORMED, mask=mask)
|
||||
_, max_val, _, max_loc = cv2.minMaxLoc(result)
|
||||
|
||||
return max_loc[0] # 图片坐标系下的 X 偏移
|
||||
|
||||
def get_captcha_info(page):
|
||||
"""获取验证码 DOM 参数"""
|
||||
return page.evaluate('''() => {
|
||||
const c = document.querySelector("#captcha");
|
||||
const p = document.querySelector("#captcha #puzzle");
|
||||
const s = document.querySelector("#slider");
|
||||
if (!p || !s || !c) return null;
|
||||
return {
|
||||
scale: parseFloat(getComputedStyle(c).getPropertyValue("--scale")) || 1,
|
||||
puzzleLeft: parseFloat(p.style.left) || 0, // 拼图块当前CSS left
|
||||
sliderContW: document.querySelector("#slider-container")?.getBoundingClientRect().width || 0,
|
||||
sliderW: s.getBoundingClientRect().width,
|
||||
};
|
||||
}''')
|
||||
|
||||
def solve_and_drag(page):
|
||||
"""完整求解+拖动流程"""
|
||||
info = get_captcha_info(page)
|
||||
if not info:
|
||||
return False
|
||||
|
||||
# 下载验证码图片
|
||||
bg_src = page.evaluate('document.querySelector("#captcha #image")?.src')
|
||||
pz_src = page.evaluate('document.querySelector("#captcha #puzzle")?.src')
|
||||
for url, path in [(bg_src, "/tmp/oz_bg.png"), (pz_src, "/tmp/oz_pz.png")]:
|
||||
subprocess.run(["curl", "-s", "-o", path, url], capture_output=True, timeout=10)
|
||||
|
||||
# 计算偏移
|
||||
target_x = solve_puzzle("/tmp/oz_bg.png", "/tmp/oz_pz.png")
|
||||
|
||||
# 核心公式:拖动距离 = 目标渲染X - 拼图当前left
|
||||
# 目标渲染X = 图片坐标偏移 × CSS scale
|
||||
drag_px = target_x * info['scale'] - info['puzzleLeft']
|
||||
max_drag = info['sliderContW'] - info['sliderW']
|
||||
drag_px = max(0, min(drag_px, max_drag))
|
||||
|
||||
# 人类化拖动
|
||||
slider = page.query_selector("#slider")
|
||||
box = slider.bounding_box()
|
||||
sx, sy = box["x"] + box["width"]/2, box["y"] + box["height"]/2
|
||||
|
||||
page.mouse.move(sx, sy)
|
||||
time.sleep(0.3)
|
||||
page.mouse.down()
|
||||
time.sleep(0.1)
|
||||
|
||||
steps = 25
|
||||
for i in range(1, steps + 1):
|
||||
t = i / steps
|
||||
ease = 1 - (1 - t) ** 3 # ease-out cubic
|
||||
x = sx + drag_px * ease
|
||||
y = sy + (np.random.random() - 0.5) * 1.0 # 微小Y抖动
|
||||
page.mouse.move(x, y)
|
||||
time.sleep(np.random.uniform(0.015, 0.04))
|
||||
|
||||
time.sleep(0.1)
|
||||
page.mouse.up()
|
||||
return True
|
||||
```
|
||||
|
||||
### 验证码通过后等跳转
|
||||
|
||||
```python
|
||||
# 验证码通过后标题变为 "Antibot Challenge Page"
|
||||
# 会自动 JS 跳转到商品页
|
||||
if "Challenge" in page.title():
|
||||
try:
|
||||
page.wait_for_url("**/product/**", timeout=10000)
|
||||
except:
|
||||
page.goto(TARGET, timeout=20000)
|
||||
page.wait_for_load_state("networkidle", timeout=15000)
|
||||
time.sleep(3)
|
||||
```
|
||||
|
||||
### 商品信息提取
|
||||
|
||||
```python
|
||||
# 方法1: LD+JSON(最可靠)
|
||||
ld = page.evaluate('''() => {
|
||||
const el = document.querySelector('script[type="application/ld+json"]');
|
||||
return el ? el.textContent : null;
|
||||
}''')
|
||||
if ld:
|
||||
data = json.loads(ld)
|
||||
name = data.get('name')
|
||||
price = data.get('offers', {}).get('price')
|
||||
currency = data.get('offers', {}).get('priceCurrency')
|
||||
description = data.get('description')
|
||||
rating = data.get('aggregateRating', {}).get('ratingValue')
|
||||
review_count = data.get('aggregateRating', {}).get('reviewCount')
|
||||
brand = data.get('brand')
|
||||
sku = data.get('sku')
|
||||
```
|
||||
|
||||
## 验证码关键参数
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| `--scale` | 1.28 | captcha 容器的 CSS 缩放因子 |
|
||||
| 背景图 | 400×300px | `#captcha #image` |
|
||||
| 拼图块 | ~133×100px | `#captcha #puzzle`,4通道PNG |
|
||||
| slider-background | 512px | 渲染宽度 = 400 × 1.28 |
|
||||
| slider | 40px 宽 | 可拖动滑块 |
|
||||
| 拖动范围 | `containerW - sliderW` = ~440px | slider-container 宽480 - slider宽40 |
|
||||
| 拼图初始left | 随机(14-67px) | `puzzle.style.left` |
|
||||
| 拖动公式 | `target_x × scale - puzzleLeft` | 从初始位置到目标位置的像素距离 |
|
||||
|
||||
## 模板匹配方法对比
|
||||
|
||||
| 方法 | 相似度 | 说明 |
|
||||
|------|--------|------|
|
||||
| `TM_CCORR_NORMED` + alpha mask | **0.84-0.86** | ✅ 最佳,推荐 |
|
||||
| `TM_CCOEFF_NORMED` + mask | 不稳定(0.39-inf) | ❌ 受拼图块透明区域干扰 |
|
||||
| Canny边缘 + `TM_CCOEFF_NORMED` | 0.45-0.67 | ⚠️ 可作辅助验证 |
|
||||
| HSV V通道 + mask | 0.85 | ≈方法1,可交叉验证 |
|
||||
|
||||
## 代理选型
|
||||
|
||||
> **Bright Data 详细配置见参考文件:** `references/brightdata-proxy-guide.md`
|
||||
> 包含完整凭证、所有可用zone、/request API 使用方式、价格对比、避坑指南。
|
||||
|
||||
### 推荐:付费住宅代理(首选)
|
||||
- **Bright Data** — 通过 Web Access API(`/request` endpoint)使用
|
||||
- `zone9`:数据中心 DC,$0.11/GB(比住宅便宜70倍)
|
||||
- `zone47`:住宅,$8/GB,支持 `country=us/kz` 切换
|
||||
- 住宅IP几乎不触发验证码
|
||||
- 轮换IP避免单IP被封
|
||||
- **策略:** 优先用 DC zone($0.11/GB),被封再换住宅 zone47
|
||||
|
||||
### 免费 SOCKS4(不推荐生产使用)
|
||||
- 来源:`https://api.proxyscrape.com/v4/free-proxy-list/get?request=display_proxies&proxy_format=protocolipport&format=text&country=ru&protocol=socks4`
|
||||
- 可用率:<5%,寿命短,同IP多人共用易触发封禁
|
||||
- 已验证的代理(可能已失效):
|
||||
- `77.232.142.77:31336` — SOCKS4,曾成功过一次,后 IP 被封
|
||||
- `89.169.168.25:1080` — SOCKS4,能连但有验证码
|
||||
|
||||
## 陷阱与避坑
|
||||
|
||||
1. **不要先访问首页再访问商品页** — 两次请求增加触发验证码概率。直接访问目标URL。
|
||||
2. **验证码最多试2次** — 第3次失败几乎必封IP。如果前2次偏移量有明显误差,放弃换代理。
|
||||
3. **CCOEFF_NORMED 的 inf 值是假信号** — 目标区域全黑(mask=0)时会产生虚假高匹配,忽略。
|
||||
4. **验证码通过后不要立刻再请求** — Challenge page 会自动跳转,等它完成。
|
||||
5. **IP被封后不要在同一代理上重试** — 封禁持续10-30分钟,换新代理。
|
||||
6. **Jina Reader 也被拦** — `r.jina.ai` 无法绕过 Ozon captcha,返回 403。
|
||||
7. **curl 裸连返回 307** — 无代理根本连不上 Ozon。
|
||||
|
||||
## 依赖安装
|
||||
|
||||
```bash
|
||||
pip install camoufox[geoip] opencv-python-headless -i https://pypi.org/simple/
|
||||
python -m camoufox fetch # 下载 Firefox v135 浏览器二进制
|
||||
```
|
||||
|
||||
## 未来改进方向
|
||||
|
||||
- [ ] 付费住宅代理集成(Bright Data / Oxylabs)
|
||||
- [ ] 验证码偏移量用深度学习模型替代 OpenCV 模板匹配
|
||||
- [ ] 代理池自动轮换,单IP失败自动切换
|
||||
- [ ] 是用 YesCaptcha 打码平台 API 解 Ozon 自研验证码
|
||||
Reference in New Issue
Block a user