MapGuess : CSR项目SEO复盘

这是 MapGuess(mapguess.net)做 SEO 的复盘。项目技术栈是 Vite + React Router,部署在 Vercel,没有 SSR,纯客户端渲染。整理记录了我踩过的坑和最终跑通的流程。

MapGuess : CSR项目SEO复盘
Photo by Growtika / Unsplash

架构约束

这个项目没有服务端渲染。页面内容、所有 meta 标签都是 JS 运行时注入的,不是服务端直出 HTML。

这意味着 SEO 能做到什么程度,有一条明确的天花板:Google 这类主流爬虫能执行 JS,通常没问题;但社交平台的爬虫(微信、Twitter 的 unfurl)很多不执行 JS,CSR 注入的 og:image/og:title 它们根本读不到。

我没有因为这个去上 SSR——改造成本太高,而且大多数 SEO 目标在 CSR 下完全可以实现。所以策略是:先把 CSR 能做的做到位,天花板碰到了再说

"服务端能力"主要靠 Vercel Edge Middleware 在 CDN 边缘处理重定向和规范化,这是整个多语言 SEO 策略的支点。


第一步:先把 SEO 做成组件

最开始我想的是在每个页面文件里手写 <Helmet>,但很快发现这会带来两个问题:一是容易漏,二是每个页面的 canonical、hreflang 生成逻辑都得重写一遍,改一个规则要改 N 个文件。

所以我封装了一个统一的 SEO 组件,页面只管传业务参数(标题、描述、关键词、面包屑),其他全部在组件内部处理:

// 页面使用方式,只传参数
<SEO
  title={t('seo.home.title')}
  description={t('seo.home.description')}
  keywords={t('seo.home.keywords')}
  schemaType="Game"
  breadcrumbs={[{ name: t('nav.home'), item: '/' }]}
/>

组件内部统一处理:

  • title / description / keywords / robots
  • canonical:规则是 https://www.mapguess.net/{lang}{path},每个页面自动生成
  • hreflang:zh / en / ja / x-default 四条,当前路径自动替换语言前缀
  • OpenGraph / Twitter Card:og:title / og:description / og:image / twitter:card 等
  • JSON-LD:Organization 默认注入,Game 和 BreadcrumbList 按需启用

HelmetProvidermain.tsx 全局注入一次,所有页面共用。

这套结构最大的好处是:canonical 规则改一处,全站生效;hreflang 漏掉某个语言版本,改组件一次解决。不用去每个页面 grep。


第二步:多语言路由结构

URL 结构上选的是语言前缀子目录:/zh/.../en/.../ja/...,根路径 / 不承载内容,只做语言分发。

这个方案与子域名(zh.mapguess.net)相比——不用多个域名,CDN 配置简单;比参数(?lang=zh)更干净——URL 可读,搜索引擎友好。

hreflang 的意义是告诉搜索引擎"这三个页面是同一内容的不同语言版本",减少跨语言页面互相竞争同一关键词,也提升对应语言用户命中正确版本的概率。


第三步:中间件的重定向策略,以及我踩的两个坑

这是我印象最深的一块,踩了两个坑,都是自己切换语言测试时发现的。

坑一:根路径用了 301,浏览器缓存"帮倒忙"

最初的逻辑很直觉:用户访问 /,按 Cookie 或 Accept-Language 判断语言,跳到 /zh/en。既然是"确定性跳转",我下意识写了 301。

问题是 301 是永久重定向,浏览器会把跳转目标缓存下来

  • 第一次访问,浏览器语言是中文 → 中间件判断 → 301 跳到 /zh浏览器记住了"/ 永远去 /zh"
  • 这个用户清了 Cookie,或者换了一台语言设置是英文的设备
  • 再次访问 / → 浏览器直接从缓存取,不经过中间件,直接跳到 /zh

测试时发现很简单:改了浏览器语言偏好,刷新根路径,死活还是跳中文。打开 DevTools → Network,根路径请求直接返回 (from disk cache),中间件根本没被调用。

修法是改成 302,加上 Cache-Control: no-storeVary: Accept-Language, Cookie

// ❌ 根路径用 301,浏览器会永久缓存跳转目标
return Response.redirect(url.toString(), 301);

// ✅ 根路径必须用 302,禁止缓存
return new Response(null, {
  status: 302,
  headers: {
    'Location': url.toString(),
    'Cache-Control': 'no-store, no-cache, must-revalidate',
    'Vary': 'Accept-Language, Cookie',
    'Set-Cookie': `i18next=${locale}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`,
  },
});

