产品图册 AI 一张都没抓过:ImageSitemap 图片站点地图的写法与提交实践
适用读者:外贸/跨境独立站的技术负责人、负责 SEO 的工程同学。前提是常规页面站点地图已经提交过、robots.txt 也放开了,但 Google 图片长期显示「已发现 0」、AI 搜索里搜自家型号一张产品图都出不来。你需要有产品库的读权限,以及一点点写脚本的习惯。
那天下午,做卫浴出口的那位老板把笔电屏幕转过来给我看:620 个产品页,图册 4,286 张,Google 图片那边长期挂着一个刺眼的 0。站点上线六年,从前端渲染到 CDN 日志,一条链路上没有任何一张产品图被真的拉取过。外贸站的询盘本来就高度依赖「看图选型」,图片索引为零这件事,在生成式引擎优化(Generative Engine Optimization, GEO)语境里比自然排名掉几位严重得多——AI 搜索回答「选哪种尺寸的独立浴缸」时连你的图都拿不到,就不会把你算进候选。
先把事情定位:图片一张没抓,问题出在哪
先看 robots.txt 和 CDN 日志,排除两类低级错误:robots 里没 Disallow 图片目录,CDN 日志里 googlebot 的 UA 出现过但只 GET 了 HTML。接着开无头浏览器把产品页渲染出来对比:<img src> 一个都没有,全是 data-src,真正的地址塞在一个相册弹层的 JS 数组里,靠 IntersectionObserver 触发才回填。

