Use Cases

电商库存与价格监控:验证码处理实战

监控脚本第三天开始大面积失败,多半不是选择器过期,而是验证码出现了。 零售站点按访问频率和请求特征滚动打分,前两天顺畅只说明分数还没跌破阈值。

把它当成正常分支:检测到 → 识别 → 提交 token 重放。CaptchaAI 覆盖 reCAPTCHA v2/v3 与 Cloudflare Turnstile。


先定检查频率

频率同时决定新鲜度、触发率和成本:

品类 建议间隔
3C 电子 30 分钟
服饰 2 小时
生鲜、家居 4–6 小时
标品耗材 12 小时

限量款要分钟级,触发率高一个量级,单独拆进程跑。

类型按站点记:Amazon、eBay 多为 reCAPTCHA v2,Walmart 叠加 Cloudflare,Target 与 Shopify 独立站用 Turnstile。国内电商以极验、易盾为主,其中 CaptchaAI 只支持 GeeTest v3。

采集边界: 只采集你有权采集的公开数据,遵守 robots 协议与 PIPL。


商品监控主流程

_solve_and_retry() 提取 sitekey,按页面上是 cf-turnstile 还是 reCAPTCHA 选 method 并提交 token。

import requests
import time
import re
import json
from datetime import datetime
from bs4 import BeautifulSoup

CAPTCHAAI_KEY = "YOUR_API_KEY"
CAPTCHAAI_URL = "https://ocr.captchaai.com"


def solve_captcha(method, sitekey, pageurl, **kwargs):
    data = {
        "key": CAPTCHAAI_KEY, "method": method,
        "googlekey": sitekey, "pageurl": pageurl, "json": 1,
    }
    data.update(kwargs)
    resp = requests.post(f"{CAPTCHAAI_URL}/in.php", data=data)
    task_id = resp.json()["request"]
    for _ in range(60):
        time.sleep(5)
        result = requests.get(f"{CAPTCHAAI_URL}/res.php", params={
            "key": CAPTCHAAI_KEY, "action": "get",
            "id": task_id, "json": 1,
        })
        r = result.json()
        if r["request"] != "CAPCHA_NOT_READY":
            return r["request"]
    raise TimeoutError("Timeout")


class RetailMonitor:
    def __init__(self, proxy=None):
        self.session = requests.Session()
        if proxy:
            self.session.proxies = {"http": proxy, "https": proxy}
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
            "Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
            "Accept-Language": "en-US,en;q=0.9",
        })

    def check_product(self, url):
        """Check single product's price and availability."""
        resp = self.session.get(url, timeout=30)

        # Handle CAPTCHA
        if self._has_captcha(resp.text):
            resp = self._solve_and_retry(resp.text, url)

        soup = BeautifulSoup(resp.text, "html.parser")
        return {
            "url": url,
            "title": self._text(soup, "h1, .product-title, #productTitle"),
            "price": self._text(soup, ".price, .a-price .a-offscreen, .prod-price"),
            "availability": self._text(soup, "#availability, .stock-status, .fulfillment"),
            "in_stock": self._check_stock(soup),
            "timestamp": datetime.now().isoformat(),
        }

    def monitor_products(self, product_urls, interval_sec=1800):
        """Continuously monitor products for changes."""
        history = {}

        while True:
            for url in product_urls:
                try:
                    current = self.check_product(url)

                    # Check for changes
                    prev = history.get(url)
                    if prev:
                        changes = self._detect_changes(prev, current)
                        if changes:
                            self._alert(current["title"], changes)

                    history[url] = current
                    time.sleep(3)

                except Exception as e:
                    print(f"Error checking {url}: {e}")

            print(f"Cycle complete: {len(product_urls)} products checked")
            time.sleep(interval_sec)

    def track_prices(self, product_urls, output_file="prices.json"):
        """Single price check across all products."""
        results = []
        for url in product_urls:
            try:
                data = self.check_product(url)
                results.append(data)
                time.sleep(3)
            except Exception as e:
                results.append({"url": url, "error": str(e)})

        with open(output_file, "w") as f:
            json.dump(results, f, indent=2)
        print(f"Tracked {len(results)} products → {output_file}")
        return results

    def _has_captcha(self, html):
        return any(tag in html.lower() for tag in [
            'data-sitekey', 'g-recaptcha', 'cf-turnstile', 'captcha',
        ])

    def _solve_and_retry(self, html, url):
        match = re.search(r'data-sitekey="([^"]+)"', html)
        if not match:
            return self.session.get(url)

        sitekey = match.group(1)
        if 'cf-turnstile' in html:
            token = solve_captcha("turnstile", sitekey, url)
            return self.session.post(url, data={"cf-turnstile-response": token})
        else:
            token = solve_captcha("userrecaptcha", sitekey, url)
            return self.session.post(url, data={"g-recaptcha-response": token})

    def _text(self, soup, selector):
        el = soup.select_one(selector)
        return el.get_text(strip=True) if el else ""

    def _check_stock(self, soup):
        stock_el = soup.select_one("#availability, .stock-status")
        if stock_el:
            text = stock_el.get_text(strip=True).lower()
            return "in stock" in text or "available" in text
        return None

    def _detect_changes(self, prev, current):
        changes = []
        if prev["price"] != current["price"]:
            changes.append(f"Price: {prev['price']} → {current['price']}")
        if prev["in_stock"] != current["in_stock"]:
            status = "In Stock" if current["in_stock"] else "Out of Stock"
            changes.append(f"Stock: → {status}")
        return changes

    def _alert(self, title, changes):
        print(f"ALERT [{title}]: {', '.join(changes)}")