根路径的跳转目标天然是"因人而异"的,301 的语义(永久、唯一)在这里根本不成立。

坑二:语言变体规范化用了 301 没错,但路径提取有 bug

zh-CN → zhen-US → en 这类规范化跳转用 301 是对的——目标 URL 是确定的,希望搜索引擎把权重集中到短路径,301 语义完全匹配。

但我当时 restPath 的提取逻辑在边界情况下有问题:当 pathname 正好是 /zh-CN(没有子路径)时,restPath 是空字符串,拼接后变成 /zh,本来是对的;但如果是 /zh-CN/aboutrestPath/about,拼接是 /zh/about,也对——等等,这个其实没问题。

真正有问题的是另一个边界:pathname.replace(/^\/[^/]+/, '') 在某些路径下提取出来带了多余的斜杠,导致跳到了 /zh//about。这种 URL 在 Vercel 的 rewrite 规则里会匹配失败,用户落到了 404。

// ❌ restPath 在某些边界情况下不干净
const restPath = pathname.replace(/^\/[^/]+/, '');
url.pathname = `/zh${restPath}`;

// ✅ 明确处理空路径,保证拼接干净
url.pathname = `/zh${restPath || '/'}`;

症状是:带 zh-CN 前缀的链接进来,301 之后偶发 404。自己测试时手动输了 /zh-CN/about,发现跳到了空白页才意识到。

两个坑的本质区别

根路径语言探测 语言变体规范化
正确状态码 302 301
原因 目标因用户而异,不能缓存 目标唯一确定,希望权重集中
当时的坑 用了 301,缓存跳错语言 301 没错,路径拼接有 bug

判断用 301 还是 302,核心问题只有一个:这个跳转的目标,对所有请求来说是同一个吗? 是 → 301,不是 → 302。

另外,爬虫访问根路径的策略我单独处理了——识别到 bot UA 直接 301 到 /zh,让主语言的权重更集中。爬虫不存在"下次换语言"的场景,对它们用 301 没有副作用:

const isBot = /bingbot|googlebot|yandex|baiduspider|slurp|duckduckbot/i.test(userAgent);

if (isBot && pathname === '/') {
  url.pathname = '/zh';
  return Response.redirect(url.toString(), 301);
}

第四步:结构化数据,低成本高收益

结构化数据是 CSR 项目里性价比最高的 SEO 操作之一——实现成本低,对搜索引擎理解页面内容帮助大,还可能触发 Rich Results(富摘要展示)。

我在 SEO 组件里注入了三类 JSON-LD:

  • Organization:每个页面都注入,声明站点主体信息
  • Game:游戏/玩法页面启用,告诉搜索引擎这是一个游戏
  • BreadcrumbList:有面包屑导航的页面启用,有助于搜索结果展示路径

验证方式:Google 的 Rich Results Test,直接把 URL 丢进去,能看到 JSON-LD 有没有被正确解析。


第五步:Sitemap 和 robots

robots.txt

User-agent: *
Allow: /
Sitemap: https://www.mapguess.net/sitemap.xml

没什么特别的,就是让爬虫知道 sitemap 在哪。

Sitemap 按语言拆分

一个 sitemap index 指向三个分语言 sitemap:

<sitemapindex>
  <sitemap><loc>https://www.mapguess.net/sitemap-zh.xml</loc></sitemap>
  <sitemap><loc>https://www.mapguess.net/sitemap-en.xml</loc></sitemap>
  <sitemap><loc>https://www.mapguess.net/sitemap-ja.xml</loc></sitemap>
</sitemapindex>

这样提交的时候可以分别看各语言版本的索引状态,比一个大 sitemap 混在一起要清晰很多。分语言 sitemap 里每个 URL 都带了 lastmodchangefreq,让爬虫知道更新频率。


第六步:验证不是一次性的,是跟着数据走的循环

上线不是终点。我的验证流程是几个数据源交叉看,发现问题再调整,调整完再看数据。

Vercel Analytics + Observability

Analytics 看页面 PV 和流量来源分布。更有用的是 Observability 里的请求日志——中间件的每一次重定向都能看到。比如 /zh-CN 有没有被正确 301 到 /zh,根路径的 302 有没有按预期带上正确语言。调试中间件逻辑时这个比在本地模拟 Edge Runtime 要直接得多。

