实战:电商价格监控

价格监控是需求最明确、也最容易踩线的采集场景:技术上只是「定时抓一个数字」,但「能不能抓、抓完能做什么」决定了整个项目是否成立。本章先讲清边界,再讲 SKU 结构、价格口径、历史表设计与降价提醒。

先讲边界:三类用途,三种结论

用途典型做法合规前提
个人自用比价本地记录自己关注的几件商品仍需遵守站点条款与 robots.txt,控制频率
企业内部分析比价用于选品、定价参考需确认平台条款是否允许自动化抓取;优先用开放平台/官方 API 或授权数据服务
对外再分发做成比价网站、公开发布或售卖必须获得授权。商品数据通常有明确使用限制,商业再分发前应取得书面许可

三条硬红线:不绕过登录、验证码与风控;不采集需要登录或付费才能看到的价格;不做高频轮询式抓取。 价格数据还涉及《著作权法》下的商品图文、平台服务条款,以及商业宣传与竞争合规要求(可能涉及《反不正当竞争法》);涉及个人信息处理须符合《个人信息保护法》《数据安全法》。工程上的第一选择永远是:能走官方开放平台或授权数据源,就不要自己抓。

价格落点:商品 → 规格 → SKU

一个商品页面是一棵「商品 → 规格 → SKU」的树:商品 ID(如 100012043978)是页面级标识,规格(颜色、容量、套餐)决定组合维度,SKU ID 才是价格与库存的真实载体,也是监控的最小单位

价格形态页面表现处理方式
券前价(划线价)¥1,299单独存 list_price,不用于比价
券后到手价券后 ¥1,099存为 price,比价主口径
会员/新人价需登录才可见不采集,越过登录即越界
存储层的铁律:每个价格行必须带「口径」和「采集时间」,否则「昨天 1099、今天 1299」可能只是券前券后混着比;无货时写 in_stock=0,绝不写成价格 0。

历史价格表设计

