跳转到内容
Tutorial

URL 元数据 API:一次调用即可构建像 Slack 一样的链接预览

| 6 min read

使用一个 POST 请求从任何 URL 中提取 Open Graph 标签、Twitter 卡数据、网站图标和页面标题。 用不到 20 行代码构建链接预览卡。

Social media cards displayed on a phone screen
Photo by Rami Al-zayat on Unsplash

用户将 URL 粘贴到您的聊天应用程序中。 您想要显示带有页面标题的丰富预览卡, 描述和缩略图; Slack、Discord 和 iMessage 显示相同的卡片。 你可以 获取页面、解析 HTML,然后自行提取 Open Graph 标签。 或者您可以发送一份 发布请求。

波托伊 /v1/url-metadata 端点获取任何 URL,读取其 <meta> 标签,并返回结构化 JSON:OG 标题、OG 描述、OG 图像、 Twitter 卡数据、网站图标、规范 URL、语言等。 一次调用取代了 fetch, HTML 解析器和后备逻辑。

终点

curl -X POST https://api.botoi.com/v1/url-metadata \\
  -H "Content-Type: application/json" \\
  -d '{ "url": "https://github.com/anthropics/claude-code" }'

回复:

{
  "success": true,
  "data": {
    "url": "https://github.com/anthropics/claude-code",
    "status": 200,
    "content_type": "text/html",
    "title": "anthropics/claude-code: Claude Code is an agentic coding tool",
    "description": "Claude Code is an agentic coding tool that lives in your terminal",
    "og": {
      "title": "anthropics/claude-code",
      "description": "Claude Code is an agentic coding tool that lives in your terminal",
      "image": "https://opengraph.githubassets.com/1/anthropics/claude-code",
      "type": "object",
      "url": "https://github.com/anthropics/claude-code",
      "site_name": "GitHub"
    },
    "twitter": {
      "card": "summary_large_image",
      "title": "anthropics/claude-code",
      "description": "Claude Code is an agentic coding tool that lives in your terminal",
      "image": null
    },
    "favicon": "https://github.com/favicon.ico",
    "canonical": "https://github.com/anthropics/claude-code",
    "language": "en",
    "author": null,
    "keywords": [],
    "theme_color": null
  }
}

该响应为您提供渲染链接预览卡所需的一切。 这 og 对象包含 Slack 和 Discord 读取的 Open Graph 标签。 这 twitter 对象包含 Twitter 卡标签。 当页面设置两者时,您会得到 两者都有。 当页面两者都没有设置时,您仍然会获得 HTML titledescription 作为后备。

构建聊天应用链接预览组件

这个 Preact 组件接受一个 URL,调用 API,并渲染一张带有 OG 图像、标题、 描述和站点名称。 当 OG 标签丢失时,它会回退到 HTML 标题。

import { useState, useEffect } from "preact/hooks";

interface LinkPreview {
  title: string | null;
  description: string | null;
  image: string | null;
  favicon: string | null;
  url: string;
  siteName: string | null;
}

function useLinkPreview(url: string) {
  const [preview, setPreview] = useState&lt;LinkPreview | null&gt;(null);
  const [loading, setLoading] = useState(false);

  useEffect(() =&gt; {
    if (!url) return;
    setLoading(true);

    fetch("https://api.botoi.com/v1/url-metadata", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ url }),
    })
      .then((res) =&gt; res.json())
      .then(({ data }) =&gt; {
        setPreview({
          title: data.og?.title || data.title,
          description: data.og?.description || data.description,
          image: data.og?.image || null,
          favicon: data.favicon,
          url: data.canonical || url,
          siteName: data.og?.site_name || null,
        });
      })
      .catch(() =&gt; setPreview(null))
      .finally(() =&gt; setLoading(false));
  }, [url]);

  return { preview, loading };
}