Google Search Console

主要看两个地方:

  • Performance:关键词展现量和点击量。展现量涨了但点击率低,说明 title/description 写得不够有吸引力,需要改;点击量涨了但展现量没涨,说明排名没变但 title 写得更好了。
  • Recommendations:GSC 会直接告诉你某些页面缺 canonical、hreflang 配置有冲突、结构化数据解析失败等,比自己肉眼审查准得多。

Bing Webmaster

用的比 GSC 少,但有一个 GSC 给不了的:Bing 的 SEO Analyzer 会对页面逐条扫描给建议,有时候会发现 GSC 没提的细节,比如某个页面的 og:image 尺寸不符合规范。两个工具一起用,覆盖面更全。

DarkDarkGo 和 Yandex

DarkDarkGo 是小众的隐私友好搜索引擎,用户体量小,但提交了之后能接到一部分隐私敏感用户的流量,成本几乎为零。Yandex 主要面向俄语圈,但日本用户有时候也用,项目有日语版,加上我已经放了 Yandex 的站长验证文件,顺手提交了 sitemap。

整个验证节奏大概是:上线改动 → Observability 确认请求行为符合预期 → 等 2-3 天 → 看 GSC/Bing 展现和点击变化 → 看 Recommendations 有没有新问题 → 根据数据再调整。不是每次改动都有明显数据变化,但这个循环让你知道自己在往哪个方向走。


新的流量来源:AI 爬虫,我之前完全忽视了这块

有一天看 Vercel Analytics 的 Referrer 分布,发现了来自豆包的流量。不多,但它在那里——说明有人在豆包里问了什么问题,豆包的爬虫抓了我的页面,然后把用户带过来了。

这让我意识到:搜索引擎已经不是唯一的爬虫流量来源了。ChatGPT、Perplexity、豆包(ByteDance 的 Doubao)、Claude、Gemini……这些 AI 产品都有自己的爬虫,会抓取网页内容来回答用户问题,并在回答里附上来源链接。这条链路产生的是真实的引用流量,不是爬完就走。

我之前的 robots.txt 对这块完全是空白,什么都没配。这是一个值得补上的欠账。

AI 爬虫分三类,策略不一样

先搞清楚 AI 爬虫不是铁板一块,大致分三类:

用户触发型(User-facing crawlers):用户在 ChatGPT / Perplexity / Claude 里问了问题,AI 实时抓取你的页面来生成回答,顺带在回答里引用你的链接。这类爬虫带来的是真实引用流量,代表性 UA 有 ChatGPT-UserPerplexity-UserClaude-UserOAI-SearchBotPerplexityBot这类应该无条件放行。

训练数据型(Training crawlers):批量抓取内容用于训练模型,不直接带来流量。代表性 UA 有 GPTBot(OpenAI)、ClaudeBot(Anthropic)、Google-Extended(Gemini)、Meta-ExternalAgentCCBot。这类是否放行取决于你对内容被用于模型训练的态度,没有绝对对错。我的选择是放行——内容本来就是公开的,被训练进去反而可能增加 AI 引用我的概率。

激进抓取型(Aggressive crawlers):典型是 Bytespider(ByteDance)和一些来路不明的爬虫,有时候不遵守 robots.txt,抓取频率高,资源消耗大,带来的收益不明确。对这类保持保守态度。

robots.txt 该怎么配

我的配置思路是:对用户触发型爬虫明确放行,对训练型爬虫选择性放行,对激进型不做鼓励:

User-agent: *
Allow: /

# Explicitly welcome AI crawlers
User-agent: Bytespider
Allow: /

User-agent: GPTBot
Allow: /

User-agent: ChatGPT-User
Allow: /

User-agent: ClaudeBot
Allow: /

User-agent: PerplexityBot
Allow: /

User-agent: Google-Extended
Allow: /

# Sitemaps
Sitemap: https://www.mapguess.net/sitemap.xml

有一点要说清楚:robots.txt 不是防火墙,正规 AI 公司的爬虫会遵守,野路子的不会。如果你想彻底屏蔽某类爬虫,需要在 Vercel 的 edge config 或 WAF 层面做,光靠 robots.txt 不够。

另外,中间件里已有的 bot 检测正则也可以顺手扩展一下,把主要 AI 爬虫的 UA 纳入进来,方便 Observability 里追踪:

// middleware.ts 里的 bot 检测,扩展 AI 爬虫 UA
const isBot = /bingbot|googlebot|yandex|baiduspider|slurp|duckduckbot|gptbot|claudebot|perplexitybot|oai-searchbot|bytespider|google-extended/i.test(userAgent);

llms.txt:让 AI 准确理解你的站点,而不是去解析 React 的 DOM 树

llms.txt 是 2024 年底提出的一个新约定,放在站点根目录 /llms.txt,用 Markdown 格式写,专门给 AI 系统看。

类比:robots.txt 告诉爬虫"哪些页面不要抓",llms.txt 告诉 AI "这个站点是什么、最重要的内容在哪里"。

需要说清楚的是:目前主流 AI 公司没有一家正式宣布支持 llms.txt。Anthropic、Vercel、Cloudflare 在自己站点上放了这个文件,但这不代表他们的爬虫真的在用它。但它的成本几乎为零——一个静态 Markdown 文件放到 public/ 目录下就完成了,在它可能变成标准之前先占个坑没有坏处。

写法上核心原则只有一个:信息密度要高。AI 爬虫不会去执行你的 React 渲染逻辑,读这一个文件就要能完整理解你的站点在做什么、有哪些内容。

MapGuess 实际的 llms.txt

# MapGuess

> MapGuess is a free online geography quiz game platform dedicated to making
> geography learning fun and engaging.(地图猜猜看 是一款免费在线地理竞猜游戏平台)

## Overview
MapGuess 提供 8 种游戏模式,涵盖人文地理和自然地理,
适合学生、旅行者和地理爱好者。

## Game Modes

### Human Geography(人文地理)
- **World Countries**:识别世界 200+ 国家在地图上的位置
- **States/Provinces**:中国 34 个省级行政区、美国各州等
- **Cities**:地级市、县级划分
- **Flags**:国旗识别

### Physical Geography(自然地理)
- **Mountains**:喜马拉雅山脉到安第斯山脉
- **Rivers**:尼罗河到亚马逊河
- **Lakes**:贝加尔湖到五大湖
- **Seas & Oceans**:太平洋到马六甲海峡

## Target Audience
中高考备考学生、旅行爱好者、对国际时事感兴趣的成年人

## Supported Languages
- English: https://www.mapguess.net/en/
- 简体中文: https://www.mapguess.net/zh/
- 日本語: https://www.mapguess.net/ja/

## Key URLs
- Home: https://www.mapguess.net/
- Leaderboard: https://www.mapguess.net/leaderboard
- Sitemap: https://www.mapguess.net/sitemap.xml

几个写法决策值得说一下:

游戏模式要枚举完整而不是写"8种模式"。当用户问 AI "有没有猜国旗的游戏",AI 是从内容里做特征匹配的,写了"Flags"才能被匹配到;笼统写"8种模式"没用。

目标受众要具体。"中高考备考学生"比"所有人"精确得多,AI 做场景推荐时按具体受众匹配。

多语言入口直接给 URL。AI 爬虫不会执行语言切换逻辑,直接给三个语言的链接是最可靠的方式。

还有一步容易漏掉:llms.txt 加入 PWA 的 includeAssets 白名单。否则 workbox 的缓存策略可能拦截这个文件,AI 爬虫访问时拿到的是 service worker 的兜底页而不是实际内容:

// vite.config.ts
VitePWA({
  includeAssets: [
    'favicon.svg', 'robots.txt', 'llms.txt', // ← 不加这个会被 PWA 缓存拦截
    'sitemap.xml', 'sitemap-zh.xml', 'sitemap-en.xml', 'sitemap-ja.xml',
  ],
})

在 Vercel Analytics 里识别 AI 引用流量

Vercel Analytics 的 Referrer 列里可以直接看到来自各 AI 平台的流量来源。豆包的 referrer 通常是 doubao.comvolcengine.com 相关域名;ChatGPT 引用过来的是 chatgpt.com;Perplexity 是 perplexity.ai

更细的分析需要到 Observability 的请求日志里,按 User-Agent 过滤,把 GPTBotPerplexityBotBytespider 等 UA 单独拉出来看抓取的路径和频率,能知道 AI 爬虫对哪些页面感兴趣。

目前我的 AI 引用流量还很小,豆包那次发现也是偶然。但这个趋势是明确的——AI 产品的使用量在增长,它们带来的引用流量占比只会越来越大。现在把 robots.txt 里的 AI 爬虫规则配好、把 llms.txt 写出来,是很低成本的提前布局。

