促销页过期了 AI 还在引用旧价:expires 属性与 Offer 时效清理的排查记录
适用读者:负责外贸独立站技术 SEO 或结构化数据的工程师;正在做生成式引擎优化(Generative Engine Optimization, GEO)落地的运营同学;被「AI 报错价」投诉折磨过的跨境电商团队。
黑五结束后第八周,客服那边转来 11 起价格投诉,用户话术高度一致:在 AI 搜索里问某款户外遮阳篷多少钱,AI 直接报了一个黑五的六折价,点进官网一看,标价回到了原价。运营以为是抓取缓存没刷新,等了两周没变化,才把问题丢给我们。后来我们做了一个月的内部观测,AI 回答里引用旧价的比例从 38% 降到 6%,靠的不是等缓存过期,而是把 Offer 的时效信号补齐了。这事儿值得完整记一笔,因为踩的坑太典型了。
投诉背后:旧促销页其实还「活着」
接手后我没有先去改代码,而是先还原用户的路径。用几个海外 AI 搜索产品分别问同一个问题:「XX 牌遮阳篷多少钱」,其中两家在回答里给出了带折扣的价格,还标了「limited time offer」。顺着 AI 引用的来源链接点过去,落地在一个 promotion/blackfriday-outdoor-canopy/ 的 URL 上。

