“Was 89.99, now 59.99”(原价89.99,现价59.99)是零售业中最古老的说法之一,也是最容易造假的说法之一:先涨价几周,再”降”回原来的水平。消费者组织、记者、监管机构以及零售商自己的合规团队都在监控价格,以发现这种情况。他们的发现只有在零售商提出异议时仍能站得住脚才有意义,而零售商确实会提出异议。
本指南讨论的是为问责而非定价策略所做的价格监控:折扣声明的规则是什么、如何收集经得起审查的价格证据,以及用代码检验折扣声明与其背后的价格历史是否相符。
这只是一般性信息,不构成法律建议。各国规则不同,任何发布指控或提起投诉的人都应先寻求法律意见。
规则对折扣声明的规定
大多数司法管辖区都将误导性的参考价格视为一种误导性商业行为。欧盟则更进一步,设定了一条具体的、可检验的规则。
- 欧盟。 自2022年5月28日起,任何降价公告都必须显示先前价格,其定义为交易者在降价前至少30天内所执行的最低价格。成员国可以为易快速变质的商品、销售期不足30天的产品以及逐步增加的降价设定不同规则。2024年9月,欧盟法院在一起涉及零售商Aldi Süd的案件中裁定,以百分比宣传的降价,或作为促销”价格亮点”宣传的降价,也必须依据该30天最低价来衡量,而不是依据促销前的价格。
- 英国。 误导性价格比较属于不公平商业行为,自2025年4月起,竞争与市场管理局可以直接裁定违反消费者法的行为并对企业处以罚款,无需先诉诸法院。
- 美国。 联邦贸易委员会的《反欺骗性定价指南》将原价视为比较的合法基础,前提是该价格是该商品在相当长的一段时间内经常向公众提供的真实、善意的价格。
欧盟的规则对监控来说最为有用,因为它把”这个折扣是否真实?”变成了一道算术题:找出促销前30天内的最低价格并加以比较。而这只有在拥有价格历史记录的情况下才可能做到,这也是为什么监控必须在要检验的促销活动开始之前很久就展开的原因。
什么样的价格证据才站得住脚
面对一项发现,零售商会先质疑数据,再质疑结论。证据要站得住脚,就必须提前回答那些显而易见的质疑。
| 质疑 | 应对方式 |
|---|---|
| ”你们的数据是错的。“ | 原始页面,连同其URL、UTC时间戳和哈希值一并保存,以便可以从原始页面重新读取价格 |
| ”你们漏掉了几天。“ | 每日观测记录,报告每个窗口期的覆盖率,覆盖率过低时不做出结论 |
| ”那是另一款产品。“ | 每次观测都记录一个稳定的产品标识符(SKU、EAN或零售商自己的ID) |
| “那是另一个地区的价格。“ | 记录价格适用的地点、门店或配送邮编,并保持不变 |
| ”那是会员价或仅限应用内的价格。“ | 以未登录状态采集公开价格,并记录页面上显示的条件 |
| ”你们挑了最糟糕的例子。“ | 在分析前制定公开的方法和样本,报告所有结果,而非只报告糟糕的那些 |
每日采集在这里比在大多数监控场景中更为重要。关于网络价格的研究表明,每周抽样能够捕捉到大型零售商大部分的常规价格变动,但虚假的参考价格可能就藏在单独一周之内。我们关于网络价格变化速度的研究详细探讨了采集频率问题。采集端的工作,从截图到哈希和时间戳,与构建来自网络商品列表的可用于法庭的证据所遵循的纪律是一样的。
检验折扣声明
下面的函数接收一个产品的每日价格历史和一项公告的降价幅度,并按照欧盟规则的方式对其进行检验。当缺失天数过多时,它会拒绝做出判断,并记录覆盖率,以便读者了解证据的完整程度。第二个函数则存储每次观测记录,带有时间戳和原始页面的哈希值。
import hashlib
from datetime import datetime, timedelta, timezone
def evidence_record(url, raw_body, price, currency):
"""One observation, stored so that anyone can later check what was seen and when."""
return {
"url": url,
"captured_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"sha256": hashlib.sha256(raw_body).hexdigest(),
"price": price,
"currency": currency,
}
def lowest_prior_price(history, start, days=30, min_coverage=0.8):
"""Lowest observed price in the `days` before `start`, if we watched closely enough.
history: {date: price} from daily observations.
Returns (price, coverage); price is None when too many days are missing to say.
"""
window = [start - timedelta(days=d) for d in range(1, days + 1)]
seen = [history[d] for d in window if d in history]
coverage = len(seen) / days
if coverage < min_coverage:
return None, round(coverage, 2)
return min(seen), round(coverage, 2)
def check_discount(history, start, sale_price, shown_prior=None, shown_percent=None):
"""Compare an announced reduction with the lowest price in the 30 days before it."""
prior, coverage = lowest_prior_price(history, start)
result = {"start": start.isoformat(), "sale_price": sale_price, "lowest_30d": prior,
"coverage": coverage, "findings": []}
if prior is None:
result["findings"].append("not enough observations before the sale to judge")
return result
real_percent = round(100 * (prior - sale_price) / prior, 1)
result["real_percent"] = real_percent
if sale_price >= prior:
result["findings"].append("no real reduction: the sale price is not below the 30-day low")
if shown_prior is not None and shown_prior > prior:
result["findings"].append(f"shown prior price {shown_prior} is above the 30-day low {prior}")
if shown_percent is not None and shown_percent > real_percent + 0.5:
result["findings"].append(f"claims {shown_percent}% off, but it is {real_percent}% off the 30-day low")
return result
以下是同一促销日的三个示例性优惠(虚构的价格历史,并非真实零售商):
from datetime import date, timedelta
from discounts import check_discount
sale = date(2026, 11, 27)
def daily(prices_before_sale):
"""Daily observations for the days before the sale, oldest first."""
n = len(prices_before_sale)
return {sale - timedelta(days=n - i): p for i, p in enumerate(prices_before_sale)}
offers = {
"kettle": (daily([49.99] * 40), 39.99, 49.99, 20),
"blender": (daily([59.99] * 25 + [89.99] * 15), 59.99, 89.99, 33),
"toaster": (daily([34.99] * 30 + [29.99] * 10), 27.99, 34.99, 20),
}
for name, (history, price, shown_prior, shown_percent) in offers.items():
result = check_discount(history, sale, price, shown_prior, shown_percent)
print(name, result["lowest_30d"], f"{result['real_percent']}%", result["findings"] or "ok")
kettle 49.99 20.0% ok
blender 59.99 0.0% ['no real reduction: the sale price is not below the 30-day low', 'shown prior price 89.99 is above the 30-day low 59.99', 'claims 33% off, but it is 0.0% off the 30-day low']
toaster 29.99 6.7% ['shown prior price 34.99 is above the 30-day low 29.99', 'claims 20% off, but it is 6.7% off the 30-day low']
- 水壶(kettle) 在整个期间价格都是49.99,现以39.99促销。20%的宣称是准确的。
- 搅拌机(blender) 在大部分期间价格为59.99,在最后15天涨到89.99,然后”降回”59.99,并宣称降价33%。相对于30天最低价,它根本没有降价。这是典型的套路。
- 烤面包机(toaster) 确实有真实降价,但幅度比宣称的要小:在促销前的十天里价格已经是29.99,因此27.99只是降价6.7%,而非20%。
80%的覆盖率阈值是一个主观判断。在开始之前确定一个阈值,将其应用于每个产品,并随结果一并报告。
开展监控项目
- 先确定样本。 在促销季开始前选定零售商、品类和产品,并记录选择原因。在看到价格之后才选定的样本,会招致挑拣证据的指控。
- 尽早开始。 欧盟的检验标准需要促销前至少30天的历史数据。提前六周开始,以应对数据缺口。
- 采集公开价格。 以未登录状态,从固定地点,使用零售商自己的产品页面采集。记录采集地点;价格和促销活动往往是分地区的。
- 记录一切。 对每一次观测都保留原始页面、解析出的价格和证据记录。存储成本很低;缺失的一天是无法弥补的。
- 发布前进行核查。 人工地从原始页面重新核实被标记的价格。许多看似违规的情况其实是解析错误、不同的规格款式或捆绑销售所致。
- 给交易者回应的机会。 记者和消费者组织通常会在发布前将发现告知零售商。回应有时会揭示出一个合理的解释,例如你未曾看到的区域价格。
- 公开方法。 报告样本、日期、采集频率、覆盖率阈值和完整结果。
负责任地采集数据
出于公共利益的监控仍然是数据采集,同样的原则依然适用。以适度的速率采集公开价格,从购物者可以打开的产品页面采集,不要登录。将个人数据完全排除在数据集之外:价格证据需要的是产品和价格,而不是评论者或客户。我们的网络爬取最佳实践涵盖了其余内容。
在价格因国家而异的地方,应在每个市场内部进行采集,以便看到当地购物者所看到的价格。具备国家定位功能的住宅代理是实现这种规模化采集的常用方式,而对每个市场保持地点固定,与采集频率同样重要。
常见问题
价格降价的30天规则是什么?
在欧盟,降价公告必须显示先前价格,即交易者在降价前至少30天内所执行的最低价格。欧盟法院在2024年确认,百分比降价必须依据该价格计算。
我怎样才能判断一个折扣是否造假?
在促销前至少30天内每日跟踪该产品的价格。如果促销价并未低于该期间的最低价格,或者宣称的折扣百分比大于相对于该最低价的实际降幅,那么根据欧盟的规则,该折扣声明很可能具有误导性。
在促销前我需要监控价格多长时间?
欧盟的检验标准要求至少30天,实际操作中大约六周,这样即使漏掉几天也不会导致窗口期数据不完整。
价格监控证据能否用于投诉?
如果记录完善,它可以支持投诉:原始页面、时间戳、产品标识符、价格适用的地点以及公开的方法。在提起投诉或发布指控之前,请先寻求法律意见。
结论
虚假折扣很容易制造,但只要有合适的数据,也很容易被检验出来。欧盟的30天规则把折扣声明变成了与价格历史的比较,因此监控必须在促销活动开始之前很久就展开。要每日采集数据,从固定地点,以未登录状态进行,并为每次观测保留原始证据。在数据不足时拒绝下结论,人工核实每一项发现,并公开方法。以这种方式收集的证据难以被驳回。
来源与参考文献
- 《(EU) 2019/2161号指令》,修订了包括《98/6/EC号价格标示指令》在内的多项指令。
- 欧盟法院,关于C-330/23号案件Aldi Süd的新闻稿,2024年9月26日。
- 美国联邦贸易委员会,《反欺骗性定价指南》,16 CFR第233部分。