# Usage
monitor = RetailMonitor(
    proxy="http://user:[email protected]:5000"
)

products = [
    "https://store.example.com/product/abc123",
    "https://store.example.com/product/def456",
    "https://store.example.com/product/ghi789",
]

# One-time price check
results = monitor.track_prices(products)

# Or continuous monitoring (every 30 min)
# monitor.monitor_products(products, interval_sec=1800)

商品之间的 time.sleep(3) 是控制触发率最有效的参数,先调它。


整类目扫描

分页扫描复用同一个分支,入口换列表页:

def scan_category(base_url, category, max_pages=20):
    """Scan an entire product category for stock status."""
    monitor = RetailMonitor(
        proxy="http://user:[email protected]:5000"
    )

    all_products = []
    for page in range(1, max_pages + 1):
        url = f"{base_url}/{category}?page={page}"
        resp = monitor.session.get(url, timeout=30)

        if monitor._has_captcha(resp.text):
            resp = monitor._solve_and_retry(resp.text, url)

        soup = BeautifulSoup(resp.text, "html.parser")
        items = soup.select(".product-card, .s-result-item")

        if not items:
            break

        for item in items:
            all_products.append({
                "name": monitor._text(item, ".product-name, .a-text-normal"),
                "price": monitor._text(item, ".price, .a-price"),
                "stock": monitor._text(item, ".stock, .a-color-success"),
                "url": item.select_one("a")["href"] if item.select_one("a") else "",
            })

        time.sleep(3)

    return all_products

max_pages 必须设上限:有的站点翻到底返回空列表,有的重复返回最后一页。


跨店比价

逐店建独立会话,避免状态干扰:

def compare_product_across_stores(product_name, stores):
    """Compare prices across retailers for the same product."""
    results = []

    for store in stores:
        monitor = RetailMonitor(proxy=store.get("proxy"))
        search_url = f"{store['base_url']}/search?q={product_name}"

        try:
            resp = monitor.session.get(search_url, timeout=30)
            if monitor._has_captcha(resp.text):
                resp = monitor._solve_and_retry(resp.text, search_url)

            soup = BeautifulSoup(resp.text, "html.parser")
            first_result = soup.select_one(".product-card, .s-result-item")

            if first_result:
                results.append({
                    "store": store["name"],
                    "price": monitor._text(first_result, ".price"),
                    "in_stock": "in stock" in first_result.get_text().lower(),
                })
        except Exception as e:
            results.append({"store": store["name"], "error": str(e)})

        time.sleep(5)

    results.sort(key=lambda x: x.get("price", "zzzz"))
    return results

末行是字符串排序:价格带 $ 时顺序不可靠,先解析成数值再排。


线程怎么估

以一个面向东南亚市场的比价看板为例:6 家站点、每家 200 个 SKU、每 30 分钟一轮,单轮 1,200 次请求,按 5% 触发率约 60 个任务。

CaptchaAI 按并发线程计费,要估的是“同一时刻几个任务在飞”:60 个任务摊在 30 分钟里,峰值十几个线程,STANDARD($30/月,15 线程)够用;频率翻倍升 ADVANCE($90/月,50 线程)。会话策略决定峰值。


三类高频故障

现象 多半是 处理方式
每页都弹验证码 出口 IP 被标记或速率超限 拉长间隔,改用持久 QA 会话
价格抓错或为空 价格由 JS 动态渲染 改用 Selenium 或 Puppeteer
字段整片为空 页面结构改版 更新 CSS 选择器

验证码率飙升时先回看频率——频率不对,加资源只会放大触发率。


常见问题

跑几天后验证码变多,是被永久拉黑了吗?

多数情况不是。零售站用滚动评分,访问特征恢复后分数会回升,先把间隔翻倍观察一个周期。

一个套餐能支撑几个监控项目?

按线程算,不按项目算。多个脚本共用一个 API Key 会竞争同一份配额,把峰值并发加总对照线程数。

国内站点的滑块能复用这套代码吗?

流程一致,method 与返回值不同:GeeTest v3 提交 gtchallenge,返回 challenge、validate、seccode 三个值而非单个 token。


相关阅读


让每一轮抓取都跑完整——注册 CaptchaAI 拿到 API Key。

该文章已禁用评论。