-- 唯一键 = (sku_id, collect_date):同一 SKU 同一天只留一条,补跑时可安全重写
CREATE TABLE price_history (
  id           BIGINT AUTO_INCREMENT PRIMARY KEY,
  sku_id       VARCHAR(64)   NOT NULL,
  collect_date DATE          NOT NULL COMMENT '采集日期,用于按天去重',
  price        DECIMAL(10,2) NULL COMMENT '券后到手价',
  list_price   DECIMAL(10,2) NULL COMMENT '券前/划线价',
  in_stock     TINYINT       NOT NULL DEFAULT 1 COMMENT '1 在售 0 缺货/下架',
  collect_time DATETIME      NOT NULL,
  UNIQUE KEY uk_sku_date (sku_id, collect_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

可运行的采集与提醒脚本

# price_monitor.py —— Python 3.10+,依赖 requests、beautifulsoup4
import json, re, sqlite3, time
from datetime import datetime
import requests
from bs4 import BeautifulSoup
SKUS = [{"sku_id": "demo-1001", "url": "https://example.com/item/1001"}]
INTERVAL = 3.0                      # 同一站点请求间隔,控制在保守区间
DDL = """CREATE TABLE IF NOT EXISTS price_history (
  id INTEGER PRIMARY KEY AUTOINCREMENT, sku_id TEXT NOT NULL, collect_date TEXT NOT NULL,
  price REAL, list_price REAL, in_stock INTEGER NOT NULL DEFAULT 1, collect_time TEXT NOT NULL,
  UNIQUE (sku_id, collect_date));"""
session = requests.Session()
session.headers.update({"User-Agent": "MyResearchBot/1.0 (+contact@example.com)"})
def parse_price(text: str) -> float | None:
    """「券后 ¥1,099.00 起」→ 数值;解析不到返回 None,绝不猜成 0"""
    cleaned = re.sub(r"[^\d.]", "", (text or "").replace(",", ""))
    try:
        return round(float(cleaned), 2) if cleaned else None
    except ValueError:
        return None
def crawl_one(sku: dict) -> dict | None:
    """抓取并解析单个商品页;失败返回 None,不把异常写进历史表"""
    try:
        resp = session.get(sku["url"], timeout=10)
    except requests.RequestException as exc:
        print(f"请求失败 {sku['url']}:{exc}")
        return None
    if resp.status_code != 200:
        print(f"[{resp.status_code}] {sku['url']}")
        return None
    soup = BeautifulSoup(resp.text, "html.parser")
    final, listed = soup.select_one("span.final-price"), soup.select_one("del.list-price")
    now = datetime.now()
    return {"sku_id": sku["sku_id"], "collect_date": now.strftime("%Y-%m-%d"),
            "price": parse_price(final.get_text(strip=True) if final else ""),
            "list_price": parse_price(listed.get_text(strip=True) if listed else ""),
            "in_stock": 0 if "缺货" in soup.get_text()[:2000] else 1,
            "collect_time": now.strftime("%Y-%m-%d %H:%M:%S")}
def upsert(conn: sqlite3.Connection, row: dict) -> None:
    """幂等写入:同一 SKU 同一天覆盖,重复运行不产生重复行"""
    conn.execute(
        "INSERT INTO price_history(sku_id, collect_date, price, list_price, in_stock, collect_time) "
        "VALUES(:sku_id, :collect_date, :price, :list_price, :in_stock, :collect_time) "
        "ON CONFLICT(sku_id, collect_date) DO UPDATE SET price=excluded.price, "
        "list_price=excluded.list_price, in_stock=excluded.in_stock", row)
    conn.commit()
def check_drop(conn: sqlite3.Connection, sku_id: str) -> str | None:
    """对比昨日价与近 7 天最低价;实用时要加最小降幅阈值与静默期"""
    rows = conn.execute(
        "SELECT price, in_stock FROM price_history WHERE sku_id=? AND price IS NOT NULL "
        "ORDER BY collect_date DESC LIMIT 8", (sku_id,)).fetchall()
    if len(rows) < 2:
        return None
    (today, in_stock), (yesterday, _) = rows[0], rows[1]
    if in_stock == 0:
        return f"{sku_id} 当前缺货,最近价格 {today} 元"
    if yesterday and today < yesterday:
        drop = round((yesterday - today) / yesterday * 100, 1)
        return f"{sku_id} 降价 {drop}%:{yesterday} → {today} 元"
    return None
if __name__ == "__main__":
    conn = sqlite3.connect("price.db")
    conn.executescript(DDL)
    alerts = []
    for sku in SKUS:
        row = crawl_one(sku)
        time.sleep(INTERVAL)
        if row is None:             # 抓取失败不写库,避免假数据污染趋势
            continue
        upsert(conn, row)
        if message := check_drop(conn, sku["sku_id"]):
            alerts.append(message)
    print(json.dumps({"alerts": alerts}, ensure_ascii=False))
    conn.close()

频率与并发建议

商品规模采集频率与并发说明
< 50 个 SKU每天 1~2 次,单线程典型自用规模,绝不值得高频轮询
50 ~ 5000 个 SKU每天 1 次夜间分批,并发 1~4需代理或降低单机频率,先确认条款允许

价格监控的价值来自趋势而非实时性:多数决策每天一次数据就够,把频率从「每分钟」降到「每天」,风险与成本会下降一个量级;量级再大(> 5000 个 SKU)就该改用官方开放平台或授权数据源。

常见坑

现象原因处理
价格忽高忽低券前价与券后价混存分列存 pricelist_price,比价只用 price
缺货被记为 0 元无价格时写入了 0无价格写 NULL,缺货写 in_stock=0
降价提醒反复触发与昨日比且小幅波动加最小降幅阈值(如 ≥ 3%)与静默期

小结:价格监控的第一性问题是合规——自用比价、内部分析、对外再分发门槛完全不同,商业再分发必须取得授权,能走官方开放平台就不要自抓;技术侧把监控粒度定在 SKU,价格必须区分券前券后并记录采集时间,历史表用 (sku_id, collect_date) 唯一键保证幂等;提醒逻辑要加最小降幅与静默期,宁可少提醒也不要天天报假警。

笔记加载中…