页面本身已经撤掉了:导航、首页、品类页的入口全下了,站内搜也搜不到。但 URL 直接访问返回 200,内容还是那套六折的促销文案,页面里嵌的 JSON-LD 也原样保留着 price: 89.00 和 priceValidUntil: 2025-12-01。也就是说,对爬虫来说这个页面不但活着,还在持续声明一个已经失效的促销。这是做 GEO 时最容易被忽略的盲区:运营侧的「下架」和技术侧的「下线」根本是两件事。
顺着这条线查了三个地方,把证据列成表:
| 嫌疑点 | 排查方式 | 现场证据 | 结论 |
|---|---|---|---|
| Offer 缺时效字段 | 抓全站 JSON-LD 扫一遍 | 主商品页 Offer 只有 price 和 priceCurrency,没有 expires | 结构化数据本身没写「何时失效」 |
| 旧促销 URL 状态 | curl -I 逐个测 | 黑五专题页和 17 个产品变体页全返回 200 | 撤入口不等于下线,页面仍在被收录 |
| sitemap 推送情况 | 对比 sitemap 快照 | sitemap.xml 里还挂着黑五专题和 6 个变体页 | 每天都在主动告诉引擎「这页值得抓」 |
三个问题叠在一起,AI 引擎持续引用旧价就说得通了。单独看任何一个都不致命,但组合起来就是:页面活着、数据没写失效时间、还主动推送,AI 没有任何理由不信它。
AI 引擎为什么会一直信旧价:机制层面拆一下
这一节讲原理,不搞清楚机制,后面的修复动作很容易做成「改了但没用」。
传统搜索引擎处理时效主要靠两件事:抓取频率衰减和页面内容变化检测。一个页面长期不变,抓取间隔会从几天拉长到几周,这对排名影响有限,因为结果页展示的摘要本来就容易滞后,用户也能容忍。
生成式引擎不一样。AI 在生成回答时是从自己的索引语料里抽取具体事实——价格、库存、日期——然后组织成自然语言。它对「这个价格还准不准」的判断,严重依赖页面里机器可读的时效声明。schema.org 给 Offer 这类实体提供了 expires 和 priceValidUntil 两个字段,作用就是告诉消费方:这条报价在什么时候之前可信。页面里不写,引擎只能自己猜,而猜的策略通常是保守取旧——宁可引用已经索引到的旧价,也不冒编错的风险。
用一张时序图看完整的引用链路:
sequenceDiagram
participant C as 爬虫
participant P as 促销页(200)
participant I as AI索引
participant U as 用户
C->>P: 黑五期间抓取,读到 price=89 + priceValidUntil
P->>I: 结构化数据入库,标注促销价
Note over I: priceValidUntil 过期后无页面更新信号
C->>P: 两周后重抓,页面无变化,间隔拉长
U->>I: 问「这款多少钱」
I->>U: 引用索引里的 89 美元旧价
关键卡点在中间那行 Note:priceValidUntil 是 2025-12-01,说明促销那会儿数据其实是规范的,坏就坏在过期之后没有任何动作——页面没下线、数据没更新、sitemap 没清。时效字段只在「被正确消费」的前提下有意义,写了不管等于白写。
还有一层容易被忽略:AI 引擎对 200 状态码的处理相当宽容。页面返回 200,它就认为内容有效;哪怕正文里小字写了「活动已结束」,结构化数据里的 Offer 依然是机器可读的第一事实来源,正文那行小字根本进不了索引优先级。这也是为什么「撤入口」这种运营侧操作对 GEO 完全无效。
修复清单:从字段、状态码到 sitemap 逐项处理
我们按影响面从大到小排,分四周做完。第 1 周处理结构化数据,第 2 周处理过期 URL,第 3 周清理 sitemap 并上增量校验,第 4 周做观测复盘。整体执行顺序用一张流程图交代:
flowchart TD
A[第1周: 补 Offer 时效字段] --> B{促销页还要保留吗}
B -- 不保留 --> C[第2周: 返回 410 Gone]
B -- 有外链要回收 --> D[301 到品类页]
C --> E[第3周: sitemap 移除死链]
D --> E
E --> F[CI 加 URL 状态校验]
F --> G[第4周: 观测旧价引用占比]
expires 怎么写,priceValidUntil 管什么
先把两个字的分工说清楚,很多文章把这俩混着讲,实际上一线用起来是两码事:
- priceValidUntil 只管价格,声明「这个 price 在某日期前有效」,语义最窄,只该出现在真促销场景;
- expires 管整个 Offer 实体的生命周期,到了日期整条 Offer 都不该再被当作有效报价引用;
- 长期在售的常规商品,两个都不用写,价格变了改 price 本身就行。
黑五那批页面当时的做法是只写 priceValidUntil,方向没错,缺的是过期后的善后。补全后的写法长这样(JSON-LD,schema.org 版本 28 语境下校验通过):
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Outdoor Pop-up Canopy 10x10",
"offers": {
"@type": "Offer",
"price": "89.00",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"priceValidUntil": "2025-12-01",
"url": "https://example.com/blackfriday-outdoor-canopy/"
}
}
注意这段是「当时的问题现场」,expires 一行都没有。真正该补的是促销结束后,要么把页面 410 掉让整条数据失效,要么在确定要保留页面一段时间时,把 expires 设为促销结束日,让引擎明确知道这条 Offer 已经过期。对常规商品页,我们的模板从这轮之后统一加了一行兜底:Offer 上默认不带任何时效字段,只有促销系统注入的 Offer 才会带 priceValidUntil 和 expires,两个日期一致,避免出现「价格没过期但实体过期了」的分裂状态。
过期页该 404、410 还是 301
这一步决策直接决定旧价多久从索引里消失,我们内部讨论了两轮:
| 方案 | 适用场景 | 对旧价引用的影响 | 我们的选择 |
|---|---|---|---|
| 301 到品类页 | URL 有外链权重要回收 | 引擎会把旧页当新页理解,旧 Offer 语义被冲掉,但收敛慢 | 保留少量高外链页 |
| 404 | 临时页面、确实删错了 | 一至两个月内退出索引 | 少量 |
| 410 Gone | 明确「故意下线、不再回来」 | 信号比 404 强,多数页面两周内掉出抓取队列 | 黑五页全用这个 |
17 个变体页里,15 个直接 410,2 个外链较多的 301 到对应品类页。这里有个取舍要说:301 能保权重,但也意味着引擎会花更长时间理解「旧内容没了」;对纯促销页这种生命周期明确的东西,410 是更诚实的信号。Google 的文档也建议永久移除内容用 410 或 404,这个官方立场和我们的实测一致。
sitemap 增量清理,别再给旧页续命
sitemap 是三个问题里最冤的一个:页面都 410 了,sitemap 还在每天推送,等于反复告诉引擎「来抓这个已死的页」,抓取配额被无意义消耗。修复动作分两步,这也是 GEO 治理里性价比极高的一环:
- 立即从 sitemap.xml 移除全部已 410/404 的 URL,当天生效;
- 在 CI 里加一道校验,sitemap 生成任务跑完后自动抽检 URL 状态码,非 200 直接让构建失败。
改完之后 sitemap 从 1204 条降到 1181 条,删掉的 23 条全是历史促销遗留。这个数字不小,说明站上可能一直存在「页面死了 sitemap 没同步」的暗坑,只是以前没人付出过代价。
一段全站扫描脚本:把过期 Offer 揪出来
光修这一批不够,得有个能反复跑的东西。下面这个脚本做四件事:读 sitemap、逐页测状态码、抽 JSON-LD 里的 Offer、把「缺时效字段的促销 Offer」和「已过期的 Offer」都报出来。依赖 Python 3.10+、requests 2.31、beautifulsoup4 4.12,先 pip install requests beautifulsoup4。
# scan_stale_offers.py — 全站扫描过期/缺时效的 Offer
# 用途:给每周定时任务跑,产出可以直接开工单的问题清单
import json
import re
import sys
# datetime 只用到 date 和 strptime,不用引整个 time 模块
from datetime import date, datetime
import requests
from bs4 import BeautifulSoup
# requests 必须显式带 UA,部分海外 CDN 会直接拦默认的 python-requests
SITEMAP_URL = "https://example.com/sitemap.xml"
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; OfferAuditor/1.0)"}
# 站点规模大时建议加并发,这里为了日志可读保持串行
# 促销页 URL 的识别规则,按自家路由改
# 注意大小写不敏感,别让 /BlackFriday/ 漏网
PROMO_PATTERN = re.compile(r"/(promotion|blackfriday|sale)/", re.I)
def fetch_sitemap_urls(sitemap_url):
# sitemap 可能多层嵌套,这里只处理单层 index 的常见情况
# 多层嵌套的站建议先递归展开再进这个函数
xml = requests.get(sitemap_url, headers=HEADERS, timeout=15).text
# BeautifulSoup 解析 XML 要装 lxml 才有 xml 解析器可用
soup = BeautifulSoup(xml, "xml")
return [loc.text for loc in soup.find_all("loc")]
def extract_offers(html):
# 只取 application/ld+json 脚本,忽略其他微数据格式
# microdata 和 RDFa 不在扫描范围,我们站上也不用它们
offers = []
soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all("script", type="application/ld+json"):
try:
# tag.string 可能为 None,空脚本直接当坏数据处理
data = json.loads(tag.string or "")
except json.JSONDecodeError:
# JSON-LD 写坏的情况也要报出来,不能静默跳过
# 这种坏数据在 AI 引擎侧等于没有数据
offers.append({"@type": "INVALID_JSON"})
continue
# @graph 包裹的结构需要展开一层再找 Offer
nodes = data.get("@graph", [data])
for node in nodes:
# 顶层直接挂 Offer 的页面也要覆盖到
if node.get("@type") in ("Offer", "AggregateOffer"):
offers.append(node)
# Product 节点里的 offers 才是主力,单独拎出来
# 这里写得绕是因为 offers 可能是对象也可能是数组
raw = node.get("offers")
subs = raw if isinstance(raw, list) else ([raw] if raw else [])
for sub in subs:
# 非字典的脏值直接丢,别让它中断整个扫描
if isinstance(sub, dict):
offers.append(sub)
return offers
def audit(url, today=None):
# today 参数留出来是为了方便回放历史日期做测试
today = today or date.today()
issues = []
resp = requests.get(url, headers=HEADERS, timeout=15)
# 已经 410/404 的页面不用看数据,本身就是要清理的对象
# 报出来是为了跟 sitemap 做交叉比对
if resp.status_code in (404, 410):
return [f"[{resp.status_code}] 页面已下线但可能仍在 sitemap:{url}"]
# 非 200 非 404 的都归为异常,人工看
if resp.status_code != 200:
return [f"[{resp.status_code}] 异常状态码:{url}"]
# 逐条 Offer 检查,一个页面可能有多个变体报价
for offer in extract_offers(resp.text):
# 两个字段都取出来,后面统一校验
p_valid = offer.get("priceValidUntil")
p_expires = offer.get("expires")
# 字段存在但日期格式不合规,schema 要求 ISO 8601
# 美式 12/01/2025 这种写法在这里会被拦下
for field, val in (("priceValidUntil", p_valid), ("expires", p_expires)):
if val and not _parse_date(val):
issues.append(f"日期格式非法 {field}={val}:{url}")
# 促销页却没有 priceValidUntil,属于黑五复盘里发现的头号问题
# 有 price 才检查,纯内容页的 Offer 不算
if PROMO_PATTERN.search(url) and offer.get("price") and not p_valid and not p_expires:
issues.append(f"促销页 Offer 缺时效字段:{url}")
# 已过期的字段还挂在 200 页面上,说明页面该下线了
for field, val in (("priceValidUntil", p_valid), ("expires", p_expires)):
# 解析失败的情况上面已经报过,这里跳过即可
d = _parse_date(val) if val else None
if d and d < today:
issues.append(f"{field} 已过期({val}) 但页面仍返回 200:{url}")
return issues
def _parse_date(val):
# ISO 8601 只支持到「日期」粒度就够用
# 带时分秒的写法按不合规处理,schema 里就是这么定的
try:
return datetime.strptime(val, "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
if __name__ == "__main__":
# 用法:python scan_stale_offers.py [sitemap 地址]
# 不传参数就用脚本里配的默认值
sitemap = sys.argv[1] if len(sys.argv) > 1 else SITEMAP_URL
all_issues = []
for u in fetch_sitemap_urls(sitemap):
all_issues.extend(audit(u))
# 输出按 URL 聚合,方便直接开工单
# 重定向到文件就能当工单附件用
for msg in all_issues:
print(msg)
print(f"\n共发现 {len(all_issues)} 条问题")
第一次跑这个脚本,全站扫出 41 条问题:28 条促销页 Offer 缺时效字段、9 条已过期字段还挂在 200 页面上、4 条日期格式写成 12/01/2025 这种美式写法。修完之后把它挂进每周的定时任务,过期 Offer 这类问题再没漏网过。
改造前后的完整对比
四个星期全部做完后的状态,跟开工前放在一起看:
| 检查项 | 改造前 | 改造后 |
|---|---|---|
| 促销页 Offer 时效字段 | 全部缺失 expires,仅旧促销带过期 priceValidUntil | 促销 Offer 统一带 priceValidUntil + expires,日期对齐 |
| 过期促销页状态码 | 18 个 URL 全部返回 200 | 15 个 410、2 个 301、1 个改造成常青内容页 |
| sitemap 中的死链 | 23 条失效 URL 持续推送 | 移除并新增 CI 校验,构建期阻断 |
| AI 回答引用旧价占比(内部观测) | 38%(第 1 周采样 50 次) | 6%(第 4 周采样 50 次) |
| 价格类客诉 | 8 周 11 起 | 修复后 3 周 1 起 |
要说明一点:38% 到 6% 是我们自己抽样的观测数据,采样方法和样本量都没到能公开发表的标准,只用于内部决策,别当成普适基准。趋势是可信的——时效信号补齐加死链清理之后,旧价引用明显掉头向下。
两个容易踩的误区和一点趋势判断
第一个误区是「撤了入口就等于下线」。运营视角里专题页消失了,技术视角里它只是不可发现,对爬虫来说路径直达照样 200。这条经验后来写进了我们团队的上线检查单:任何促销活动结束当天,必须执行 URL 下线动作,不能只撤入口。
第二个误区是把 expires 当成万能开关。有个同事一度想给全站商品都加上一年期的 expires,理由是「保险」。这是反效果——常规商品的 Offer 加过期时间,等于每隔一段就让引擎重新评估一遍所有报价,被抓取和索引的节奏反而被打乱。时效字段只给生命周期明确的实体用,这个边界要守住。
趋势上说,AI 引擎对结构化时效信号的权重只会越来越重。现在还只是价格被引用错,接下来库存、交期、保修条款这些字段都会走同一条路——AI 引用的事实粒度越细,对数据里「这一条什么时候不再为真」的要求就越苛刻。趁早把 expires 的治理流程建起来,比等客诉来了再动手省事得多。你们站上有没有遇到过 AI 引用旧价格的案例,欢迎评论区聊聊。
参考与延伸
- schema.org Offer 属性定义(含 expires、priceValidUntil):https://schema.org/Offer
- Google 搜索中心商品结构化数据文档:https://developers.google.com/search/docs/appearance/structured-data/product
- Google 抓取与索引(410/404 与移除内容):https://developers.google.com/search/docs/crawling-indexing/reduce-crawl-load
- sitemap 协议规范:https://www.sitemaps.org/protocol.html
GEO、expires 属性、priceValidUntil、Offer 时效、JSON-LD、外贸独立站、AI 搜索引用