为什么不做百度:放弃了

sitemap 里没有在百度提交。

百度的搜索结果现在是这个状态:打开首页,推广广告、百度笔记、百家号、爱奇艺、文库、视频、游戏……真正的搜索结果被挤到不知道哪里去了。搜技术问题,前几条全是 SEO 农场文;搜猜省份,首页大量是视频内容和自家生态产品,跟搜索引擎已经没什么关系了。

用户体验差到这个程度,往百度导流本质上是在伤害用户——把人引过去,他们得到的是一堆垃圾结果。

我自己现在如果不小心在地址栏输了 bai 开头,会立刻觉得不舒服,马上删掉换成 Bing。不是矫情,是真的恶心。

所以这个项目的搜索引擎策略很明确:Google、Bing、Yandex、DarkDarkGo,百度不在列。这是我在产品价值观上的选择——我不想把时间花在一个已经自我放弃质量的平台上。


回头看:CSR 做 SEO 的方法论

把这次实践浓缩成一个流程:

  1. 可发现:Sitemap 分语言拆分 + robots.txt 指向 + 提交多个站长平台
  2. 可理解:SEO 组件化统一管理 title/description/canonical/hreflang,一处改全站生效
  3. 可丰富呈现:JSON-LD 结构化数据(Organization/Game/BreadcrumbList)+ OG/Twitter Card
  4. 可持续优化:Vercel Observability + GSC + Bing Webmaster 交叉验证,跟着数据迭代

CSR 的 SEO 没有想象中那么难,也没有想象中那么完美。Google 能执行 JS 所以大部分场景没问题,但社交平台的 unfurl 预览这类需要服务端直出的场景确实是天花板。知道天花板在哪,在天花板以内把事情做扎实,是比较务实的路。

Read more

間

春节回家,我又见到了我干爹家的三儿子。 他生下来就带着残疾,不能说话,手脚不协调,走路一瘸一拐,嘴角总是挂着口水。小时候干爹干娘怕别人欺负他,教他见人就笑。所以这么多年,不管走到哪,他都是笑着的。 左脚脚尖点地,左手弯着伸不直,走路习惯性靠在路的最右边,紧贴着路沿。我有时候担心他会踩进沟里,想想又觉得,也许他自己知道,这样不容易被人撞到。 那天下午我一个人在村东边路上走,他跟了上来。脸上沾着灰,鼻子里有一团鼻垢,我下意识想帮他弄掉,他偏过头,自己扣了下来,然后转过脸,把手里点着的烟举了举,冲我笑。 他的手指黄黄的,染得很深。后来我知道,小时候有人逗他,教他抽烟,就这么上了瘾,又没有能力自己戒。烟瘾越来越大,有烟就一口气抽完,多的时候一天三包。这两年逢年过节,大家口袋里都装着烟,见面互让,他也学会了凑过去。村里谁家办红白喜事,他都去帮着搬凳子搬椅子,人家给他几根烟,他就高兴。我那半包苏烟,后来进了他的口袋。

折叠时间

折叠时间

上次坐地铁的时候,我盯着手机看了一眼时间:20:37。等反应过来抬起头,已经是20:52了。十五分钟,就这么没了。 但1月牙疼去看牙医,在椅子上躺着等医生准备器械,那三分钟感觉比一个小时还长。 同样是时间,为什么有时候像沙子一样从指缝溜走,有时候又像琥珀一样凝固住每一秒? 不同的星球,不同的时钟 物理学告诉我们,引力会让时间变慢。在靠近黑洞的地方过一小时,地球上可能已经过了好几年。就像不同重量的球压在一张网上,越重的球把网面压得越深,时间在那里流逝得就越慢。 这个画面一直让我着迷。 后来我想,其实我们每个人的内心世界也像是不同的星球。有些事情对你来说很重要,它就像一颗大质量的星球,把你的时间网压出很深的凹陷。你围绕着它打转,时间在那里变得又浓又稠。 恋爱的时候,一天能想对方好几百次。每一次心跳都被放大,每一个眼神都值得回味。楼下等她的那段时间好像特别"漫长"。 但也有些日子,你就是在重复。起床、上班、吃饭、睡觉。一天天像复制粘贴一样过去了,回头看,好像什么都没留下。 大象和蚂蚁的一秒钟