产品图 AI 看不懂细节:ImageObject 与 alt 写法差异的 45 天引用对照
适用读者:负责外贸独立站产品页的前端与 SEO 工程师,需要在多语言目录站上落地图片结构化数据的维护者,以及正在被「AI 回答不引用自家产品图」这个问题困住的运营技术负责人。
上个月初,我们在客户会议室做了一轮人工提问测试:把一台数控设备的产品页链接分别丢给三个常用的 AI 引擎,问「这台设备有几个气动接口」。三份回答都把正文第一段的参数描述换了个说法复述了一遍,图片里铭牌上印着的接口数量,一份都没提到。
那张图拍得并不糊,铭牌数字肉眼可辨。文件名叫 IMG_2841.jpg,alt 写的是「产品图」两个字。图片对引擎来说等于不存在。
现场:图片信号断在哪一环
排查用了两天,顺序比工具重要。第一步是 curl 把页面原始 HTML 拉下来看 <img> 标签,抓取器拿到的是未执行 JavaScript 的那一版,而这家站用了一个懒加载脚本,真实地址塞在 data-src 里,src 是一个 1×1 的占位 GIF。第二步是 Search Console 的图片报告,1260 张主图里有展示数据的不到 200 张,而且集中在类目页的那几张 banner 上。第三步是把所有 alt 抄出来做文本量统计,这一步得出的数字最扎眼。
那家站当时的状态是这样的:420 个产品页,每页 3 张主图,共 1260 张。文件名全部是相机默认的 IMG_xxxx.jpg,其中 300 多张还是从微信导出、带 mmexport 前缀的。alt 只有三种写法——「产品图」、「图1」、空字符串,前两种各占一半左右。页脚和证书栏还有 200 多张装饰图,alt 全空。图片没有进 sitemap,产品页的 JSON-LD 只声明了 Product,image 字段直接丢了一个 URL 进去,没有 ImageObject 节点。
flowchart TD
A[抓取器拉取 HTML] --> B[解析 img 与 picture 节点]
B --> C{真实图片地址是否可达}
C -- 否 --> D[记录 404 或占位图, 该图退出链路]
C -- 是 --> E[下载图片二进制]
E --> F[视觉语言模型读图]
F --> G[生成机器描述: 主体 属性 可见文字]
B --> H[抽取 alt / caption / 文件名 / 周边正文]
G --> I[多模态融合 Multimodal Fusion]
H --> I
I --> J{两路信号是否冲突}
J -- 冲突 --> K[作者声明优先, 机器描述降级为补充信号]
J -- 一致 --> L[合并为图片文本表示并加权]
J -- 作者声明为空 --> M[机器描述升为主信号]
K --> N[与页面正文段落做语义对齐]
L --> N
M --> N
N --> O{与提问意图是否重叠}
O -- 是 --> P[进入引用候选池]
O -- 否 --> Q[仅入库, 不参与引用]
**冲突时作者声明优先。** 逻辑不复杂:`alt` 和 `caption` 是作者有意写下的,意图明确;机器描述是概率输出,铭牌上的小号数字、单位符号、易混字母(0 和 O、1 和 l)都容易读错。引擎宁可信一个静态字符串,也不愿把读错的数字写进回答里,因为参数类回答的容错率极低。
但这条优先级有个前提——作者声明得含有信息量。A 档的 `alt="产品图"` 在融合阶段几乎被当成空值处理,此时机器描述会顶上,整张图的文本表示全部来自模型。C 档不一样,`alt`、`caption`、sitemap 的 `image:caption`、JSON-LD 的 `caption` 四处文本互相印证,模型描述只负责补充「铭牌在画面左下角」这类位置信息,两路信号叠起来,图片文本表示的密度才够参数类提问去匹配。
flowchart LR
A[扫描图片 sitemap 的 image:loc] --> B{HTTP 200 且 MIME 为图片}
B -- 否 --> C[标记失效并写入修复清单]
B -- 是 --> D{alt 长度是否在 15 到 125 字符之间}
D -- 否 --> E[打回重写, 记录原值]
D -- 是 --> F{alt 与 caption 是否高度重复}
F -- 重复 --> E
F -- 否 --> G{图片 URL 是否可被索引}
G -- 否 --> H[移出图片 sitemap 并检查 robots]
G -- 是 --> I{尺寸与体积是否达标}
I -- 否 --> J[转码压缩后更新 image:loc]
I -- 是 --> K[保留并进入下一轮对照窗口]
第二张图是我们每周跑一次的校验流程,落成了脚本,下面给出实现。
## 图片 sitemap 生成与 `alt` 校验脚本
环境与依赖:Python 3.10 及以上,仅使用标准库(`xml.etree.ElementTree`、`urllib.request`、`csv`、`re`、`pathlib`)。如果站点图片量超过十万张,把解析部分换成 `lxml.etree` 会快 3 到 5 倍,接口基本一致,需要注意 `lxml` 对命名空间的写法更严格,`ElementTree` 里可用的 `{http://www.sitemaps.org/schemas/sitemap/0.9}url` 这种花括号语法两边都支持。
# -*- coding: utf-8 -*-
# 批量生成图片 sitemap,并对每张图的 alt 做合规校验
# 依赖:Python 3.10+,仅标准库
import csv
import re
from pathlib import Path
# 只用标准库发探测请求,避免为了一个校验脚本引入额外依赖
from urllib.request import Request, urlopen
from xml.etree import ElementTree as ET
# 图片 sitemap 的命名空间,图片扩展必须挂在 url 节点下
NS_SM = "http://www.sitemaps.org/schemas/sitemap/0.9"
NS_IMAGE = "http://www.google.com/schemas/sitemap-image/1.1"
NS_XHTML = "http://www.w3.org/1999/xhtml"
# 注册前缀,否则 ElementTree 会写出一串 ns0 这样的自动前缀
ET.register_namespace("", NS_SM)
ET.register_namespace("image", NS_IMAGE)
ET.register_namespace("xhtml", NS_XHTML)
# alt 与 caption 的长度区间,短于下限信息量不足,长于上限会被截断
ALT_MIN, ALT_MAX = 15, 125
# 零信息量写法,命中即判不合格
EMPTY_ALT = {"产品图", "图片", "img", "image", "photo", "图1", "banner", ""}
# 单张图的行结构:页面地址、图片地址、alt、caption、语言
CSV_PATH = Path("images.csv")
# 生成物统一落到 out 目录,方便整目录提交给引擎做校验
OUT_DIR = Path("out")
def validate_alt(alt: str, caption: str) -> list[str]:
"""返回问题列表,空列表表示这张图的 alt 通过校验。"""
# 一条图可能同时踩多个坑,所以用列表累积而不是提前返回
problems: list[str] = []
# 归一化后比对零信息量写法,避免全角空格和大小写造成漏判
norm = alt.strip().lower()
# 零信息量 alt 直接退出,后面的长度与重复判断对它没有意义
if norm in EMPTY_ALT:
problems.append("alt 无信息量")
return problems
# 长度下限防「分配器」这种单词,上限防整段说明被截断
if not (ALT_MIN <= len(alt.strip()) <= ALT_MAX):
problems.append(f"alt 长度 {len(alt.strip())} 不在 {ALT_MIN}-{ALT_MAX}")
# alt 与 caption 完全一致时,两路信号退化成一路,融合阶段拿不到互证
if caption and alt.strip() == caption.strip():
problems.append("alt 与 caption 完全重复")
# 逗号分隔的关键词堆砌特征:逗号多于 3 个且没有完整句式
if alt.count(",") + alt.count(",") > 3 and "的" not in alt:
problems.append("疑似关键词堆砌")
# 单位符号缺失会让数值类问题无法对齐,例如只写 8 没写 G1/4
if re.search(r"\d", alt) and not re.search(r"(mm|cm|kg|V|A|G\d|/4|寸|口)", alt):
problems.append("含数值但缺少单位")
# 返回空列表表示通过,调用方用 if problems 判断即可
return problems
def http_head_ok(url: str, timeout: int = 8) -> bool:
"""用 HEAD 探测图片是否可达,失败时不抛异常,交给调用方记账。"""
try:
# 带上自定义 UA,部分 CDN 会直接拒绝空 UA 的探测请求
req = Request(url, method="HEAD", headers={"User-Agent": "img-sitemap-check/1.0"})
with urlopen(req, timeout=timeout) as resp:
# 只看状态码不够,还要确认 MIME 是图片,有些错误页也返回 200
ctype = resp.headers.get("Content-Type", "")
return resp.status == 200 and ctype.startswith("image/")
# 超时、证书错误、DNS 失败统一按不可达处理,报表里再人工看
except Exception:
return False
def build_sitemap(rows: list[dict], lang: str) -> ET.Element:
"""按语言生成一份 urlset,一条 url 下挂一张或多个 image 节点。"""
# 同一张图在三个语言版本里的地址不同,alt_url 里存了其余两份地址
# 根节点走 sitemaps 命名空间,图片节点走 image 扩展命名空间
urlset = ET.Element(f"{{{NS_SM}}}urlset")
for row in rows:
# 每个语言版本单独一份文件,行数据在这里按 lang 过滤
if row["lang"] != lang:
continue
url_el = ET.SubElement(urlset, f"{{{NS_SM}}}url")
ET.SubElement(url_el, f"{{{NS_SM}}}loc").text = row["page_url"]
# image:loc 必须写成带协议头与域名的完整 URL,相对路径会被引擎丢弃
img_el = ET.SubElement(url_el, f"{{{NS_IMAGE}}}image")
ET.SubElement(img_el, f"{{{NS_IMAGE}}}loc").text = row["image_url"]
# title 用于图片搜索的标题展示,caption 用于语义融合,两者不要写成同一句
ET.SubElement(img_el, f"{{{NS_IMAGE}}}title").text = row["title"]
ET.SubElement(img_el, f"{{{NS_IMAGE}}}caption").text = row["caption"]
# 多语言站用 xhtml:link 互指,避免三个语言版本被判重复
for other in ("zh", "en", "es"):
link = ET.SubElement(img_el, f"{{{NS_XHTML}}}link")
# rel 固定为 alternate,hreflang 写目标语言代码
link.set("rel", "alternate")
link.set("hreflang", other)
# 同语言指向自身,这是协议要求而不是笔误
link.set("href", row["image_url"] if other == lang else row["alt_url"][other])
return urlset
def main() -> None:
# 输入是一张平铺表:页面地址、图片地址、alt、caption、语言
rows = list(csv.DictReader(CSV_PATH.open(encoding="utf-8")))
# report 三列与输出的 csv 表头一一对应,改列序时两处要一起改
report: list[tuple[str, str, str]] = []
for row in rows:
# 可达性放在最前面,失效图不必再看文本质量
if not http_head_ok(row["image_url"]):
report.append((row["image_url"], "不可达", row["alt"]))
continue
# 每张图的所有问题都写进报表,修图的人只打开一个文件就够
for problem in validate_alt(row["alt"], row["caption"]):
report.append((row["image_url"], problem, row["alt"]))
# 输出目录不存在时自动创建,定时任务里不用额外加一步 mkdir
OUT_DIR.mkdir(exist_ok=True)
# 三份 sitemap 分开输出,各自提交,便于单独观察抓取反馈
for lang in ("zh", "en", "es"):
tree = ET.ElementTree(build_sitemap(rows, lang))
# 缩进只为人工核对,引擎不依赖格式
ET.indent(tree, space=" ")
# 一份文件超过 5 万条要拆包并配 sitemap 索引,这里先按语言拆
# xml_declaration 必须打开,缺少声明时部分校验工具直接判错
tree.write(OUT_DIR / f"sitemap-images-{lang}.xml", encoding="utf-8", xml_declaration=True)
with (OUT_DIR / "alt-report.csv").open("w", encoding="utf-8", newline="") as fh:
writer = csv.writer(fh)
# 表头固定三列,直接丢给内容团队当工单用
writer.writerow(["image_url", "problem", "alt"])
writer.writerows(report)
# 打印一行汇总,方便塞进 CI 的日志里看趋势
print(f"checked={len(rows)} problems={len(report)}")
if __name__ == "__main__":
# 入口保持极简,方便挂到定时任务或 CI 的图片合规检查步骤里
main()
页面上的 `ImageObject` 声明放在产品页原有的 `Product` 节点旁边,两者用 `image` 字段建立引用关系。下面这段为便于阅读保留了 `//` 注释行,部署前删掉注释行即可得到合法 JSON。
// 放在产品页 <head> 内的 application/ld+json 脚本里
// 与 Product 节点同处一个 @graph,用 image 字段互相指向
{
"@context": "https://schema.org",
"@type": "ImageObject",
// @id 用带域名的完整 URL 且全站不重复,方便 Product 用 image 字段指过来
"@id": "https://example.com/products/pneumatic-manifold-8port#primaryimage",
// contentUrl 指向可直接访问的原图,不要给懒加载占位图
"contentUrl": "https://example.com/media/pneumatic-manifold-8port-800.jpg",
// 缩略图用于列表与回答卡片,宽高比与主图保持一致
"thumbnailUrl": "https://example.com/media/pneumatic-manifold-8port-320.jpg",
"name": "铝合金八口气动分配器正面铭牌",
// caption 与页面 figcaption 的文本保持一字不差,融合阶段会被判定为互证
"caption": "八口气动接口的铝合金分配器,正面铭牌标注 8×G1/4,用于自动化产线的末端执行器",
// description 用来复述图上可见文字,给读图模型留一条校验路径
"description": "铭牌位于画面左下角,标注型号与八口接口规格",
// 声明这是该页面的代表图,参数类回答优先取用这一张
"representativeOfPage": true,
// 宽高写成 QuantitativeValue,比裸数字更容易被尺寸校验流程读到
"width": { "@type": "QuantitativeValue", "value": 800, "unitCode": "E37" },
"height": { "@type": "QuantitativeValue", "value": 800, "unitCode": "E37" },
// encodingFormat 写真实格式,webp 与 jpeg 混用时要逐图声明
"encodingFormat": "image/jpeg",
"creditText": "产品实拍",
// 图片本身的许可声明,缺失时部分引擎会拒绝在回答中直接展示
"license": "https://example.com/license/photo-usage",
// 与 Product 节点建立弱关联,帮助引擎把图归到正确的实体名下
"about": { "@type": "Product", "@id": "https://example.com/products/pneumatic-manifold-8port#product" }
}
## 误区澄清与接下来十二个月的判断
第一个误区是把 `alt` 当关键词堆场。测试初期我们让一个子目录试过 `气动接口 气动接口厂家 气动接口价格 气动分配器`这种写法,30 天里带图引用是 0 次,比描写细腻的 B 档还差。逗号堆词会把 `alt` 从描述句降级成标签集合,融合阶段拿不到句式结构,与正文的语义对齐反而变难。
第二个误区是觉得文件名改了就够。文件名在候选里权重排得靠后,它主要用于 URL 可读性与相近图片的区分。B 档的收益主要来自描述句而不是 slug,这一点我们做过拆分验证:只改文件名、`alt` 保持「产品图」的那 60 张,30 天里带图引用仍然是 0。
第三个误区是只交 sitemap 不做 `ImageObject`。两件事管的不是同一个环节,图片 sitemap 解决「这张图存在且值得抓」,`ImageObject` 解决「这张图是什么、属于哪个实体、能不能作为该页面的代表图」。C 档里单独抽 60 张只提交 sitemap、不加 JSON-LD 的图做对照,带图引用比例停在中位数附近,明显低于同批次加了两者的图。
接下来一年可以预判的方向是引用粒度下沉。现在的带图引用基本停在「回答里放一张产品图」,下一步会走到「回答直接读出铭牌上的具体数值并标注来自哪张图」。要提前准备的事情很具体:让图片结构化字段里的数值与正文表格里的数值严格一致,把铭牌、标签、面板这类「图上有数字」的素材单独标为一组并优先补 `caption`,同时把图上可见文字在 `description` 里用文字复述一遍,给读图模型留一条校验路径。
工程上最省事的落地顺序是:先修懒加载,让真实图片地址出现在原始 HTML 里;再批量改写 `alt`,长度按 15 到 125 字符控;然后生成按语言拆分的图片 sitemap 并提交;最后补 `ImageObject` 节点,用 `@id` 把图片和 `Product` 绑起来。四件事的顺序不要颠倒,前一件没做完就做后一件,观测数据会被污染,45 天的对照就白跑了。脚本和对照口径都在上面,跑完第一轮拿到 report.csv 的同学,可以在评论区贴一下自己站的 `alt` 不合格率,我们对一下量级。
## 参考与延伸
- [schema.org ImageObject](https://schema.org/ImageObject):`contentUrl`、`caption`、`representativeOfPage` 等属性的权威定义
- [Sitemaps 协议图片扩展](https://www.sitemaps.org/protocol.html#image):`image:loc`、`image:caption`、`image:title` 的官方说明与示例
- [Google 图片 SEO 最佳实践](https://developers.google.com/search/docs/appearance/google-images):图片发现、索引与展示的官方指引
- [Google 图片 sitemap 文档](https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps):图片 sitemap 的构建要求与常见错误
*关键词:ImageObject, alt 文本, 图片 sitemap, 多模态抓取, 结构化数据, 生成式引擎优化, AI优化AIO*