‹ 返回博客

个人网站(七):SPA 做 SEO 与微信分享——给爬虫单独开一扇门

2026-09-20 · 约 11 分钟读完 · #编程

一个人做个人网站(七):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 框架

摆在面前的路有三条:

  1. 整体迁移到 Nuxt/Quasar SSR:前后端同构,首屏直出 HTML,最"正统"。代价是要引入 Node 服务端渲染进程、改造数据获取方式(一套代码要同时在浏览器和 Node 跑)、处理服务端生命周期、部署链路从"Nginx 托管静态文件"变成"Node 常驻进程"。对一个已经稳定运行的 Flask + SPA 单人项目,这是伤筋动骨的重写;
  2. 构建期预渲染vite-plugin-ssr 之类在 build 时把每个页面生成静态 HTML。适合内容固定的站,但我的文章在后台随时增删改,不可能每改一篇就重新构建部署;
  3. 运行时分流:人走 SPA,爬虫走一套独立的服务端 HTML

我选第三条。它的关键洞察是:爬虫需要的不是"可交互的应用",而是"一份语义完整、meta 齐全的 HTML"。这份 HTML 不需要水合(hydrate)、不需要绑定任何事件、甚至不需要好看——它只负责被读取。于是我可以用 Flask 直接拼出轻量 HTML,数据读的是和 SPA 同一份 MySQL,天然实时,部署上只是 Nginx 多加几条转发规则。成本极低,收益完整。

二、分流的第一直觉,和一个致命反例

最直觉的分流逻辑是"看 User-Agent":UA 里有 GooglebotBaiduspiderMicroMessenger 就给 SSR,否则给 SPA。Nginx 用一个 map 就能判。

但微信给了我当头一棒:微信抓取分享卡片的爬虫,和微信内置浏览器的 UA 完全一样,都包含 MicroMessenger,服务端无法区分。这意味着如果简单地"UA 像微信就给 SSR",那么真人在微信里点开链接,看到的也会是那套不可交互的静态 HTML——点赞、翻页、小游戏全废。

解决思路是利用"爬虫不执行 JavaScript,真人浏览器执行"这一唯一可靠的差异,设计一个自我纠正的闭环

  1. 微信环境(爬虫或真人)首次请求,Nginx 都先给 SSR 页;
  2. SSR 页里藏一段 JS:如果我是真人浏览器,我就写一个 spa=1 的 cookie,然后带 ?spa=1 参数重新请求自己,"自报家门";
  3. 爬虫不执行 JS,永远停在 SSR 页,安静地读 og 标签;
  4. Nginx 第二次见到请求时,发现带了 spa=1 cookie 或参数,就改给真正的 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.txtsitemap.xmlrss.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);
})();

三个防御性细节:

  1. 只在 SSR 页里生效(检查 data-ssr),避免脚本被复用到别处时误跳;
  2. 已经带 spa=1 参数就不再跳。这行是防死循环的关键——万一 Nginx 配置没更新、带参数仍回到 SSR,页面也不会无限刷新;
  3. 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">')

这里面每个决策都有出处:

正文部分直接复用第 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:把网站的地图主动交出去

被动等爬虫发现还不够,主动喂地图效率高得多。三个动态生成的文件:

写 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 给真人浏览器,两者数据源相同、字段口径相同(标题模板、摘要截断长度、封面兜底图都刻意保持一致)

九、踩过的坑

  1. 站点配置的多 worker 缓存一致性。 SSR 页要读站名、首页大图等配置,每次查库浪费,我做了进程内缓存并在后台保存时主动清空;但 Gunicorn 有 2 个 worker,清缓存通知只能到达处理保存请求的那个。所以缓存加了 60 秒 TTL 兜底——另一个 worker 最多滞后一分钟也会读到新配置,这是"最终一致"在单机场景的恰当用法。
  2. SSR 列表页必须限条数。相册可能有上百个,全渲染成 HTML 会让爬虫抓取的页面体积失控,50 条 + sitemap 是"可发现性"和"页面体积"的平衡点。
  3. 所有插值都要 html.escape。文章标题、标签、视频外链都来自后台输入,拼进 HTML/属性时不转义就是 XSS 和标签断裂;属性场景还得用 html.escape(url, quote=True) 连引号一起转。
  4. slug 纯数字的坑在 SSR 路由里重演/blog/2026 必须先按 slug 查再按 id 兜底,和第 4 篇的 API 完全同构。
  5. 本地验证分流要用 curl 模拟 UAcurl -A "MicroMessenger..." https://wulala.cc/blog/xxx 应返回 SSR(含 og 标签),普通 curl(也匹配爬虫规则)带 -H 'Cookie: spa=1' 应返回 SPA 的 index.html。这两条命令是我每次改 Nginx 后的固定自检。
  6. 微信卡片有缓存:改了标题封面后微信里仍是旧卡片,不是代码 bug,是微信侧缓存;用链接加任意无意义参数(?v=2)可绕过缓存验证。
  7. 跳转脚本必须放在 head 最前面,尽量早执行,避免真人先看到 SSR 内容闪一下再跳走(FOUC)。

十、小结

这套方案的本质是承认不同消费者的差异,并用最小的架构成本分别服务

一个人维护的系统,最忌讳为了"政治正确"引入驾驭不住的复杂度。这套轻量分流用几百行 Nginx + 一个 Flask 蓝图,拿到了重型 SSR 框架 80% 的收益,而维护成本几乎可以忽略——这是我在整个项目里最满意的一次取舍。

下一篇轻松一点,看两个纯前端但很有意思的模块:GitHub 风格的打卡年度热力图怎么画,以及小游戏中心怎么做排行榜,还有 Matter.js 物理引擎 collisionStart 事件里一个反直觉的坑——第 8 篇:打卡热力图与小游戏中心

系列导航:① 开篇与总体架构 → ② Flask 后端骨架 → ③ 登录与安全 → ④ 博客内容模块 → ⑤ Markdown 编辑器 → ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享(本文)→ ⑧ 打卡热力图与小游戏 → ⑨ AI 网页宠物与收官