function LinkPreviewCard({ url }: { url: string }) {
  const { preview, loading } = useLinkPreview(url);

  if (loading) {
    return (
      &lt;div class="rounded-lg border border-gray-200 p-4 animate-pulse"&gt;
        &lt;div class="h-4 bg-gray-100 rounded w-3/4 mb-2"&gt;&lt;/div&gt;
        &lt;div class="h-3 bg-gray-100 rounded w-full"&gt;&lt;/div&gt;
      &lt;/div&gt;
    );
  }

  if (!preview) return null;

  return (
    &lt;a
      href={preview.url}
      target="_blank"
      rel="noopener noreferrer"
      class="block rounded-lg border border-gray-200 overflow-hidden
             hover:border-gray-400 transition-colors no-underline"
    &gt;
      {preview.image &amp;&amp; (
        &lt;img
          src={preview.image}
          alt=""
          class="w-full h-40 object-cover"
        /&gt;
      )}
      &lt;div class="p-4"&gt;
        &lt;div class="flex items-center gap-2 mb-2"&gt;
          {preview.favicon &amp;&amp; (
            &lt;img src={preview.favicon} alt="" class="w-4 h-4" /&gt;
          )}
          &lt;span class="text-xs text-gray-500"&gt;
            {preview.siteName || new URL(preview.url).hostname}
          &lt;/span&gt;
        &lt;/div&gt;
        &lt;p class="font-semibold text-sm text-gray-900 mb-1"&gt;
          {preview.title}
        &lt;/p&gt;
        &lt;p class="text-xs text-gray-600 line-clamp-2"&gt;
          {preview.description}
        &lt;/p&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  );
}

useLinkPreview hook 处理获取并将 API 响应映射到平面 您的 UI 可以使用的对象。 后备链(data.og?.title || data.title) 意味着即使页面的 OG 标签为零,您也始终可以显示一些内容。 组件 在 API 调用正在进行时渲染加载骨架,然后交换预览卡。

在 CMS 中自动填充 SEO 元数据

内容编辑者在撰写文章时粘贴参考 URL。 而不是让它们复制粘贴 手动输入标题和描述,您的 CMS 可以从 URL 中提取该数据并预填充 搜索引擎优化领域。

