#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
BIZ-145 / S2 竞品价格采集脚本
合规红线(法务 BIZ-147 复核意见 C1–C7,全部硬编码,不可被命令行覆盖):
C1 频率封顶:单店每周 ≤1 次、单次 ≤10 页面、请求间隔 ≥5 秒
C2 遵守 robots.txt:被禁抓即跳过
C3 不绕反爬:固定常规 UA、不用 IP 池、遇验证码/登录墙即放弃该页面
C4 字段白名单:商品名称 / 页面展示标价 / 页面展示促销信息 / 页面可见销量数
C5 数据留存:原始快照 ≤90 天(--prune 清理)
C7 不使用代理/账号池:直连、无登录态
输出:data/YYYY-MM-DD/prices.json + prices.csv + fetch.log
失败告警:整体或单店失败时写日志;环境变量 MULTICA_PARENT_ISSUE 存在时
通过 multica CLI 在父单留一条失败通知评论。
"""
import csv
import json
import logging
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import urllib.robotparser
from datetime import datetime, timedelta, timezone
from pathlib import Path
# ---------------- 合规常量(C1–C7,硬编码) ----------------
MAX_PAGES_PER_SHOP = 10 # C1
MIN_INTERVAL_SEC = 5.0 # C1
RETENTION_DAYS = 90 # C5
USER_AGENT = ( # C3/C6:常规 UA,不带伪装、附采集说明联系方式
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 "
"(+internal price monitoring; contact: ops@bizwingsinc.com)"
)
TIMEOUT_SEC = 20
ALLOWED_FIELDS = ("name", "list_price", "promo_text", "sales_display") # C4 白名单
# 验证码 / 登录墙特征(C3:命中即放弃,不尝试绕过)
BLOCK_PATTERNS = [
"login.taobao.com", "passport", "验证码", "verify", "captcha",
"slide.tsx", "punish", "sec.taobao",
]
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
TARGETS_FILE = BASE_DIR / "targets.yaml"
CST = timezone(timedelta(hours=8))
log = logging.getLogger("competitor-price")
def setup_logging(log_file: Path):
log.setLevel(logging.INFO)
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(fmt)
fh = logging.FileHandler(log_file, encoding="utf-8")
fh.setFormatter(fmt)
log.addHandler(sh)
log.addHandler(fh)
def load_targets() -> list[dict]:
"""极简 YAML 解析(targets.yaml 结构固定,避免引入第三方依赖)。"""
text = TARGETS_FILE.read_text(encoding="utf-8")
competitors, cur, in_sku, in_monitor = [], None, False, False
for raw in text.splitlines():
line = raw.split("#", 1)[0].rstrip()
if not line.strip():
continue
stripped = line.strip()
if stripped.startswith("- id:"):
cur = {"id": stripped.split(":", 1)[1].strip(), "sku_urls": [],
"monitor_skus": []}
competitors.append(cur)
in_sku = in_monitor = False
elif cur is None:
continue
elif re.match(r"^- \w+:", stripped) is None and ":" in stripped and not stripped.startswith("-"):
key, _, val = stripped.partition(":")
val = val.strip().strip('"')
if key == "sku_urls":
in_sku, in_monitor = True, False
if val.startswith("[") and val != "[]":
cur["sku_urls"] = [u.strip().strip('"') for u in val.strip("[]").split(",") if u.strip()]
elif key == "monitor_skus":
in_monitor, in_sku = True, False
else:
cur[key] = val
in_sku = in_monitor = False
elif stripped.startswith("-") and in_sku:
cur["sku_urls"].append(stripped.lstrip("- ").strip('"'))
elif stripped.startswith("-") and in_monitor:
cur["monitor_skus"].append(stripped.lstrip("- ").strip('"'))
return competitors
def robots_allows(url: str) -> bool:
"""C2:遵守 robots.txt;出错时保守处理为允许但记日志。"""
parsed = urllib.parse.urlparse(url)
rp_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
rp = urllib.robotparser.RobotFileParser()
try:
rp.set_url(rp_url)
rp.read()
return rp.can_fetch(USER_AGENT, url)
except Exception as e: # robots 不可达时不放大风险,记录并放行(人工每周低频)
log.warning("robots.txt 读取失败 %s: %s(保守放行,人工复核)", rp_url, e)
return True
def fetch_page(url: str) -> tuple[int | None, str]:
"""返回 (http_status, html)。不跟随登录跳转以外的异常。"""
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT,
"Accept-Language": "zh-CN,zh;q=0.9"})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
html = resp.read(2_000_000).decode("utf-8", errors="replace")
return resp.status, html
except urllib.error.HTTPError as e:
return e.code, ""
except Exception as e:
log.warning("请求异常 %s: %s", url, e)
return None, ""
def looks_blocked(html: str, final_status: int | None) -> bool:
"""C3:命中反爬/登录墙特征 → 放弃该页面。"""
if final_status in (401, 403):
return True
low = html.lower()
return any(p.lower() in low for p in BLOCK_PATTERNS)
PRICE_RE = re.compile(r'"price"\s*:\s*"?([\d.]+)"?|¥\s*([\d]+(?:\.\d+)?)|¥\s*([\d]+(?:\.\d+)?)')
PROMO_RE = re.compile(r"(满\d+减\d+|第二件半价|买一送一|买赠|券后)")
SALES_RE = re.compile(r"(?:月销|已售|销量)\s*[::]?\s*([\d,]+\+?)")
TITLE_RE = re.compile(r"
(.*?)", re.S)
def parse_fields(url: str, html: str) -> dict:
"""C4:只抽白名单字段,抽不到就留空并在 status 标注。"""
out = {f: "" for f in ALLOWED_FIELDS}
m = TITLE_RE.search(html)
if m:
out["name"] = re.sub(r"-(天猫|淘宝网).*$", "", m.group(1)).strip()[:120]
m = PRICE_RE.search(html)
if m:
out["list_price"] = next(g for g in m.groups() if g)
promos = PROMO_RE.findall(html)
if promos:
out["promo_text"] = ";".join(sorted(set(promos))[:5])
m = SALES_RE.search(html)
if m:
out["sales_display"] = m.group(1)
return {k: v for k, v in out.items() if k in ALLOWED_FIELDS}
def notify_parent_issue(summary: str):
"""失败告警:在父单留评论。无 MULTICA_PARENT_ISSUE 时只写日志。"""
issue = os.environ.get("MULTICA_PARENT_ISSUE") or "448cdf6f-ac97-4c7f-953f-0d92d03c6e85"
body = f"⚠️ 竞品价格采集失败告警({datetime.now(CST):%Y-%m-%d %H:%M} GMT+8)\n\n{summary}\n\n详见监控目录 data/ 下当日 fetch.log。"
try:
subprocess.run(["multica", "issue", "comment", "add", issue,
"--content", body], check=True, timeout=60,
capture_output=True)
log.info("已在父单 %s 留言告警", issue)
except Exception as e:
log.error("父单告警发送失败: %s", e)
def prune_old_snapshots():
"""C5:删除 90 天前的采集目录。"""
cutoff = datetime.now(CST).date() - timedelta(days=RETENTION_DAYS)
for d in DATA_DIR.iterdir() if DATA_DIR.exists() else []:
if d.is_dir():
try:
day = datetime.strptime(d.name, "%Y-%m-%d").date()
except ValueError:
continue
if day < cutoff:
import shutil
shutil.rmtree(d)
log.info("C5 留存清理:删除 %s", d)
def collect_one(comp: dict, today_dir: Path, results: list, failures: list):
urls = [u for u in comp.get("sku_urls", []) if u]
urls = urls or ([comp["shop_url"]] if comp.get("shop_url") else [])
if not urls:
log.warning("%s:无可用 URL(白牌未回填),跳过", comp["name"])
results.append({"competitor_id": comp["id"], "competitor": comp["name"],
"url": "", "status": "skipped_no_url",
**{f: "" for f in ALLOWED_FIELDS}})
return
urls = urls[:MAX_PAGES_PER_SHOP] # C1
for i, url in enumerate(urls):
if i > 0:
time.sleep(MIN_INTERVAL_SEC) # C1
if not robots_allows(url): # C2
log.warning("robots.txt 禁止抓取,跳过:%s", url)
failures.append(f"{comp['name']} robots 禁止 {url}")
results.append({"competitor_id": comp["id"], "competitor": comp["name"],
"url": url, "status": "skipped_robots",
**{f: "" for f in ALLOWED_FIELDS}})
continue
status, html = fetch_page(url)
if looks_blocked(html, status): # C3
log.warning("疑似登录墙/验证码(HTTP %s),放弃:%s", status, url)
failures.append(f"{comp['name']} 被反爬拦截 {url} (HTTP {status})")
results.append({"competitor_id": comp["id"], "competitor": comp["name"],
"url": url, "status": f"blocked_anti_bot_http_{status}",
**{f: "" for f in ALLOWED_FIELDS}})
continue
if not html:
failures.append(f"{comp['name']} 抓取失败 {url} (HTTP {status})")
results.append({"competitor_id": comp["id"], "competitor": comp["name"],
"url": url, "status": f"fetch_failed_http_{status}",
**{f: "" for f in ALLOWED_FIELDS}})
continue
fields = parse_fields(url, html)
row = {"competitor_id": comp["id"], "competitor": comp["name"],
"url": url, "status": "ok" if fields.get("list_price") else "ok_no_price_field",
**fields}
results.append(row)
log.info("✅ %s %s → %s", comp["name"], url, row["status"])
def main() -> int:
today = datetime.now(CST).strftime("%Y-%m-%d")
today_dir = DATA_DIR / today
today_dir.mkdir(parents=True, exist_ok=True)
setup_logging(today_dir / "fetch.log")
log.info("=== BIZ-145 竞品价格采集启动 %s ===", today)
prune_old_snapshots()
competitors = load_targets()
results: list[dict] = []
failures: list[str] = []
for comp in competitors:
collect_one(comp, today_dir, results, failures)
time.sleep(MIN_INTERVAL_SEC) # C1:店铺间同样限速
run_meta = {
"run_date": today,
"tz": "Asia/Shanghai",
"compliance": {"C1_interval_sec": MIN_INTERVAL_SEC,
"C1_max_pages_per_shop": MAX_PAGES_PER_SHOP,
"C2_robots_respected": True,
"C3_user_agent": USER_AGENT,
"C4_field_whitelist": list(ALLOWED_FIELDS),
"C5_retention_days": RETENTION_DAYS,
"C7_no_proxy_no_login": True},
"results": results,
"failures": failures,
}
(today_dir / "prices.json").write_text(
json.dumps(run_meta, ensure_ascii=False, indent=2), encoding="utf-8")
with open(today_dir / "prices.csv", "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=["competitor_id", "competitor", "url",
"status", *ALLOWED_FIELDS])
w.writeheader()
w.writerows(results)
log.info("=== 完成:%d 条记录,%d 条失败 ===", len(results), len(failures))
if failures:
notify_parent_issue("\n".join(f"- {x}" for x in failures))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())