换句话说,服务端吐出来的 HTML 里,一张图片的 URL 都不存在。搜索引擎解析到这份 HTML,看不到任何可以直接 GET 的图片地址,后续也就无从谈起。这里有个很容易混淆的点:页面被收录 ≠ 图片被收录,两者是两条独立的流水线。
图片是怎么被发现的:三条路径的机制剖析
搜索引擎要入图片索引,得先「知道」这张图的地址。知道的路径一共三条,任何一条通了理论上都能被发现,但速度和覆盖率差别很大。这属于底层的机制剖析,值得花两分钟看完,因为后面所有工程动作都是在给这三条路补缺口。
路径一:解析页面 HTML 时顺手拿到
抓取器下载 HTML,解析 DOM,把 <img src>、<source srcset>、<link rel="preload" as="image">、部分 CSS url() 里的地址收成线索。这条路最自然,但前提是服务端渲染出来的初始 HTML 里就有值。JS 回填的 src 在纯 HTML 抓取阶段等于不存在;部分渲染器会在第二轮执行 JS 再补一次,那个队列排期很后面,图册这种体量基本排不到。
路径二:图片站点地图把名单直接递过去
这就是本文的主角:图片站点地图(Image Sitemap)。它不改标准 sitemap 协议,只是在其上靠 xmlns:image 命名空间做扩展,把每个页面 URL 下面挂了哪些图片写成一份名单。这条路绕过了渲染,是目前对付懒加载最干净的办法——你不必为了抓取器去改前端交互,只要额外给一份机器可读的清单。
路径三:从别的站点顺着链接摸过来
别人转载你的产品图、图片素材站收录你的实拍、采购平台引用了你的图床地址,抓取方可以从那些页面的 DOM 里反向发现 URL。这条路完全不受你控制,覆盖率随机,适合解释「为什么偶尔会有零星几张进来」,不适合当方案。
懒加载为什么让三条路一起断
懒加载本身没错,带宽和首屏体验都靠它。错在实现:只用 data-src 承载地址,并且没有任何 HTML 层的兜底。于是路径一在解析阶段无值可拿;路径二因为没提交过图片 sitemap 而不存在;路径三依赖别人的转载,等于零。三条路同时断,结果当然是 0。
lastmod 只是投票,不是命令
很多人以为把 lastmod 改成当前时间就能让 URL 立刻被抓。它实际是「这张图最近变过」的一个信号,用来辅助调度:同一次调度里,lastmod 更新的资源优先级更高,额度紧张时会先照顾它们。它不保证抓取,也不能替代 robots 的放行。反过来更有价值:sitemap 里已经不存在的图,抓取方会逐步剔除,这才是 lastmod 和清单修剪最实用的地方。三条路径的取舍整理如下。
| 发现路径 | 触发条件 | 懒加载站点能否生效 | 覆盖率 | 适用判断 |
|---|---|---|---|---|
| 页面 HTML 解析 | 初始 HTML 里有 src/srcset |
不能,JS 回填前无值 | 取决于渲染队列排期 | 静态页够用,SPA/懒加载相册不够 |
| 图片站点地图 | 提交过 image sitemap 且 robots 放行 | 能,名单直给 | 取决于清单完整度 | 图册体量大时的主路径 |
| 外部引用反发现 | 第三方页面引用了你的图床地址 | 能,但不由你控制 | 随机、极低 | 只能当补充信号 |
字段怎么写:image:* 里每个值究竟喂给谁
image sitemap 的命名空间是 http://www.google.com/schemas/sitemap-image/1.1,所有扩展标签都要带 image: 前缀。最常写错的是 loc 和 license 这两个,下面这张表按「写错会怎样」排了一遍。
| 字段 | 层级与是否必填 | 正确写法 | 常见错误 | 喂给谁 |
|---|---|---|---|---|
image:loc |
<image:image> 下必填 |
图片资源的完整 URL,含协议和域名 | 写成页面 URL;写成相对路径;带 tracking query | 抓取队列,这份清单里真正被 GET 的地址 |
<loc> |
<url> 下必填 |
承载图片的页面 URL | 写成图片 CDN 域名,导致找不到宿主页面 | 图片搜索的来源页归属与跳转 |
image:title |
可选 | 这一份物料的具体名称 | 全站写同一个站点名堆关键词 | 图片搜索的标题与悬浮文案 |
image:caption |
可选 | 一两句描述画面里有啥 | 和 title 复制粘贴 | 语义理解,AI 搜索提取图片意图的主要来源 |
image:geo_location |
可选 | 图源地、拍摄或适用地点 | 拿它代替「产地维基地点」 | 有地理意图的检索 |
image:license |
可选 | 指向许可条款页的真实 URL | 写字面「版权所有」四个字 | Licensing 过滤与 AI 结果的用途标注 |
image:video |
可选 | 视频抽帧图所属的视频 | 用来塞 gallery 页 | 视频源归属 |
image:caption 值得多说一句。它本来是给图片搜索当说明文字的,现在在 AI 搜索里更像一张图的自然语言注释:多模态检索判断这张图表达了什么、该不该在回答里调用,很大程度上读的就是 caption 和宿主页正文。title 要短,caption 要具体,两者别复制。
一份能落地的 image sitemap
依赖只有一件:能自己 gzip 就行。文件名前缀保持一致,方便后面按前缀在 Nginx 日志里反查。下面这份是真实用的结构,删到一个 URL 两张图。
<?xml version="1.0" encoding="UTF-8"?>
<!-- 分片 03:卫浴品类图册,按产品首字母切分 -->
<!-- xmlns:image 命名空间漏写,整份文件会被当作普通 sitemap 忽略 -->
<!-- 单个分片上限:5 万条 URL、未压缩 50MB,这里按 4000 条一片切 -->
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
<url>
<!-- loc 必须是承载这张图的页面 URL,不是图片地址 -->
<loc>https://example.com/products/arc-freestanding-bathtub-1700</loc>
<!-- 图有没有改动的时间信号,用产品的更新时间,别用今天 -->
<lastmod>2026-08-19T10:12:00+08:00</lastmod>
<image:image>
<!-- 图片资源的完整 URL,必须能被直接 GET 拿到原始字节 -->
<image:loc>https://img.example.com/arc-1700/front-wide.jpg</image:loc>
<!-- title 短、具体,写到「哪一款的哪一面」 -->
<image:title>Arc 1700 独立浴缸 正面 亚克力哑光白</image:title>
<!-- caption 描述画面,AI 搜索读它判断图片语义 -->
<image:caption>1700mm 亚克力独立式浴缸正面视图,哑光白缸体配同色落水</image:caption>
<!-- license 指向许可条款页的真实地址,不是一句「版权所有」 -->
<image:license>https://example.com/license/product-photo</image:license>
</image:image>
<image:image>
<!-- 同一个产品页的多张图共用这个 <url>,不要拆出去 -->
<image:loc>https://img.example.com/arc-1700/side-detail.jpg</image:loc>
<!-- 第二张走细节视角,和前一张 caption 不重复 -->
<image:title>Arc 1700 独立浴缸 侧面裙边细节</image:title>
<!-- 有地理属性的图才写 geo_location,其余留空即可 -->
<image:geo_location>Jinhua, Zhejiang</image:geo_location>
</image:image>
</url>
<!-- 实际文件里会重复上面这个 <url> 块几千次 -->
</urlset>
一个 URL 下最多允许 1000 个 <image:image>,正常图册根本用不到,真正的坑在于重复:同一张图挂在不同页面下会被当成多份资源分别入队,抓取额度就这么浪费掉了。
从产品库批量生成:脚本长什么样
依赖:Python 3.10+、SQLAlchemy 2.x、lxml 4.9(XML 转义交给库,别手拼字符串)、requests 2.32。数据源是 MySQL 的 products 和 product_gallery 两张表,字段里有主图、相册图、起订量和更新时间。
flowchart TD
A["产品库 products / product_gallery"] --> B["SQL 拉取:主图 + 相册图 + 更新时间"]
B --> C["URL 归一化:补协议域名 / 去掉追踪参数"]
C --> D["HEAD 校验:状态 200 且 Content-Type 是 image/*"]
D --> E["挂在所属产品页 loc 下,拼 image:image 节点"]
E --> F["按 4000 条 URL 一片切分"]
F --> G["写文件并 gzip,落到 /sitemap/ 目录"]
G --> H["所有分片汇总生成 sitemap index"]
H --> I["robots.txt 追加 Sitemap: 声明"]
I --> J["Search Console 提交 index,API 常驻盯状态"]
核心生成逻辑如下,全站跑一遍大概三分钟。
# -*- coding: utf-8 -*-
# 依赖:Python 3.10+ / SQLAlchemy 2.x / lxml 4.9 / requests 2.32
# 从产品库批量生成 image sitemap 分片
from datetime import date
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
# lxml 负责 XML 转义,避免标题里的 & 把整份文件写坏
from sqlalchemy import create_engine, text
SHARD_SIZE = 4000 # 单个分片的 <url> 条数上限
SITE_HOST = "https://example.com"
OUT_DIR = "public/sitemap" # 输出目录,需已在 Nginx 侧放行
# 拉全站图册:主图排第一位,相册图按 sort 排序
# 下架产品的图不进清单,避免占用抓取额度
SQL = """
SELECT p.slug, p.title_en, p.updated_at,
g.url AS img_url, g.caption, g.is_main
FROM products p
JOIN product_gallery g ON g.product_id = p.id
WHERE p.status = 'published' AND g.is_deleted = 0
ORDER BY p.slug, g.is_main DESC, g.sort ASC
"""
def normalize(u: str) -> str:
# 去掉 utm 之类的追踪参数,同一张图别因为参数不同产出两份 URL
parts = urlsplit(u.strip())
if not parts.scheme:
# 库里存 /uploads/xxx.jpg 这种相对路径时,补成带域名的完整 URL
return SITE_HOST + parts.path
keep = [(k, v) for k, v in parse_qsl(parts.query) if not k.startswith("utm_")]
# 重建 URL,query 部分只保留非追踪参数
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(keep), ""))
def flush(rows: list, shard: int):
# rows 是按产品页分组好的 [(loc, lastmod, [(img, caption), ...]), ...]
if not rows:
# 最后一片刚好为空时跳过,避免生成空文件
return None
# 分片号补零到三位,字典序排序时等于数字序
name = f"image-sitemap-{shard:03d}.xml"
with open(f"{OUT_DIR}/{name}", "w", encoding="utf-8") as f:
# XML_HEAD 里含 xmlns 与 xmlns:image 两个命名空间声明
f.write(XML_HEAD)
for loc, lastmod, imgs in rows:
f.write(" <url>\n")
f.write(f" <loc>{esc(loc)}</loc>\n")
# lastmod 用产品更新时间,不要写脚本运行时间
f.write(f" <lastmod>{lastmod}</lastmod>\n")
for img_url, caption in imgs:
f.write(" <image:image>\n")
f.write(f" <image:loc>{esc(img_url)}</image:loc>\n")
# caption 缺失时退回产品英文名,保证语义字段非空
f.write(f" <image:caption>{esc(caption)}</image:caption>\n")
f.write(" </image:image>\n")
f.write(" </url>\n")
# 去重放在 SQL 阶段做,这里不再二次判断
f.write("</urlset>\n")
return name
def main():
# 用只读账号,别拿写权限账号跑批处理
engine = create_engine("mysql+pymysql://ro:**@db/products?charset=utf8mb4")
buf, shard, files = [], 1, []
with engine.connect() as conn:
# mappings() 让行可以按字段名取值,比下标可读性好
for row in conn.execute(text(SQL)).mappings():
loc = f"{SITE_HOST}/products/{row['slug']}"
img = normalize(row["img_url"])
cap = (row["caption"] or row["title_en"] or "").strip()
# 同一个产品页的图片合并到一个 <url> 节点下
if buf and buf[-1][0] == loc:
buf[-1][2].append((img, cap))
else:
# updated_at 转 ISO 日期字符串写进 lastmod
buf.append((loc, row["updated_at"].date().isoformat(), [(img, cap)]))
if len(buf) >= SHARD_SIZE:
# 够一片就落盘,避免大列表常驻内存
files.append(flush(buf, shard))
shard += 1
buf = []
# 收尾:把不足一片的剩余行写出去
files.append(flush(buf, shard))
print(f"shards={len(files)}")
esc 就是 lxml.etree 的转义封装,这里不展开。重点是发出去之前先 HEAD 一遍:我习惯在这步之后加一次并发校验,把返回非 200、或 Content-Type 不是图片的资源单独打到一张 bad_urls.csv,这份表后面会救你一次命。
分片、压缩、index:三个容易被忽略的细节
第一条是命名一致性:所有分片统一 image-sitemap-NNN.xml 前缀,方便在 Nginx 日志里 grep。第二条是压缩,提交给搜索引擎的是 .xml.gz,能省不少带宽额度。第三条是 index 文件写法,注意 <sitemap> 里的 <loc> 指向的是分片文件本身。
# -*- coding: utf-8 -*-
# 依赖:Python 3.10+ / google-api-python-client 2.x / python-dotenv 1.0
# 生成 sitemap index 并通过 Search Console API 提交
import gzip
from googleapiclient.discovery import build
from google.oauth2.service_account import Credentials
def write_index(files: list[str], today: str) -> str:
# files 是上一步产出的分片文件名列表
# OUT_DIR / SITE_HOST 沿用上一节脚本里的常量
body = ['<?xml version="1.0" encoding="UTF-8"?>',
# index 里每个 <sitemap> 只写一个 loc 和一个 lastmod
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
for name in files:
# 顺手 gzip,搜索引擎拿到 .xml.gz 会自动解压
with open(f"{OUT_DIR}/{name}", "rb") as src, gzip.open(f"{OUT_DIR}/{name}.gz", "wb") as dst:
dst.writelines(src)
body.append(" <sitemap>")
# index 里指向的是压缩后的地址,不是未压缩版本
body.append(f" <loc>{SITE_HOST}/sitemap/{name}.gz</loc>")
# index 自身的 lastmod 用本次跑批日期
body.append(f" <lastmod>{today}</lastmod>")
body.append(" </sitemap>")
body.append("</sitemapindex>")
# index 文件本身不要 gzip,方便人工直接打开核对
open(f"{OUT_DIR}/sitemap-index.xml", "w", encoding="utf-8").write("\n".join(body))
return "sitemap-index.xml"
def submit(sa_json: str, scopes: list, site_url: str):
# 服务账号要先在 Search Console 里加为「完全」权限用户
creds = Credentials.from_service_account_file(sa_json, scopes=scopes)
# cache_discovery=False 关掉 discovery 缓存写入,CI 里少一堆告警
svc = build("webmasters", "v3", credentials=creds, cache_discovery=False)
# sitemaps().submit 是幂等的,重复提交同一个 URL 不会报错
return svc.sitemaps().submit(siteUrl=site_url,
feedpath=f"{SITE_HOST}/sitemap/sitemap-index.xml").execute()
# 域级资源用 sc-domain: 前缀,URL 前缀资源用完整 https 地址
scopes = ["https://www.googleapis.com/auth/webmasters"]
print(submit("sa.json", scopes, "sc-domain:example.com"))
还有两件小事。Google 在 2023 年下线了 /ping?sitemap= 这个接口,还在脚本里 ping 的话会返回 404,别浪费重试次数;Bing 和 Yandex 目前仍然接受,想保留就分着发。另外 robots.txt 里加一行 Sitemap: https://example.com/sitemap/sitemap-index.xml 就够了——这行让任何遵守协议的抓取方第一次访问站点时就能自助拿到名单,不必等你在后台提交。
懒加载别拆,加一层兜底就行
我不要他们改前端。懒加载留着,只在服务端渲染阶段多输出一个 <noscript> 块:里面放前 3 张图的完整 <img>,带 src、alt、width、height。<noscript> 里的 DOM 在纯 HTML 抓取阶段可见,浏览器启用 JS 后又会忽略它,对真实用户体验零影响。
<!-- 懒加载主图:真实地址仍由 IntersectionObserver 回填 -->
<img class="js-lazy gallery-main" data-src="https://img.example.com/arc-1700/front-wide.jpg"
alt="Arc 1700 独立浴缸正面" width="1200" height="800" loading="lazy">
<!-- noscript 兜底:给不执行 JS 的抓取器提供可直接 GET 的地址 -->
<!-- 只放前 3 张,多了没人读,还会拖长首字节时间 -->
<!-- noscript 必须在 body 里,不能塞进 head -->
<noscript>
<!-- alt 要与 image:caption 的口径一致,别两处打架 -->
<img src="https://img.example.com/arc-1700/front-wide.jpg" alt="Arc 1700 独立浴缸正面" width="1200" height="800">
<!-- 第二张是同一款的其他角度,alt 各自独立 -->
<img src="https://img.example.com/arc-1700/side-detail.jpg" alt="Arc 1700 独立浴缸侧面细节" width="1200" height="800">
</noscript>
第二条兜底更轻量:data-src 保留,只额外给首图补一个 <link rel="preload" as="image" href="...">。极端情况下(图册完全由 JSON 驱动、连 DOM 都没有)就退守到只发 image sitemap,那条路径本来就不依赖页面 DOM。补完之后的抓取流程:
sequenceDiagram
participant B as 图片抓取器
participant R as robots.txt
participant I as sitemap index
participant S as 分片文件
participant C as 图片 CDN
participant P as 产品详情页
B->>R: 首次访问取 robots.txt
R-->>B: 返回 Sitemap: 声明的地址
B->>I: GET sitemap-index.xml
I-->>B: 分片列表与各分片 lastmod
B->>S: 优先取 lastmod 有变化的分片
S-->>B: image:loc 清单
B->>C: 逐条 GET 图片资源
C-->>B: 200 且 Content-Type 为 image/*
B->>P: 回查 loc 页面取 caption 与正文
P-->>B: noscript 兜底里的 img alt
B->>B: 入图片索引,供图片搜索与 AI 搜索调用
三十天以后:能看见的数字
提交分两次走,第一天先提交 1 个分片约 400 条做验证,确认转义和命名空间没问题后,第三天补齐剩余 9 个分片。不要一上来就 4000 多张全量提交,先小样本验证可以避开「批量被判数据质量问题」这种返工。下面是 GSC 图片报告里逐周记的数字。
| 时间窗口 | 累计提交 | 已发现 URL | 已抓取成功 | 已入图片索引 | 备注 |
|---|---|---|---|---|---|
| 改造前 30 天基线 | 0 | 0 | 0 | 0 | 只有 112 张早期硬编码的非懒加载图 |
| 提交后第 1-3 天 | 4,286 | 4,286 | 0 | 0 | 小样本分片先过,三天跑满全量 |
| 第 4-7 天 | 4,286 | 4,286 | 1,940 | 610 | CDN 有 128 张返回 403 |
| 第 8-14 天 | 4,286 | 4,286 | 3,505 | 2,270 | 403 修掉后抓取曲线才转正 |
| 第 15-21 天 | 4,240 | 4,240 | 4,102 | 3,180 | 下架产品 46 张从清单剔除 |
| 第 22-30 天 | 4,240 | 4,240 | 4,238 | 3,742 | 索引率约 88%,余下多为同一产品的重复角度图 |
自然搜索流量那三个月涨了 27%,但真正有意思的是另一条线:AI 搜索里带图片的富问答卡片开始出现他们的浴缸实拍,GSC 里 AI 模式的曝光从 0 变成了每周几百次。这批图是整个 GEO 侧少有的、能在 AI 回答里被直接看见的资产。
返工记录:返工了三次才搞定的地方
第一次返工是 Referer 防盗链。CDN 配了 Referer 白名单只允许本站域名,而图片抓取请求通常不带 Referer,于是直接 403。防盗链策略要么放行空 Referer,要么把已知抓取 UA 加白名单。这事不修,前面所有工作等于零。
第二次返工是标题里的 &。手拼 XML 字符串时产品名里的 & 没转义,整份分片 schema 校验失败,GSC 报「XML 解析错误」。换成 lxml 之后彻底消失,所以能用库的地方别手写。
第三次返工是清单过期。产品下架后图还在 sitemap 里跟着跑了半个月,每天浪费几百次抓取。后来加了一条:每日增量重跑,把非 published 状态的产品从清单里剔除,并更新对应 <loc> 的 lastmod。这一轮返工的真实收益,比把 lastmod 改得勤快要有用得多。
参考与延伸
- Google Search Central:Image sitemaps 官方说明,字段定义与示例,https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps
- sitemaps.org:站点地图协议正文,index 与 lastmod 的原始定义,https://www.sitemaps.org/protocol.html
- 图片站点地图 XML Schema:核对命名空间和标签层级,https://www.google.com/schemas/sitemap-image/1.1/sitemap-image.xsd
- RFC 9309:Robots Exclusion Protocol,robots.txt 里
Sitemap:行的最新约定,https://datatracker.ietf.org/doc/html/rfc9309
关键词:GEO、AI 搜索优化、ImageSitemap、图片站点地图、sitemap index、懒加载抓取、外贸独立站 SEO