一个人做个人网站(七):SPA 做 SEO 与微信分享——给爬虫单独开一扇门
用 Vue 做内容站,迟早要面对一个基因层面的矛盾:单页应用的页面是 JavaScript 在浏览器里"长"出来的,而搜索引擎和社交软件的爬虫,大多不执行 JavaScript。你在浏览器里看到图文并茂的文章,爬虫抓到的可能只是一个空壳:
<div id="app"></div>
<script type="module" src="/assets/index-xxxx.js"></script>
后果有两个:搜索引擎收录不到正文(SEO 归零),更致命的是微信里分享链接没有卡片——标题、封面、摘要全空白,只有一条光秃秃的 URL,点击率天差地别。
这一篇讲我怎么在不推翻 SPA、不引入 Nuxt/Next 这类 SSR 框架的前提下,用一套"Nginx 分流 + Flask 轻量 SSR"解决它。这是整个站设计上最精巧的部分。
一、方案取舍:为什么不直接上 SSR 框架
摆在面前的路有三条:
- 整体迁移到 Nuxt/Quasar SSR:前后端同构,首屏直出 HTML,最"正统"。代价是要引入 Node 服务端渲染进程、改造数据获取方式(一套代码要同时在浏览器和 Node 跑)、处理服务端生命周期、部署链路从"Nginx 托管静态文件"变成"Node 常驻进程"。对一个已经稳定运行的 Flask + SPA 单人项目,这是伤筋动骨的重写;
- 构建期预渲染:
vite-plugin-ssr之类在 build 时把每个页面生成静态 HTML。适合内容固定的站,但我的文章在后台随时增删改,不可能每改一篇就重新构建部署; - 运行时分流:人走 SPA,爬虫走一套独立的服务端 HTML。
我选第三条。它的关键洞察是:爬虫需要的不是"可交互的应用",而是"一份语义完整、meta 齐全的 HTML"。这份 HTML 不需要水合(hydrate)、不需要绑定任何事件、甚至不需要好看——它只负责被读取。于是我可以用 Flask 直接拼出轻量 HTML,数据读的是和 SPA 同一份 MySQL,天然实时,部署上只是 Nginx 多加几条转发规则。成本极低,收益完整。
二、分流的第一直觉,和一个致命反例
最直觉的分流逻辑是"看 User-Agent":UA 里有 Googlebot、Baiduspider、MicroMessenger 就给 SSR,否则给 SPA。Nginx 用一个 map 就能判。
但微信给了我当头一棒:微信抓取分享卡片的爬虫,和微信内置浏览器的 UA 完全一样,都包含 MicroMessenger,服务端无法区分。这意味着如果简单地"UA 像微信就给 SSR",那么真人在微信里点开链接,看到的也会是那套不可交互的静态 HTML——点赞、翻页、小游戏全废。
解决思路是利用"爬虫不执行 JavaScript,真人浏览器执行"这一唯一可靠的差异,设计一个自我纠正的闭环:
- 微信环境(爬虫或真人)首次请求,Nginx 都先给 SSR 页;
- SSR 页里藏一段 JS:如果我是真人浏览器,我就写一个
spa=1的 cookie,然后带?spa=1参数重新请求自己,"自报家门"; - 爬虫不执行 JS,永远停在 SSR 页,安静地读 og 标签;
- Nginx 第二次见到请求时,发现带了
spa=1cookie 或参数,就改给真正的 SPA。
于是 Nginx 的判定不能只看 UA,要综合三个信号。
三、Nginx 三层 map:把"谁是爬虫"算成一个布尔值
Nginx 配置里用三个 map 把判定过程表达得清清楚楚。第一层识别爬虫 UA:
map $http_user_agent $is_crawler {
default 0;
~*MicroMessenger 1;
~*Googlebot 1;
~*Baiduspider 1;
~*Bytespider 1;
~*bot 1;
~*spider 1;
~*curl 1;
# ...QQ/头条/搜狗/360/Twitter/Discord 等
}
第二层、第三层分别识别"真人标记"——cookie 里的 spa=1 和 URL 参数里的 spa=1。注意参数缺失时 $arg_spa 是空串而不是 0,必须先归一化:
map $http_cookie $has_spa_cookie {
default 0;
~*spa=1 1;
}
map $arg_spa $has_spa_arg {
default 0;
"1" 1;
}
# 爬虫UA(1) + 无cookie(0) + 参数不是1(0) => 拼成 "100" 才走 SSR
map "$is_crawler$has_spa_cookie$has_spa_arg" $serve_ssr {
default 0;
"100" 1;
}
三个值拼成字符串再判定,是 Nginx 里表达"与"逻辑的惯用法。只有纯爬虫(UA 像爬虫、没有自报家门的 cookie、也没带参数)才得到 $serve_ssr=1;真人微信跳回来一次后,cookie 生效,此后 24 小时内所有页面都直接走 SPA,不会重复跳转。
四、418 跳板:在 Nginx 的 if 里优雅地反代
Nginx 有句著名的"if is evil"——if 指令内部不能安全地写 proxy_pass。我用一个"先返回 418,再用 error_page 接住"的跳板模式绕过它:
location ~ ^/(blog|gallery|videos|works|about|checkin|games)(/|$) {
error_page 418 = @flask_ssr;
if ($serve_ssr = 1) { return 418; }
try_files $uri $uri/ /index.html;
}
location @flask_ssr {
proxy_pass http://127.0.0.1:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
普通请求落到 try_files,最终回退到 SPA 的 index.html;爬虫请求被 return 418 截获,error_page 把它改道到名为 @flask_ssr 的命名 location,反代给 Flask。418(I'm a teapot)本身语义无害,且不会被任何真实业务用到,是个干净的内部信号。/api/、/media/、robots.txt、sitemap.xml、rss.xml 则无条件反代——这些路径人和爬虫都该由 Flask 处理。
五、SSR 页里的跳回脚本:整个机制的题眼
Flask 返回的 SSR 页 <html> 上带 data-ssr="1" 标记,head 最前面注入这段脚本:
(function(){
if (document.documentElement.getAttribute('data-ssr') !== '1') return;
if (/(^|[?&])spa=1($|[&])/.test(location.search)) return; // 已跳回,防死循环
document.cookie = 'spa=1;path=/;max-age=86400';
var s = location.search;
location.replace(location.pathname + s + (s ? '&' : '?') + 'spa=1' + location.hash);
})();
三个防御性细节:
- 只在 SSR 页里生效(检查
data-ssr),避免脚本被复用到别处时误跳; - 已经带
spa=1参数就不再跳。这行是防死循环的关键——万一 Nginx 配置没更新、带参数仍回到 SSR,页面也不会无限刷新; - 用
location.replace而不是href=,跳转不留历史记录,用户按返回键不会退回中转页;跳转时保留原有 query 和 hash(比如草稿预览的?preview=xxx不能丢)。
这个机制的自洽性很值得体会:信任不是服务端口令,而是"有没有能力执行 JS"这个行为证明,证明一次,cookie 记账一天。
六、SSR 页面本身:一份为"被读取"而生的 HTML
Flask 侧是一个独立的 seo 蓝图,覆盖首页、各列表页、文章/相册/视频/作品/打卡/小游戏详情页。所有页面共用一个 build_meta() 生成 head,这是分享卡片的核心:
def build_meta(*, title, description, image, url, og_type='website', noindex=False, video=None):
parts = [
f'<title>{html.escape(title)} · {info["name"]}</title>',
f'<meta name="description" content="{html.escape(desc)}">',
f'<link rel="canonical" href="{html.escape(url)}">',
f'<meta property="og:type" content="{og_type}">',
f'<meta property="og:title" content="{html.escape(title)}">',
f'<meta property="og:description" content="{html.escape(desc)}">',
f'<meta property="og:url" content="{html.escape(url)}">',
'<meta name="twitter:card" content="summary_large_image">',
]
if image:
parts.append(f'<meta property="og:image" content="{html.escape(image)}">')
if video and video.get('url'):
parts += [f'<meta property="og:video" content="...">',
f'<meta property="og:video:type" content="{video.get("mime")}">']
if noindex:
parts.append('<meta name="robots" content="noindex,nofollow">')
这里面每个决策都有出处:
- 封面图必须是绝对 HTTPS URL。社交爬虫不认相对路径,微信还强制要求 HTTPS。
absolute_url()把/ossimg/xxx补成https://wulala.cc/ossimg/xxx——这也正是第 6 篇坚持用 Nginx 反代剥掉 OSS 强制下载头的原因,图片不能被预览,卡片照样废; - 文章没封面时用站点首页大图兜底,保证任何分享都"有图可卡";
- 视频页区分两种来源:上传到自家 OSS 的视频输出
og:video系列标签并用<video>直链播放;B站/YouTube 外链则抽 iframe 嵌入地址。爬虫因此能识别视频内容,真人误入 SSR 也能直接看; - 草稿预览页打
noindex,nofollow且不注入跳回脚本。第 4 篇的签名预览链接是给真人看的,绝不能被搜索引擎收录,也不该被强制跳去 SPA(SPA 里草稿反而要登录)。 - canonical 指向 SPA 的正式 URL,告诉搜索引擎"SSR 和 SPA 是同一个页面、以这个地址为准",避免重复内容判罚。
正文部分直接复用第 4 篇存下的 content_html——双端存储的红利在这里兑现:SSR 不用现场渲染 Markdown,取出来塞进 <article class="content"> 即可,和访客在 SPA 里看到的内容出自同一份 HTML,天然一致。文章页还复用了同一个 published_post_neighbors() 和 related_posts(),SSR 里的上下篇、相关文章排序和 SPA 完全相同。
列表页 SSR 还承担一个 SEO 职责:输出条目链接让爬虫能发现详情页。爬虫不会操作"下一页"按钮,所以我把每个板块前 50 条内容渲染成带封面和摘要的 <a href> 列表;50 条之外的内容则靠下一节的 sitemap 保证可达。
七、robots、sitemap、rss:把网站的地图主动交出去
被动等爬虫发现还不够,主动喂地图效率高得多。三个动态生成的文件:
- robots.txt:放行全站,禁止爬
/admin和/api/,并声明 sitemap 位置; - sitemap.xml:列出所有固定页、游戏页和全部已发布文章/相册/视频/作品的标准 URL,文章带
lastmod,首页优先级 1.0、文章 0.8、其余 0.5/0.6。打卡页根据"是否在后台开启"动态决定是否出现; - rss.xml:最近 20 篇文章的 RSS 2.0 订阅源,正文放进
content:encoded,pubDate用 RFC 822 GMT 时间——时间戳是绝对时刻,不受服务器时区影响。
写 XML 有两个字符串安全细节:文本节点一律 html.escape;正文 HTML 放进 CDATA 时,内容里如果恰好出现 ]]> 必须拆成相邻的两个 CDATA 段,否则 XML 会被提前截断:
f'<content:encoded><![CDATA[{content.replace("]]>", "]]]]><![CDATA[>")}]]></content:encoded>'
Nginx 对这三个文件做的是无条件精确反代(location = /sitemap.xml),因为它们本就该动态生成、人和爬虫都一样。
八、真人这一侧:前端仍然维护自己的 meta
爬虫问题在 Nginx + Flask 解决,但真人浏览器里地址栏标题、收藏、以及少数会执行 JS 的爬虫(如 Googlebot 现代版本)仍需要正确的 meta。所以前端有一套对称的 useSeo composable,详情页用它在路由切换时改写 document.title 和 og 标签:
useSeo(() => post.value ? {
title: post.value.title,
description: post.value.summary,
image: absoluteUrl(post.value.cover),
type: 'article',
url: window.location.origin + window.location.pathname,
} : null)
它的内部有一套"持有者计数"(activeCount)解决 SPA meta 管理的经典竞态:详情页挂载期间,路由级别的默认卡片逻辑不得介入覆盖;详情页卸载时,在 onBeforeUnmount 里按新路由(此时 $route 已切换)的标题恢复默认卡片。否则快速切页时,旧页面的残留 meta 会闪现在新页面上。
于是最终形成清晰的双轨:SSR meta 给不执行 JS 的爬虫,JS meta 给真人浏览器,两者数据源相同、字段口径相同(标题模板、摘要截断长度、封面兜底图都刻意保持一致)。
九、踩过的坑
- 站点配置的多 worker 缓存一致性。 SSR 页要读站名、首页大图等配置,每次查库浪费,我做了进程内缓存并在后台保存时主动清空;但 Gunicorn 有 2 个 worker,清缓存通知只能到达处理保存请求的那个。所以缓存加了 60 秒 TTL 兜底——另一个 worker 最多滞后一分钟也会读到新配置,这是"最终一致"在单机场景的恰当用法。
- SSR 列表页必须限条数。相册可能有上百个,全渲染成 HTML 会让爬虫抓取的页面体积失控,50 条 + sitemap 是"可发现性"和"页面体积"的平衡点。
- 所有插值都要
html.escape。文章标题、标签、视频外链都来自后台输入,拼进 HTML/属性时不转义就是 XSS 和标签断裂;属性场景还得用html.escape(url, quote=True)连引号一起转。 - slug 纯数字的坑在 SSR 路由里重演:
/blog/2026必须先按 slug 查再按 id 兜底,和第 4 篇的 API 完全同构。 - 本地验证分流要用 curl 模拟 UA:
curl -A "MicroMessenger..." https://wulala.cc/blog/xxx应返回 SSR(含 og 标签),普通 curl(也匹配爬虫规则)带-H 'Cookie: spa=1'应返回 SPA 的 index.html。这两条命令是我每次改 Nginx 后的固定自检。 - 微信卡片有缓存:改了标题封面后微信里仍是旧卡片,不是代码 bug,是微信侧缓存;用链接加任意无意义参数(
?v=2)可绕过缓存验证。 - 跳转脚本必须放在 head 最前面,尽量早执行,避免真人先看到 SSR 内容闪一下再跳走(FOUC)。
十、小结
这套方案的本质是承认不同消费者的差异,并用最小的架构成本分别服务:
- 不和 SPA 的体验优势对抗,也不假装爬虫会变聪明;
- 分流下沉到 Nginx(用 map 表达复杂布尔、用 418 跳板规避 if 限制),应用层无感知;
- 用"能否执行 JS"作为人/爬虫不可伪造的行为分界,一个 cookie 完成自证;
- SSR 页是只读快照而非应用,直接复用入库 HTML 和业务查询函数,零数据冗余;
- 绝对 HTTPS URL、canonical、noindex、sitemap、rss 这些细节一个都不能少,它们共同构成搜索引擎和社交平台信任你的语言。
一个人维护的系统,最忌讳为了"政治正确"引入驾驭不住的复杂度。这套轻量分流用几百行 Nginx + 一个 Flask 蓝图,拿到了重型 SSR 框架 80% 的收益,而维护成本几乎可以忽略——这是我在整个项目里最满意的一次取舍。
下一篇轻松一点,看两个纯前端但很有意思的模块:GitHub 风格的打卡年度热力图怎么画,以及小游戏中心怎么做排行榜,还有 Matter.js 物理引擎 collisionStart 事件里一个反直觉的坑——第 8 篇:打卡热力图与小游戏中心。
系列导航:① 开篇与总体架构 → ② Flask 后端骨架 → ③ 登录与安全 → ④ 博客内容模块 → ⑤ Markdown 编辑器 → ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享(本文)→ ⑧ 打卡热力图与小游戏 → ⑨ AI 网页宠物与收官


