import logging
import os
import re
import time
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Optional
import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
class ManualReviewRequired(Exception):
"""任务需要停止自动处理并进入人工复核。"""
@dataclass
class PriceResult:
title: Optional[str]
price: Optional[Decimal]
currency: Optional[str]
availability: Optional[str]
http_status: int
elapsed_ms: int
quality_status: str
def build_session(proxy_url: str) -> requests.Session:
session = requests.Session()
session.proxies.update({
"http": proxy_url,
"https": proxy_url,
})
retry = Retry(
total=2,
connect=2,
read=2,
status=1,
backoff_factor=2,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"],
respect_retry_after_header=True,
raise_on_status=False,
)
session.mount("https://", HTTPAdapter(max_retries=retry))
session.mount("http://", HTTPAdapter(max_retries=retry))
return session
def clean_text(node) -> Optional[str]:
if node is None:
return None
return " ".join(node.get_text(" ", strip=True).split())
def parse_decimal(whole: Optional[str], fraction: Optional[str]) -> Optional[Decimal]:
if not whole:
return None
whole_digits = re.sub(r"[^0-9]", "", whole)
fraction_digits = re.sub(r"[^0-9]", "", fraction or "")
if not whole_digits:
return None
normalized = whole_digits
if fraction_digits:
normalized = f"{whole_digits}.{fraction_digits[:2]}"
try:
return Decimal(normalized)
except InvalidOperation:
return None
def parse_product_page(html: str, status_code: int, elapsed_ms: int) -> PriceResult:
soup = BeautifulSoup(html, "html.parser")
title = clean_text(soup.select_one("#productTitle"))
whole = clean_text(soup.select_one(".a-price .a-price-whole"))
fraction = clean_text(soup.select_one(".a-price .a-price-fraction"))
currency = clean_text(soup.select_one(".a-price .a-price-symbol"))
availability = clean_text(soup.select_one("#availability span"))
price = parse_decimal(whole, fraction)
quality_status = "valid" if title and price and currency else "needs_review"
return PriceResult(
title=title,
price=price,
currency=currency,
availability=availability,
http_status=status_code,
elapsed_ms=elapsed_ms,
quality_status=quality_status,
)
def fetch_product(session: requests.Session, url: str) -> PriceResult:
headers = {
"Accept-Language": "en-US,en;q=0.8",
"User-Agent": "AuthorizedPriceMonitor/1.0 (+operations-contact@example.com)",
}
started = time.perf_counter()
response = session.get(url, headers=headers, timeout=(5, 20))
elapsed_ms = int((time.perf_counter() - started) * 1000)
logging.info(
"request_finished status=%s elapsed_ms=%s url=%s",
response.status_code,
elapsed_ms,
url,
)
if response.status_code in {401, 403}:
raise ManualReviewRequired(
f"访问被拒绝,状态码为 {response.status_code},停止自动重试并人工检查规则与授权。"
)
if response.status_code == 429:
raise ManualReviewRequired(
"请求受到限流。应降低频率、检查服务器建议等待时间与网站规则,不应通过持续更换网络地址规避限制。"
)
response.raise_for_status()
lower_html = response.text.lower()
if "captcha" in lower_html or "robot check" in lower_html:
raise ManualReviewRequired(
"页面出现验证码或机器人检查,任务应暂停并进入人工复核。"
)
return parse_product_page(response.text, response.status_code, elapsed_ms)
if name == "main":
proxy_url = os.environ["PROXY_URL"]
product_url = "https://www.amazon.com/dp/EXAMPLE_ASIN"
http_session = build_session(proxy_url)
result = fetch_product(http_session, product_url)
print(result)</code></pre><p>这段代码故意把状态码为 <code>401</code>、<code>403</code>、<code>429</code> 的响应和验证码页面送入人工复核,而不是不断切换地址继续请求。这样做不仅更符合合规要求,也能避免把拒绝页面、验证页面或不完整页面源码误解析成商品数据。</p><figure><img src="https://data.ipipd.cn/common/image/2026/07/31/ba494a7546b04df8b87b0d26c7a50f92.png" alt="地区价格差异面板" title="地区价格差异面板" loading="lazy"><figcaption>地区价格差异需要和币种、库存、时间窗口一起判断。</figcaption></figure><h3>示例一的生产化改造重点</h3><p>正式系统还需要补充:</p><ul><li><p>从任务队列读取商品和地区,而不是把页面地址写死。</p></li><li><p>将代理会话编号、目标地区和任务编号加入结构化日志。</p></li><li><p>将选择器放进配置文件,页面变更时无需修改主程序。</p></li><li><p>把原始响应摘要或哈希保存到证据表,不长期保存不必要的页面内容。</p></li><li><p>对价格、货币、库存和卖家分别做质量检查。</p></li><li><p>设置全局并发和每个站点的访问节奏上限。</p></li></ul><h2>使用脚本发送请求示例二</h2><p>已有的脚本服务可以使用相应请求库和代理连接组件。先安装依赖:</p><pre><code class="language-bash">npm install axios https-proxy-agent</code></pre><pre><code class="language-javascript">import axios from "axios";
import { HttpsProxyAgent } from "https-proxy-agent";
const proxyUrl = process.env.PROXY_URL;
if (!proxyUrl) {
throw new Error("缺少 PROXY_URL 环境变量");
}
const agent = new HttpsProxyAgent(proxyUrl);
async function fetchProduct(url) {
const startedAt = Date.now();
const response = await axios.get(url, {
httpsAgent: agent,
proxy: false,
timeout: 20000,
maxRedirects: 3,
headers: {
"Accept-Language": "en-US,en;q=0.8",
"User-Agent": "AuthorizedPriceMonitor/1.0 (+operations-contact@example.com)",
},
validateStatus: () => true,
});
const elapsedMs = Date.now() - startedAt;
console.log(JSON.stringify({
event: "request_finished",
url,
status: response.status,
elapsedMs,
}));
if ([401, 403, 429].includes(response.status)) {
throw new Error(
任务暂停并进入人工复核,网页状态码:${response.status}
);
}
if (response.status < 200 || response.status >= 300) {
throw new Error(
请求失败,网页状态码:${response.status}
);
}
const html = String(response.data);
const lowerHtml = html.toLowerCase();
if (lowerHtml.includes("captcha") || lowerHtml.includes("robot check")) {
throw new Error("检测到验证码或机器人检查,停止自动处理");
}
return {
html,
status: response.status,
elapsedMs,
};
}
const result = await fetchProduct(
"https://www.amazon.com/dp/EXAMPLE_ASIN"
);