async function autoFillSeoFields(referenceUrl: string) {
  const res = await fetch("https://api.botoi.com/v1/url-metadata", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: \`Bearer \${process.env.BOTOI_API_KEY}\`,
    },
    body: JSON.stringify({ url: referenceUrl }),
  });

  const { data } = await res.json();

  return {
    seoTitle: data.og?.title || data.title || "",
    seoDescription: data.og?.description || data.description || "",
    ogImage: data.og?.image || "",
    canonical: data.canonical || referenceUrl,
    favicon: data.favicon || "",
  };
}

// Usage in a CMS admin panel
const fields = await autoFillSeoFields("https://stripe.com/docs/payments");
// fields.seoTitle       → "Payments | Stripe Documentation"
// fields.seoDescription → "Accept payments online..."
// fields.ogImage        → "https://images.stripe.com/..."

当编辑者将 URL 粘贴到“参考”字段时,CMS 会调用 autoFillSeoFields, 填充 SEO 标题、描述和 OG 图像输入,并让编辑器从那里进行调整。 同样的方法适用于书签管理器、稍后阅读应用程序和内部 wiki 工具 从粘贴的链接自动生成卡片。

具有超时和错误处理功能的 Node.js 函数

在生产中,您需要超时,以便缓慢的目标页面不会无限期地阻止您的请求。 该函数用 5 秒的时间封装 API 调用 AbortController 超时和 回报 null 失败而不是抛出。

async function getLinkPreview(url: string) {
  const controller = new AbortController();
  const timeout = setTimeout(() =&gt; controller.abort(), 5000);

  try {
    const res = await fetch("https://api.botoi.com/v1/url-metadata", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: \`Bearer \${process.env.BOTOI_API_KEY}\`,
      },
      body: JSON.stringify({ url }),
      signal: controller.signal,
    });

    if (!res.ok) {
      throw new Error(\`API returned \${res.status}\`);
    }

    const { success, data } = await res.json();

    if (!success) {
      return null;
    }

    return {
      title: data.og?.title || data.title,
      description: data.og?.description || data.description,
      image: data.og?.image,
      favicon: data.favicon,
      siteName: data.og?.site_name,
      canonical: data.canonical,
      twitterCard: data.twitter?.card,
    };
  } catch (err) {
    console.error(\`Failed to fetch preview for \${url}:\`, err);
    return null;
  } finally {
    clearTimeout(timeout);
  }
}

该函数返回一个干净的对象,其中包含您的 UI 所需的字段。 来电者不接触原始的 API 响应。 如果目标页面宕机或者请求超时,函数返回 null 并且您的应用程序可以显示回退而不是崩溃。

处理边缘情况

并非每个 URL 都能配合。 有些页面没有 OG 标签。 有些位于重定向链后面。 一些 需要 10 秒来响应。 以下是如何处理每种情况。

// 1. Pages with no OG tags: fall back to title + description
const preview = await getLinkPreview(url);
const displayTitle = preview?.title || "Untitled page";
const displayDesc = preview?.description || url;
const displayImage = preview?.image || "/fallback-thumbnail.png";

// 2. Detect redirects by comparing input URL to canonical
const inputUrl = "https://bit.ly/3xYzAbc";
const result = await getLinkPreview(inputUrl);
if (result?.canonical !== inputUrl) {
  console.log(\`Redirected to: \${result?.canonical}\`);
}

// 3. Batch multiple URLs with Promise.allSettled
const urls = [
  "https://github.com",
  "https://stripe.com",
  "https://vercel.com",
];

const previews = await Promise.allSettled(
  urls.map((u) =&gt; getLinkPreview(u))
);

const results = previews.map((p, i) =&gt; ({
  url: urls[i],
  preview: p.status === "fulfilled" ? p.value : null,
}));

没有 AND 标签: 回落至 titledescription。 如果 这些也是空的,显示原始 URL。 显示占位符图像 og.image 为空。

重定向: API 遵循重定向并从最终页面返回元数据。 将输入 URL 与 canonical 检测重定向何时发生。

慢速页面: 在您这边设置一个超时(大多数情况下 5 秒即可)。 的 API 本身有一个内部超时,但您应该强制执行自己的超时,这样慢速目标就不会 阻碍你的用户体验。

批量抓取: 使用 Promise.allSettled 获取预览 并行多个 URL。 失败请求返回 null 而不妨碍其余部分。

要点

  • POST /v1/url-metadata 返回 OG 标签、Twitter 卡标签、favicon、canonical 一个 JSON 响应中的 URL、语言和关键字。
  • 该响应反映了 Slack、Discord 和 iMessage 用于呈现链接预览的数据。 你 无需编写 HTML 解析器即可获得相同的字段。
  • 匿名访问的速度为每分钟 5 个请求,无需 API 密钥。 够发展了 和低流量应用程序。
  • 回落至 titledescription 当 OG 标签丢失时。 API 总是从 HTML 返回这些 <head> 当它们存在时。
  • 对于生产使用,添加超时,通过 URL 缓存结果,并处理 null 优雅地回应。 检查 API文档 获取完整参数参考。

FAQ

什么是 URL 元数据 API?
URL 元数据 API 获取网页并从其 HTML 中提取结构化数据:页面标题、元描述、Open Graph 标签(og:title、og:image、og:description)、Twitter Card 标签、favicon URL、规范 URL 和语言。 您发送一个 URL,API 将所有这些内容以 JSON 形式返回。 它使您无需亲自获取页面并解析原始 HTML。
Slack、Discord 和 iMessage 如何生成链接预览?
当用户粘贴 URL 时,这些应用程序会在后台获取页面并读取其 Open Graph 元标记(og:title、og:description、og:image)。 他们根据这些值渲染预览卡。 如果 OG 标签丢失,它们会回退到 HTML 标题和元描述。 botoi /v1/url-metadata 端点返回这些应用程序读取的相同数据,因此您可以在自己的应用程序中构建相同的预览卡。
如果页面没有开放图谱标签会发生什么?
API 仍返回 HTML 标题、元描述、网站图标、规范 URL 和语言。 响应中的 og 字段将为空。 当 og.title 和 og.image 丢失时,您的前端应该回退到标题和描述。
API 是否遵循重定向?
是的。 API 遵循 HTTP 301/302/307/308 重定向并从最终目标 URL 返回元数据。 响应包括已解析的 URL 及其 HTTP 状态代码,因此您可以检测重定向链。
URL 元数据 API 是免费的吗?
匿名访问不需要 API 密钥,每分钟允许 5 个请求,每天允许 100 个请求。 这涵盖了开发和低流量用例。 付费计划起价为 9 美元/月,费率限额更高。

开始使用 botoi 构建

150+ 个 API 端点,涵盖查询、文本处理、图片生成和开发者工具。免费套餐,无需信用卡。