SerpBase AI Overview 引用卡片:React 组件实战

4 阅读4分钟

SerpBase AI Overview 引用卡片:React 组件实战

上个月给自己的技术博客加「本文相关问答」模块时,我盯上了 Google 搜索结果页里的 AI Overview——它本身就带着一段摘要和几个来源链接,简直是现成的「带引用的答案卡片」。手工复制肯定不现实,我需要程序化地拿到这块数据。试了一圈,最后落在 SerpBase 上:它的 /google/search 响应里,ai_overview 是一个独立字段,里面有 text、sources 数组和一个 truncated 标志位,结构干净到可以直接喂给 React 组件。

这篇文章记录我把它做成一个 <AiOverviewCard /> 组件的全过程,包括服务端调用、字段语义和几个渲染上的取舍。

为什么不在前端直接调

先把架构说死:API key 不能进浏览器。SerpBase 的文档也明确写了 key 只能放在 X-API-Key 请求头里、永远不要出现在 URL 或前端代码里。所以结构是:

浏览器 → 自己的 /api/overview 路由 → api.serpbase.dev

前端组件只跟自己的路由打交道。下面这个是 Next.js 的 Route Handler,做了最基本的一层缓存:

// app/api/overview/route.ts
import { NextRequest, NextResponse } from "next/server";

const cache = new Map<string, { data: any; at: number }>();
const TTL = 1000 * 60 * 60 * 6; // 6 小时

export async function GET(req: NextRequest) {
  const q = req.nextUrl.searchParams.get("q");
  if (!q) return NextResponse.json({ error: "q required" }, { status: 400 });

  const hit = cache.get(q);
  if (hit && Date.now() - hit.at < TTL) {
    return NextResponse.json(hit.data);
  }

  const resp = await fetch("https://api.serpbase.dev/google/search", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": process.env.SERPBASE_KEY!,
    },
    body: JSON.stringify({ q, gl: "us", hl: "en", device: "desktop" }),
  });
  const json = await resp.json();

  // 只向前端暴露需要的字段,别把整份 SERP 扔下去
  const payload = {
    aiOverview: json.ai_overview ?? null,
    charged: json.credits_charged,
  };
  if (json.status === 0) cache.set(q, { data: payload, at: Date.now() });
  return NextResponse.json(payload);
}

这里有两个只有认真读过 SerpBase 计费规则才会写的细节:

  • status === 0 才进缓存。SerpBase 的口径是失败的请求自动退款、不计费,但失败响应里当然没有 ai_overview,缓存一个空结果等于白白等 6 小时 TTL。
  • 我把 credits_charged 透传到了响应里。一次 /google/search 固定 1 credit,这个字段正常返回 1;如果是 0 说明没扣费,null 说明还在事后结算中。调试期把它打在前端控制台,能立刻确认这次调用到底花没花钱,不用切去后台看余额。

组件本体:三个状态的渲染

ai_overview 不是每个查询都有。Google 没生成概览时,这个字段整个缺席。加上 truncated 标志,组件实际上有三种形态:完整卡片、截断卡片(提示用户来源更全)、空态(静默不渲染)。

// components/AiOverviewCard.tsx
"use client";
import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

type Source = { title: string; link: string };
type AiOverview = { text: string; sources: Source[]; truncated: boolean };

export function AiOverviewCard({ query }: { query: string }) {
  const { data, isLoading } = useSWR(
    `/api/overview?q=${encodeURIComponent(query)}`,
    fetcher
  );

  if (isLoading) return <div className="aio-skeleton" aria-busy="true" />;

  const aio: AiOverview | null = data?.aiOverview ?? null;
  if (!aio) return null; // 该查询没有 AI Overview,整个组件消失

  return (
    <aside className="aio-card" aria-label="AI Overview">
      <header className="aio-badge">AI Overview</header>
      <p className="aio-text">{aio.text}</p>
      {aio.truncated && (
        <p className="aio-truncated">摘要被 Google 截断,以下来源包含完整信息</p>
      )}
      <ul className="aio-sources">
        {aio.sources.map((s) => (
          <li key={s.link}>
            <a href={s.link} target="_blank" rel="noopener noreferrer">
              {s.title}
            </a>
          </li>
        ))}
      </ul>
    </aside>
  );
}

配一点最简样式(CSS 略),效果就是文章页右侧一张带「AI Overview」角标的卡片:一段答案正文,下面挂 2~4 个来源链接。

实测返回长什么样

拿 q=best coffee in austin 跑了一次(SerpBase 官网首页的互动演示里就有这个例子),ai_overview 字段的真实结构:

"ai_overview": {
  "text": "Austin's best-rated cafés cluster around South Congress and East 6th; most open by 7 am and roast in house.",
  "sources": [
    { "title": "Austin coffee guide 2026", "link": "https://atxcoffeeguide.com/best" },
    { "title": "r/Austin: favourite coffee?", "link": "https://reddit.com/r/Austin/..." }
  ],
  "truncated": false
}

sources 里每个元素就是 title + link,没有多余字段要清洗。我额外测了 20 个长尾技术 query(比如 "react server components caching"),其中 13 个返回了 ai_overview,命中率比预期高;没返回的 7 个走空态分支,页面布局完全不受影响——这也是当初选字段级渲染而不是整页 iframe 的原因。

成本上,一次查询 1 credit。我的博客每天触发大约 300 次不同 query 的调用,加上 6 小时缓存去重,一天实际消耗不到 200 credits。按预付费包最低档 $0.50/千次算,一天一毛钱不到,而且 credits 不过期,失败调用还退款——对 Side Project 来说这个计费模型几乎不用动脑。

踩过的三个小坑

  1. truncated 别忽略。 一开始我没处理这个标志,结果有几张卡片的答案以半句话结尾,读者以为是 bug。截断时给一句提示并把来源链接突出显示,体验立刻正常。
  2. query 要做规范化再当缓存 key。 大小写和首尾空格会产生不同 key,白白多扣 credit。我在 route handler 里统一 q.trim().toLowerCase()。
  3. 空态要返回 null 而不是占位 UI。 AI Overview 是「锦上添花」模块,没有数据时静默消失,比显示「暂无内容」干净得多。

顺手能扩展的方向

同一份响应里还有 people_also_ask(问题列表)和 related_searches,我用同一个 route handler 把它们也透传了,卡片下面再加一排「相关问题」的折叠条目,零额外请求——反正一次调用 1 credit,字段多拿一个不花钱。想看完整字段定义的话,直接翻 SerpBase 的 /google/search 端点文档,每个模块的字段都列得很清楚;注册送的 500 免费 credits 不用绑卡,够你把组件从头调到上线。

如果你也在给内容站找「带出处的事实摘要」模块,别再自己接 LLM 现编了——Google 已经生成好了,把它渲染出来就行。