GeoVector 集成指南

GeoVector CMS 协议

实现四个 HTTP handler,GeoVector 即可在您的网站上发布、更新、读回和撤回文章,无需插件、特定 CMS 厂商或共享数据库。

协议:geovector-cms 版本:1 传输:HTTPS + JSON 身份验证:静态 Bearer 文档修订:3(2026-08-07) English version

1. 工作原理

GeoVector 通过 HTTPS 将生成的文章推送到您的网站。存储、渲染、路由和缓存由您负责;生成和效果衡量由 GeoVector 负责。双方只通过一个路径上的一份 JSON 协议交互。

GeoVector                                   Your website
─────────                                   ────────────
generate article
        │
        │  POST   /api/publish        (upsert by slug)   ──►  store + return public URL
        │  GET    /api/publish?slug=  (exists?)          ──►  { exists }
        │  GET    /api/publish?slug=&include=content     ──►  stored HTML (drift check)
        │  DELETE /api/publish        (unpublish)        ──►  { action }
        │  GET    /api/publish/meta   (capabilities)     ──►  categories, default, path shape
        ▼
measure AI visibility of the published URL

核心要点:

  • slug 是主键。POST 执行 upsert;同一 slug 必须原位更新,绝不能创建重复文章。
  • POST 响应决定规范网址。GeoVector 会监测返回的 url 并将引用归因到该网址。请返回重定向后用户最终访问的网址。
  • 您的网站是内容的事实来源。GeoVector 会读回已存储的 HTML,以检测网站侧的修改。
  • 存储方式完全由您决定。数据库记录、Markdown 文件加 git 提交,或无头 CMS API 均可。
路径固定

所有操作都位于 {siteUrl}/api/publish{siteUrl}/api/publish/meta。v1 不支持自定义路径;如有需要,请配置 rewrite。

2. 身份验证

每个请求(包括 /meta)都必须携带静态 Bearer token:

Authorization: Bearer <your-token>
  • token 由您生成,而非 GeoVector。请使用至少 32 字节的 CSPRNG 输出:openssl rand -hex 32
  • 将其存放在部署环境中(例如 CMS_PUBLISH_TOKEN)。切勿提交到代码库,也不要暴露给浏览器端 bundle。
  • 使用恒定时间比较。直接用 === 比较密钥会形成计时侧信道。
  • 连接时将 token 填入 GeoVector。我们使用 AES-256-GCM 加密存储,并且只会把它发送到已配置的主机。
  • 未通过身份验证时返回 401{"error":"unauthorized"}。不要向未授权调用者泄露 slug 是否存在。
轮换

同时接受 CMS_PUBLISH_TOKENCMS_PUBLISH_TOKEN_PREVIOUS。轮换顺序为:添加新 token、更新 GeoVector、移除旧 token。

重定向会移除凭据

请配置规范主机。GeoVector 仅在重定向仍位于同一注册域且未从 HTTPS 降级时重新附加 Bearer token;其他重定向会以 redirect_offsite 失败。

3. 端点参考

POST /api/publish — 创建或更新

请求体(application/json):

{
  "title":    "How AI Assistants Choose Sources",
  "slug":     "how-ai-assistants-choose-sources",
  "category": "guide",
  "date":     "2026-08-06",
  "featured": false,
  "content":  "<article>…</article>",
  "format":   "html",

  "excerpt":      "One-paragraph summary used for cards and meta description.",
  "author":       "Acme Research",
  "readTime":     "7 min read",
  "tags":         ["ai-search", "citations"],
  "dateModified": "2026-08-06",
  "thumbnail":    { "gradientFrom": "#10b981", "gradientTo": "#0284c7" },
  "imageBase64":  "iVBORw0KGgo…"
}

成功响应(200):

{ "action": "created", "url": "https://acme.com/articles/how-ai-assistants-choose-sources" }
最重要的两条规则

返回绝对 urlGeoVector 会通过该地址监测 AI 引用。相对路径或随后发生 301 跳转的网址会降低后续衡量的准确性。

发布日期只设置一次。更新时保留原始 date,并改写 dateModified。每次编辑都把文章重设为“今天”会损害其搜索和 AI 检索表现。

GET /api/publish?slug=… — 存在性探测

响应 200{ "exists": true }{ "exists": false }。缺少 slug 时返回 400 {"error":"slug_required"}

GeoVector 也用它测试连接。设置时我们会用保留 slug __verify__ 调用;返回 {"exists": false} 即为正确的成功响应。

GET /api/publish?slug=…&include=content — 读回内容

{ "exists": true, "content": "<article>…</article>", "format": "html" }
{ "exists": false }

请原样返回已存储的 HTML。GeoVector 会将其空白归一化后的 SHA-256 与发布时发送的 HTML 比较,以检测外部修改。写入时重写内容会让每篇文章立即显示为已修改,因此应先验证,再逐字节存储(参见 §9)。如无法避免重写,请告知我们,以便改为对存储后的形式计算哈希。

DELETE /api/publish — 撤回发布

请求体为 { "slug": "…" }。响应为 { "action": "deleted" }{ "action": "not_found" },两者均返回 200。删除已不存在的内容也算成功。请从索引中移除文章,并让原网址返回 410(或 404),不要留下孤立页面。

GET /api/publish/meta — 声明选项

{
  "protocol": "geovector-cms",
  "version": "1",
  "categories": ["guide", "comparison", "industry", "research"],
  "defaultCategory": "guide",
  "articlePathTemplate": "/articles/{slug}",
  "capabilities": {
    "protocolVersions": ["1"],
    "supportsDelete": true,
    "maxBodyBytes": 10485760,
    "imageMimeTypes": ["image/png", "image/webp"],
    "locales": ["en"],
    "fields": ["excerpt", "tags", "metaDescription", "canonicalUrl"]
  }
}

GeoVector 在连接时获取并缓存该响应。分类会变成下拉选项;articlePathTemplate 仅用于显示,真正的网址仍以 POST 响应为准。返回 404 时,分类会退回自由文本输入。

capabilities — 声明不支持的能力

该对象及其中所有字段均为可选。请在设置阶段声明限制,避免到发布时才发现。

字段类型缺失时的含义
protocolVersionsstring[]["1"]
supportsDeleteboolean支持;DELETE 属于 v1 基线。仅追加的接收端应发送 false
maxBodyBytesinteger未知。声明触发 413 的上限。
imageMimeTypesstring[]未知。明确的 [] 表示不接受图片;请声明,而不是静默丢弃。
localesstring[]未知。列出实际可路由的语言区域。
fieldsstring[]未知。列出实际持久化的可选字段,使发送端知道哪些内容会被保留。
缺失表示未知,而非不支持

缺少某个字段或整个对象时,发送端会“假定支持 v1 基线”。请省略对象,不要发送 {};也不要声明会被静默丢弃的字段。

GeoVector 当前实际采用的字段

GeoVector 目前只会根据 categoriesdefaultCategoryarticlePathTemplate 改变行为。capabilities 内的字段会被验证,但目前仅作提示;例如 supportsDelete: false 尚不会隐藏“撤回发布”。仍请准确声明,以便未来执行这些限制。

状态码

状态码使用场景响应体
200成功,包括删除时的 not_found按操作定义
400请求体不是 JSON{"error":"invalid_json"}
400JSON 不符合字段 schema{"error":"invalid_payload","issues":{…}}
400GET 缺少 slug 查询参数{"error":"slug_required"}
400HTML 不符合内容策略{"error":"unsafe_content","violations":[…]}
401Bearer token 缺失或错误{"error":"unauthorized"}
404仅用于未声明元数据的 /api/publish/meta{"error":"meta_not_advertised"}
413请求体超过大小限制{"error":"payload_too_large"}
429触发速率限制;包含 Retry-After{"error":"rate_limited"}
500存储层抛出异常{"error":"storage_error"}

500 的响应体应保持通用。请在服务端记录异常,不要把数据库错误返回给调用者。

状态码决定客户看到的结果

状态码会决定客户控制面板中显示的集成健康状态:

  • 400文章问题。发布失败;集成健康状态不变。
  • 401 / 403凭据问题。连接会被标记为失败。
  • 5xx、超时或主机不可达 — 网站问题。连接会被标记为失败,并显示您的响应体,最多 500 个字符。

下一次成功请求会清除失败标记。拒绝请求载荷时请返回 400,而不是 500

只发送一次

每次发布只发送一个请求,不重试,超时为 30 秒。失败后必须由用户重新发布,因此请把耗时的构建和图片处理移出请求。GeoVector 尚不采用 Retry-After429 会使本次发布失败,但不会把网站标记为不健康。

4. 文章字段

字段类型要求说明
titlestringMUST纯文本,不是 HTML。正文已包含自己的 <h1>
slugstringMUST主键;匹配 ^[a-z0-9][a-z0-9-]*$。接受最长 200 个字符,并在每个 HTTP 方法中验证。
categorystringMUST应为 /meta 声明的值之一。遇到未知值时使用默认分类,不要拒绝。
datestringMUSTISO-8601,例如 2026-08-062026-08-06T09:30:00Z。拒绝长格式日期以及 2026-02-30 等不存在的日期。
contentstringMUSTHTML 片段。文章有问答部分时,会包含 <script type="application/ld+json">FAQPage 区块,位于 </article> 之后;请原样渲染。参见 §10
format"html"MUST固定值。拒绝其他值,避免未来格式被错误存储。
featuredbooleanMAY默认为 false。可以采用或忽略。
excerptstringSHOULD用于卡片摘要和 <meta name="description">。缺失时从首段生成。
authorstringSHOULD显示名称。默认为您的品牌。
readTimestringMAY已格式化文本("7 min read")。缺失时可按约 200 wpm 计算。
tagsstring[]MAY自由格式。
dateModifiedstringSHOULDISO-8601,规则同 date。写入 Article JSON-LD;更新时缺失则填入当前时间。
metaDescriptionstringSHOULD<meta name="description">,最多 500 个字符。缺失时使用 excerpt。与 title 一样,这是纯文本;写入属性时须进行 HTML 转义。
canonicalUrlstringSHOULD用于转载内容的绝对 http(s) URL。缺失表示使用自身规范网址。见下方 URL 规则。
ogImageUrlstringMAY已托管图片的绝对 http(s) URL。发送端只会提供此字段或 imageBase64 之一。
localestringMAYBCP-47 标签(enen-GBzh-Hans),用于 <html lang>。在 capabilities.locales 中声明支持的值。
noindexbooleanMAY输出 <meta name="robots" content="noindex">,并从 sitemap 中排除页面。缺失表示允许索引。
thumbnailobjectMAY在没有主图的网站上用于卡片图形的 {gradientFrom, gradientTo} 十六进制颜色对。
imageBase64stringMAY原始 base64(无 data-URI 前缀),不声明 MIME 类型。检查 magic bytes,限制解码后大小,存入对象存储并用作 OG 图片。v1 允许忽略。
未知字段

忽略未知字段;可选字段会在不改变协议 v1 的情况下新增。

您不会收到 jsonLd 字段。结构化数据以内联方式包含在 content 中,内容策略会验证其中的 <script type="application/ld+json">。单独的字段会迫使双方合并两个来源。

URL 字段可被攻击者控制;解析器不等于验证器

canonicalUrlogImageUrl 必须是绝对 httphttps URL。仅检查 scheme 并不安全:许多解析器会接受能够逃逸 HTML 属性的原始字符。存储前请拒绝下表中的所有字符。

URL 字段中任何位置都应拒绝原因
" ' `结束 href="…" 的引号,使剩余内容被解释为标记。
< >可直接开启标签;在未加引号的属性中单独出现就足以造成问题。
任何空白字符,包括 U+00A0把一个属性拆成两个。语言中的 \s 字符类通常已涵盖完整集合。
任何控制字符:U+0000U+001FU+007F真实 URL 不会未转义地包含这些字符;某些解析器、日志和终端会截断或重新解释该值。

应拒绝而不是重写;重写会隐藏无效输入。

5. 使用其他编程语言实现

对于非 Node 技术栈,请根据以下四个稳定、与语言无关的文件实现四个 handler:

文件用途
geovector-cms.openapi.yaml描述所有端点、状态码和错误响应的 OpenAPI 3.1 文件。它是规范性来源;本指南与规范冲突时,以规范为准。大多数语言都能据此生成服务端 stub 和请求验证器。
article-html-policy.v1.json以数据形式提供 HTML 允许列表和结构规则。它由接收端实际执行的同一组常量生成,不会落后于实现。
html-policy-conformance.v1.json内容策略测试向量:{id, why, html, expect}。请让内容检查逻辑运行全部用例。
verify-receiver.sh使用 POSIX shell 和 curl 对线上端点执行完整协议验证,并指出错误。
curl -sO https://www.geovector.ai/docs/cms/geovector-cms.openapi.yaml
curl -sO https://www.geovector.ai/docs/cms/article-html-policy.v1.json
curl -sO https://www.geovector.ai/docs/cms/html-policy-conformance.v1.json
curl -sO https://www.geovector.ai/docs/cms/verify-receiver.sh && chmod +x verify-receiver.sh

请在发布前重新获取这些文件,尤其是自动生成的策略文件。

实际需要编写的部分

协议的大部分内容可直接映射到框架功能;HTML 策略需要格外谨慎:

部分工作量说明
路由、JSON 解析、状态码很小从 OpenAPI 规范生成。
slug 验证很小一个正则表达式,应用于 POST、GET 和 DELETE。
恒定时间 token 比较很小使用标准库;见下文。
请求体大小限制多数框架原生支持。确保在 JSON 解析之前生效。
日期验证先匹配 ISO-8601 格式,再确认日期真实存在;许多标准库会把 2026-02-30 自动变成 3 月 2 日。
URL 字段验证很小只允许 http/https scheme,并对 canonicalUrlogImageUrl 拒绝 §4 表格中的所有原始字符。scheme 匹配不区分大小写;字符规则不可省略。详见§4
HTML 内容策略主要工作根据允许列表编写扫描器,并使用一致性向量测试。这是最需要认真处理的部分。
不要跳过内容策略

您会在自己的域名上渲染远程生成的 HTML。没有允许列表时,泄露的 API key 会直接变成存储型 XSS。

编写扫描器

直接读取并执行 article-html-policy.v1.jsonelements 把允许的元素映射到其额外属性;globalAttributes 适用于所有元素;urlAttributes 列出必须通过 url 规则的属性;jsonLd 描述唯一允许的 <script> 形式;structure 定义标记结构。

以下四条容易忽略,并且都有对应测试向量:

  1. 每个 < 都必须开始一个格式正确且允许的标签。拒绝裸露的小于号。
  2. 检查 URL scheme 前,先解码实体并移除控制字符。浏览器会解析 &#106;avascript: 等形式。
  3. 拒绝未加引号的属性值。不同 tokenizer 对 href=x onclick=y 等含糊标记的解释并不一致。
  4. 不要把 script 内容交回标签扫描器。对于允许的 JSON-LD,禁止正文中出现 <,防止提前用 </script> 逃逸。

查询允许元素时只检查对象自身的 key;继承的 toStringconstructor 等名称必须失败。两个一致性向量专门覆盖这一点。

验证,而不是清洗

拒绝策略之外的内容;不要删掉违规标记后存储剩余部分。

重写会破坏基于字节的内容偏差检测,手写删除逻辑还可能组合出不安全的残余标记。请拒绝整个请求载荷。

恒定时间比较 token

比较固定长度的摘要,而不是原始字符串;长度不同时提前返回也会泄露信息。请使用标准库:

PHP       hash_equals(hash('sha256', $expected), hash('sha256', $given))
Python    hmac.compare_digest(sha256(expected).digest(), sha256(given).digest())
Go        subtle.ConstantTimeCompare(e[:], g[:])   // e, g := sha256.Sum256(...)
Ruby      OpenSSL.secure_compare(Digest::SHA256.digest(expected), ...)
Java      MessageDigest.isEqual(sha256(expected), sha256(given))
Node      crypto.timingSafeEqual(sha256(expected), sha256(given))

检查顺序

顺序很重要;每一步都在保护下一步,使其不会处理无法安全接受的输入:

1. Bearer auth                    -> 401
2. Body size, before parsing      -> 413
3. JSON parse                     -> 400 invalid_json
4. Schema, including slug pattern -> 400 invalid_payload
5. Content policy                 -> 400 unsafe_content
6. Store

验证实现

chmod +x verify-receiver.sh
./verify-receiver.sh https://your-site.com "$YOUR_TOKEN"

脚本会发布并删除 geovector-conformance-check,同时验证危险 HTML 被拒绝。只有无法写入时才使用 --read-only;上线前至少完整运行一次。

本指南其余部分仍然适用

§8–§16 与编程语言无关;§6–§7 是 JavaScript 参考实现。

6. 参考实现 — Next.js App Router

这是完整示例:复制到 app/api/publish/route.ts,实现 storage§8),并设置 CMS_PUBLISH_TOKEN。示例包含 §9 的安全加固,请勿删除。唯一依赖是 HTML 允许列表检查:checkContent 定义于§9。这里建议使用库,不要手写 HTML tokenizer。

// app/api/publish/route.ts
import { timingSafeEqual, createHash } from 'node:crypto';
import { storage } from '@/lib/cms-storage';

export const dynamic = 'force-dynamic';   // never cache a mutation endpoint

const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
const MAX_SLUG = 200;             // senders cap at 100; leave headroom
const MAX_BODY_BYTES = 10 * 1024 * 1024;  // 10 MB, matching our reference receiver
const CATEGORIES = ['guide', 'comparison', 'industry', 'research'] as const;
const DEFAULT_CATEGORY = 'guide';
const SITE = 'https://acme.com';

const json = (status: number, body: unknown) =>
  new Response(JSON.stringify(body), {
    status,
    headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
  });

/** Constant-time bearer check over fixed-length digests (no length leak). */
function authorized(req: Request): boolean {
  const header = req.headers.get('authorization') ?? '';
  const m = header.match(/^Bearer\s+(.+)$/);
  if (!m) return false;
  const tokens = [process.env.CMS_PUBLISH_TOKEN, process.env.CMS_PUBLISH_TOKEN_PREVIOUS]
    .filter((t): t is string => !!t && t.length >= 32);
  const given = createHash('sha256').update(m[1]).digest();
  return tokens.some((t) => timingSafeEqual(given, createHash('sha256').update(t).digest()));
}

function validSlug(slug: unknown): slug is string {
  return typeof slug === 'string' && slug.length <= MAX_SLUG && SLUG_RE.test(slug);
}

/** Reads the body with a hard byte ceiling, metered as the bytes arrive. */
async function readJson(req: Request): Promise<{ ok: true; data: any } | { ok: false; res: Response }> {
  const tooLarge = { ok: false as const, res: json(413, { error: 'payload_too_large' }) };

  const declared = Number(req.headers.get('content-length'));
  if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return tooLarge;

  // Content-Length is a hint: absent on a chunked request, and not binding on
  // any request. Meter the stream and abandon an oversized body mid-flight
  // rather than buffering it first and checking afterwards.
  const reader = req.body?.getReader();
  if (!reader) return { ok: false, res: json(400, { error: 'invalid_json' }) };

  const chunks: Uint8Array[] = [];
  let total = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    total += value.byteLength;
    if (total > MAX_BODY_BYTES) { await reader.cancel(); return tooLarge; }
    chunks.push(value);
  }

  try {
    // Decode over the joined buffer: a multi-byte character can straddle chunks.
    return { ok: true, data: JSON.parse(Buffer.concat(chunks).toString('utf8')) };
  } catch {
    return { ok: false, res: json(400, { error: 'invalid_json' }) };
  }
}

export async function POST(req: Request) {
  if (!authorized(req)) return json(401, { error: 'unauthorized' });

  const body = await readJson(req);
  if (!body.ok) return body.res;
  const b = body.data;

  const issues: string[] = [];
  if (typeof b?.title !== 'string' || !b.title.trim()) issues.push('title');
  if (!validSlug(b?.slug)) issues.push('slug');
  if (typeof b?.content !== 'string' || !b.content.trim()) issues.push('content');
  if (b?.format !== 'html') issues.push('format');
  if (typeof b?.date !== 'string' || Number.isNaN(Date.parse(b.date))) issues.push('date');
  if (issues.length) return json(400, { error: 'invalid_payload', issues });

  const category = CATEGORIES.includes(b.category) ? b.category : DEFAULT_CATEGORY;

  // REQUIRED — see §9. You render this HTML on your own domain, so a leaked
  // token is stored XSS unless you decide what markup you are willing to keep.
  const verdict = checkContent(b.content);
  if (!verdict.ok) return json(400, { error: 'unsafe_content', violations: verdict.violations });

  try {
    const { action } = await storage.upsert({
      title: b.title,
      slug: b.slug,
      category,
      date: new Date(b.date),
      dateModified: b.dateModified ? new Date(b.dateModified) : new Date(),
      featured: b.featured === true,
      // Stored exactly as received. Nothing was rewritten, so the hash we
      // return from GET ?include=content still matches what the sender sent.
      content: b.content,
      excerpt: typeof b.excerpt === 'string' ? b.excerpt : undefined,
      author: typeof b.author === 'string' ? b.author : 'Acme',
      readTime: typeof b.readTime === 'string' ? b.readTime : undefined,
      tags: Array.isArray(b.tags) ? b.tags.filter((t: unknown) => typeof t === 'string') : [],
      thumbnail: b.thumbnail ?? null,
    });
    return json(200, { action, url: `${SITE}/articles/${b.slug}` });
  } catch (err) {
    console.error('[cms] upsert failed', err);          // details stay server-side
    return json(500, { error: 'storage_error' });
  }
}

export async function GET(req: Request) {
  if (!authorized(req)) return json(401, { error: 'unauthorized' });

  const url = new URL(req.url);
  const slug = url.searchParams.get('slug');
  if (!slug) return json(400, { error: 'slug_required' });
  // Validate here too: an unvalidated slug is path traversal on a file-backed store.
  if (!validSlug(slug)) return json(200, { exists: false });

  if (url.searchParams.get('include') === 'content') {
    const remote = await storage.fetch(slug);
    return remote
      ? json(200, { exists: true, content: remote.content, format: 'html' })
      : json(200, { exists: false });
  }
  return json(200, { exists: await storage.exists(slug) });
}

export async function DELETE(req: Request) {
  if (!authorized(req)) return json(401, { error: 'unauthorized' });

  const body = await readJson(req);
  if (!body.ok) return body.res;
  if (!validSlug(body.data?.slug)) return json(400, { error: 'invalid_payload', issues: ['slug'] });

  const { action } = await storage.delete(body.data.slug);
  return json(200, { action });
}

元数据子路由:

// app/api/publish/meta/route.ts
export const dynamic = 'force-dynamic';

export async function GET(req: Request) {
  // Same bearer check as /api/publish — extract it into a shared module.
  if (!authorized(req)) {
    return new Response(JSON.stringify({ error: 'unauthorized' }), {
      status: 401,
      headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
    });
  }
  return Response.json({
    protocol: 'geovector-cms',
    version: '1',
    categories: ['guide', 'comparison', 'industry', 'research'],
    defaultCategory: 'guide',
    articlePathTemplate: '/articles/{slug}',
  }, { headers: { 'cache-control': 'no-store' } });
}

7. 参考实现 — Express

协议和规则相同。注意显式请求体限制,以及 DELETE 会携带 JSON 请求体。

import express from 'express';
import { timingSafeEqual, createHash } from 'node:crypto';
import { storage } from './cms-storage.js';

const router = express.Router();
router.use((_req, res, next) => {
  res.set('Cache-Control', 'no-store');
  next();
});
router.use(express.json({ limit: '10mb' }));  // rejects past the ceiling

// body-parser throws before any route runs. Without this, Express answers with
// its default HTML error page instead of the JSON envelope this API promises.
router.use((err, _req, res, next) => {
  if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'payload_too_large' });
  if (err?.type === 'entity.parse.failed') return res.status(400).json({ error: 'invalid_json' });
  return next(err);
});

const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
const validSlug = (s) => typeof s === 'string' && s.length <= 200 && SLUG_RE.test(s);

function auth(req, res, next) {
  const m = (req.get('authorization') || '').match(/^Bearer\s+(.+)$/);
  const expected = process.env.CMS_PUBLISH_TOKEN;
  if (!m || !expected) return res.status(401).json({ error: 'unauthorized' });
  const a = createHash('sha256').update(m[1]).digest();
  const b = createHash('sha256').update(expected).digest();
  if (!timingSafeEqual(a, b)) return res.status(401).json({ error: 'unauthorized' });
  next();
}

router.post('/api/publish', auth, async (req, res) => {
  const b = req.body;
  if (!validSlug(b?.slug) || b?.format !== 'html' || !b?.title || !b?.content) {
    return res.status(400).json({ error: 'invalid_payload' });
  }
  // REQUIRED — see §9. Store the HTML exactly as received, or not at all.
  const verdict = checkContent(b.content);
  if (!verdict.ok) return res.status(400).json({ error: 'unsafe_content', violations: verdict.violations });
  try {
    const { action } = await storage.upsert(b);
    res.json({ action, url: `https://acme.com/articles/${b.slug}` });
  } catch (err) {
    req.log?.error(err);
    res.status(500).json({ error: 'storage_error' });
  }
});

router.get('/api/publish', auth, async (req, res) => {
  const { slug, include } = req.query;
  if (!slug) return res.status(400).json({ error: 'slug_required' });
  if (!validSlug(slug)) return res.json({ exists: false });
  if (include === 'content') {
    const remote = await storage.fetch(slug);
    return res.json(remote ? { exists: true, content: remote.content, format: 'html' }
                           : { exists: false });
  }
  res.json({ exists: await storage.exists(slug) });
});

router.delete('/api/publish', auth, async (req, res) => {
  if (!validSlug(req.body?.slug)) return res.status(400).json({ error: 'invalid_payload' });
  res.json(await storage.delete(req.body.slug));
});

router.get('/api/publish/meta', auth, (_req, res) => res.json({
  protocol: 'geovector-cms',
  version: '1',
  categories: ['guide', 'comparison', 'industry', 'research'],
  defaultCategory: 'guide',
  articlePathTemplate: '/articles/{slug}',
}));

export default router;

8. 存储适配器

只需四个方法。前面的逻辑是通用的;这里是唯一与您的技术栈相关的部分。

interface CmsStorage {
  upsert(input: ArticleInput): Promise<{ action: 'created' | 'updated' }>;
  delete(slug: string):        Promise<{ action: 'deleted' | 'not_found' }>;
  exists(slug: string):        Promise<boolean>;
  fetch(slug: string):         Promise<{ content: string; format: string } | null>;
}

要求

  • slug upsert,并报告实际操作。写入前检查是否存在,以区分 createdupdated;GeoVector 会向用户显示该结果。
  • 更新时不要重设发布日期。date 只在创建时设置;更新时写入 dateModified
  • 在写入操作内部使缓存失效。如果发布返回 200 后页面仍显示旧内容,集成看起来就是坏的。Next.js 应对文章、索引和 sitemap 调用 revalidateTag()revalidatePath();CDN 应在响应前完成 purge。
  • 未经验证的 slug 不得进入文件系统。只用已经通过正则验证的 slug 构建路径,解析到内容根目录后再次确认结果仍在该目录内。
  • 写入必须幂等。重复或重放的请求载荷只能更新同一篇文章,绝不能创建重复内容。

常见存储方式

后端存储实现方式注意事项
SQL 记录UPSERT … ON CONFLICT (slug);HTML 存入文本列最简单,推荐使用。对读取结果设置缓存标签。
Markdown + git写文件、commit、push,由 CI 部署POST 不应等待构建完成。提交成功即可返回 200,网址稍后上线。
无头 CMS代理到供应商 API将供应商 id 映射到 slug,并确保其速率限制不会触碰 30 秒截止时间。
对象存储使用 articles/{slug}.html 作为 key在写入内部清理 CDN,不要等到之后。

9. 安全加固 — 上线前必须完成

该端点会在您的域名上渲染远程 HTML。请把它视为高权限写入 API。

不可妥协的要求
  1. 接收时应用 HTML 策略。拒绝下方精确允许列表之外的所有内容;否则 token 泄露会变成存储型 XSS。
  2. 对 POST、GET 和 DELETE 的 slug 都执行正则验证。读取和删除路径最容易遗漏,也是目录穿越风险所在。
  3. 解析前限制请求体大小,超限返回 413。不接收 imageBase64 时可用 5 MB,接收时用 10 MB。必须计量数据流;Content-Length 可能缺失。
  4. 对固定长度摘要进行恒定时间 token 比较
  5. 只允许 HTTPS,并启用 HSTS。绝不能通过明文 HTTP 接收 token。
  6. 端点不可缓存。设置 Cache-Control: no-store,并确保 CDN 规则不会缓存 /api/publish

此外强烈建议:

  • 速率限制 — 每个 token 每分钟 60 次已远高于真实发布量。返回 429Retry-After
  • 记录每次变更 — 时间、HTTP 方法、slug、action、响应码和 request id。
  • 回传 request id — 返回 X-Request-Id;请求中已有时沿用,便于跨系统排查。
  • 监控 5005xx 会把连接标记为失败,并在控制面板中显示您的通用响应消息;详细异常只应保留在日志中。
  • 保持快速。客户端会在 30 秒后中止。图片处理和网站构建等耗时工作应放入响应后启动的后台任务。

内容策略 — 允许什么,以及为何拒绝而非删除

下表是接收端允许列表的易读版本。请根据自动生成的 article-html-policy.v1.json 实现,并使用 html-policy-conformance.v1.json 测试;它们包含表格无法表达的规则。

类别允许内容
结构article, section, header, footer, main, aside, div, figure, figcaption
标题h1h6
文本p, span, strong, b, em, i, u, s, small, sub, sup, mark, abbr, br, hr, wbr, time (datetime)
列表ul, ol (start, reversed, type), li (value), dl, dt, dd
引用和代码blockquote/q (cite), cite, pre, code, kbd, samp, var
表格table, caption, colgroup/col (span), thead, tbody, tfoot, tr, th (scope, colspan, rowspan, abbr, headers), td (colspan, rowspan, headers)
链接和媒体a (href, rel, hreflang), img (src, alt, width, height, loading, decoding)
展开内容details (open), summary
全局属性class, id, title, lang, dir — 可用于上述任何元素
URLhref/src/cite 必须是相对地址,或使用 http, https, mailto

唯一允许的 script 是 <script type="application/ld+json">:必须使用这个精确 type,不得有其他属性,正文必须是有效 JSON,且不得包含 <。嵌入其他不可信 JSON-LD 时,把 < 转义为 \u003c

拒绝,不要删除

返回 400 {"error":"unsafe_content","violations":[…]},且不存储任何内容:

  1. 内容偏差检测依赖字节相等。重写会让每篇文章立即显示为已修改。
  2. 拒绝本身是重要信号。异常 HTML 表示生成管线发生变化或 token 被滥用,应保留在日志中。

如果必须重写,请使用 sanitize-html 或 DOMPurify 配合 jsdom,存储清洗后的内容,并告知我们,让偏差检查使用该形式。

§6 示例sanitize-html 用作验证器:任何重写都意味着拒绝。JSON-LD 单独检查,因为该库无法表达其精确的允许结构。

import sanitizeHtml from 'sanitize-html';

const POLICY = {
  allowedTags: ['article','section','header','footer','main','aside','div','figure','figcaption',
    'h1','h2','h3','h4','h5','h6','p','span','strong','b','em','i','u','s','small','sub','sup',
    'mark','abbr','br','hr','wbr','time','ul','ol','li','dl','dt','dd','blockquote','q','cite',
    'pre','code','kbd','samp','var','table','caption','colgroup','col','thead','tbody','tfoot',
    'tr','th','td','a','img','details','summary'],
  allowedAttributes: {
    '*': ['class', 'id', 'title', 'lang', 'dir'],
    a: ['href', 'rel', 'hreflang'],
    img: ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
    th: ['scope', 'colspan', 'rowspan', 'abbr', 'headers'],
    td: ['colspan', 'rowspan', 'headers'],
    ol: ['start', 'reversed', 'type'],
    li: ['value'],
    col: ['span'], colgroup: ['span'], time: ['datetime'],
    blockquote: ['cite'], q: ['cite'], details: ['open'],
  },
  allowedSchemes: ['http', 'https', 'mailto'],
  allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
  disallowedTagsMode: 'discard',
} as const;

// The one script we accept. Every part of this shape is load-bearing: that
// exact type, no other attributes, and a body containing no `<` at all — so
// there is nothing in it a browser can read as the end of the element.
const JSON_LD_BLOCK = /<script type="application\/ld\+json">([^<]*)<\/script>/g;

function checkContent(html: string): { ok: true } | { ok: false; violations: string[] } {
  const violations: string[] = [];

  // Lift the JSON-LD out before sanitizing. `sanitize-html` drops script bodies
  // by design (`nonTextTags`), so handing it one would make every article that
  // carries structured data look modified. Validate it on its own terms
  // instead; anything script-shaped that this regex does *not* match is not a
  // well-formed inert block and is caught immediately below.
  const withoutJsonLd = html.replace(JSON_LD_BLOCK, (_match, body: string) => {
    try {
      JSON.parse(body);
    } catch {
      violations.push('invalid-json-ld');
    }
    return '';
  });
  if (/<script/i.test(withoutJsonLd)) violations.push('script');

  const cleaned = sanitizeHtml(withoutJsonLd, POLICY);
  if (cleaned !== withoutJsonLd) {
    // Any mismatch is a rejection, named or not. Being unable to name what was
    // removed is not a reason to accept it: a stripped `onerror` leaves the
    // element name in the allowlist, so an unnamed violation is exactly the
    // case you least want to let through.
    const unknown = [...withoutJsonLd.matchAll(/<([a-zA-Z][a-zA-Z0-9]*)/g)]
      .map((m) => m[1].toLowerCase())
      .filter((t) => !POLICY.allowedTags.includes(t as never));
    violations.push(...(unknown.length > 0 ? unknown : ['sanitizer-mismatch']));
  }

  if (violations.length === 0) return { ok: true };
  return { ok: false, violations: [...new Set(violations)] };
}
存储原始内容,而不是 withoutJsonLd

提取出的副本只用于验证。请原样持久化原始 content,包括 JSON-LD。

注意:sanitize-html 会归一化某些安全标记,因此比较可能产生 sanitizer-mismatch。请用真实文章测试。必要时在比较前做相同归一化,或存储清洗后的形式并接受偏差检测后果。我们的接收端直接扫描,因此可以保持字节精确。

10. 渲染我们发送的 HTML

content 是一个片段,不是完整文档:其中没有 <html><head><body>。结构如下:

<article>
<h1>How AI Assistants Choose Sources</h1>
<main>
  <p>Opening paragraph…</p>
  <h2>Section heading</h2>
  <p>Body copy with a <a href="https://example.com/study">citation</a>.<sup id="fnref-1"><a href="#fn-1">1</a></sup></p>
  <ul><li>List item</li></ul>
  <table><thead>…</thead><tbody>…</tbody></table>
  <blockquote><p>Pull quote</p></blockquote>
  <h2>Sources</h2>
  <ol><li id="fn-1">Source title — publisher, 2026. <a href="…">link</a></li></ol>
</main>
</article>
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [{ "@type": "Question", "name": "…", "acceptedAnswer": { "@type": "Answer", "text": "…" } }]
}
</script>
  • 服务端渲染。文章 HTML 必须存在于初始响应中。多数 AI 爬虫看不到客户端 JavaScript 注入的内容。
  • 片段已自带 <h1>不要根据 title 再添加第二个;title 用于 <title>、OG 标签和列表卡片。
  • 末尾的 FAQPage 区块属于片段。请原样渲染。把 HTML 转为搜索文本、摘要或 /llms.txt 前,应连同正文一起移除 script 区块,避免原始 JSON 混入文本。
  • 不要删除 id/href 锚点。脚注引用通过 #fnref-N#fn-N 配对;过度清洗会破坏跳转。
  • 正确设置表格样式。比较表格很容易被 AI 答案引用;在移动端使用 overflow-x: auto 容器。
  • 保留真实外链。不要给引用来源添加 nofollow,也不要通过重定向器改写;引用图谱是衡量对象的一部分。

11. 面向 AI 可见性的页面要求

以下属性能帮助 AI 助手发现、抓取并引用已发布页面。

要求原因
服务端渲染 HTML(SSR/SSG),正文不能只依赖 JS多数 AI 抓取器不执行 JavaScript;没有 HTML 就不会有引用。
<link rel="canonical"> 与返回的 url 一致同一文章分散到多个地址会分散权威度并破坏归因。
Article JSON-LD,其中包含 headlinedatePublisheddateModifiedauthorpublisher提供机器可读的来源信息,更有利于内容被引用而非仅被改写。
正文有问答部分时输出 FAQPage JSON-LD;该内容已包含在 content,只需原样渲染问题形式的内容块容易被助手直接引用。
输出 <meta name="description">,内容取自 excerpt,并添加 OG/Twitter 标签用于不同平台的摘要和链接预览。
发布后几分钟内把文章加入 sitemap.xml在 upsert 的同一写入路径中更新 sitemap。
robots.txt 允许您希望获得引用的 AI 爬虫GPTBotOAI-SearchBotClaudeBotPerplexityBotGoogle-ExtendedBingbot。屏蔽它们会使发布失去意义。
文章网址没有 bot challenge过于激进的 WAF 或中间页会向爬虫返回挑战页面而不是文章。请允许文章路径。
网址稳定;必须更改时使用 301引用会积累在具体地址上;无重定向地移动页面会丢失历史。
TTFB 快(<800 ms),且无需登录爬虫会超时;受限内容不会被引用。
可选:用 /llms.txt 列出已发布文章这是帮助助手发现优质内容的新兴约定。

12. 使用 curl 验证

连接前请针对部署环境运行以下命令。十项检查都必须得到所示结果。

也可以一次运行完整验证

./verify-receiver.sh https://acme.com "$TOKEN" 还会检查大小限制、内容策略、目录穿越 slug 和逐字节一致的读回内容。

curl -sO https://www.geovector.ai/docs/cms/verify-receiver.sh
chmod +x verify-receiver.sh
./verify-receiver.sh https://acme.com "$TOKEN"

脚本会发布一篇探测文章,然后将其删除。添加 --read-only 可跳过所有写操作,只检查读取路径。

SITE=https://acme.com
TOKEN=your-token

# 1 — auth is enforced (expect 401)
curl -s -o /dev/null -w '%{http_code}\n' "$SITE/api/publish?slug=__verify__"

# 2 — the exact probe GeoVector runs at connect (expect {"exists":false})
curl -s -H "Authorization: Bearer $TOKEN" "$SITE/api/publish?slug=__verify__"

# 3 — capabilities (expect the protocol envelope)
curl -s -H "Authorization: Bearer $TOKEN" "$SITE/api/publish/meta"

# 4 — publish (expect {"action":"created","url":"https://acme.com/articles/..."})
curl -s -X POST "$SITE/api/publish" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"Integration Test","slug":"geovector-integration-test",
       "category":"guide","date":"2026-08-06","format":"html","featured":false,
       "excerpt":"Verifying the GeoVector CMS protocol.",
       "content":"<article><h1>Integration Test</h1><main><p>Hello.</p></main></article>"}'

# 5 — the returned URL actually serves the article, server-rendered
curl -s "$SITE/articles/geovector-integration-test" | grep -c 'Integration Test'

# 6 — read back (content must match byte-for-byte what you stored)
curl -s -H "Authorization: Bearer $TOKEN" \
  "$SITE/api/publish?slug=geovector-integration-test&include=content"

# 7 — re-publish is an update, not a duplicate (expect "updated")
#     repeat step 4 verbatim

# 8 — unpublish (expect {"action":"deleted"}), then again (expect "not_found")
curl -s -X DELETE "$SITE/api/publish" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"slug":"geovector-integration-test"}'

# 9 — traversal is refused, not executed
curl -s -X DELETE "$SITE/api/publish" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"slug":"../../etc/passwd"}'          # expect 400, and nothing deleted

# 10 — a non-ISO date is refused (expect 400 invalid_payload)
curl -s -X POST "$SITE/api/publish" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"x","slug":"geovector-integration-test","category":"guide",
       "date":"August 06, 2026","format":"html","featured":false,
       "content":"<article><h1>x</h1><main><p>x</p></main></article>"}'
还要确认

完成步骤 4 后,文章应无需手动构建即可出现在索引页和 sitemap.xml 中。缓存失效是最常见的“本地正常、线上失败”原因。

13. 在 GeoVector 中连接

  1. 打开 Settings → Integrations → GeoVector CMS
  2. 输入 Site URL:只包含 scheme 和规范主机,不带末尾斜杠(https://www.acme.com),也不要填写 /api/publish 路径。
  3. 输入 API key:即您的 Bearer token。它会被加密存储,之后不再显示。
  4. 点击 Verify。我们会探测 GET /api/publish?slug=__verify__,随后获取 /api/publish/meta,仅在探测成功时保存。
  5. 从由 /meta 填充的下拉菜单中选择默认分类。

发布后,GeoVector 会记录并开始监测返回网址上的 AI 引用。

14. 故障排查

GeoVector 报告原因解决办法
unauthorizedtoken 不匹配(401/403逐字节比较;echo 产生的末尾换行最常见。确认环境变量已设置在部署环境,而非仅在本地。
not_geovector_cms404,或 200 但响应体不是 JSON/api/publish 未部署,或 catch-all 路由/CDN 抢先响应。使用 curl 步骤 2 验证。
invalid_response200 JSON 中没有 boolean 类型的 existsGET handler 必须直接返回 {"exists": <boolean>},不要添加外层 envelope。
unreachableDNS 失败、TLS 错误或响应超过 8 秒检查证书链和冷启动延迟。WAF 对探测请求发出挑战时也会显示此错误。
blocked_private_host主机解析到私有或 loopback 地址我们拒绝非公网目标。请使用公网网址;staging 也必须可公开访问。
redirect_offsite网站重定向到其他注册域直接填写目标主机;GeoVector 会把它显示为一键建议。
server_errorhandler 抛出异常查看该请求的日志。常见原因是缺少 CMS_PUBLISH_TOKEN 或数据库尚未迁移。
发布成功,但页面返回 404url 与真实路由不一致,或缓存未清理返回路由实际提供的地址;在写入内部使缓存失效。
未修改的文章显示 “Modified externally”读回 HTML 与存储内容不同只在写入时清洗或归一化一次,存储结果并原样返回;读取时不要再次转换。
每次发布都会创建重复内容使用 insert 而不是 upsertslug 作为唯一约束。
发布在 30 秒后超时请求内同步执行构建或图片处理先响应,再在后台任务中执行耗时工作。
Settings 显示 “Publishing failing”上次到达您的请求返回 5xx、超时,或以 401/403 拒绝横幅消息来自您的响应体。修复原因后,任何成功发布都会自动清除标记;重新验证并保存连接也可以。连接并未被禁用。
一次错误草稿后显示 “Publishing failing”接收端返回了 500,而实际应为 400拒绝请求载荷时返回 4005xx 表示您的网站故障(参见 §3)。

15. 向前兼容

  • 忽略未知请求字段。v1.x 只会增加可选字段;严格拒绝会在新字段发布时破坏接收端。
  • 响应中可以自由返回 GeoVector 尚不认识的字段;我们会忽略它们。
  • /meta 中如实声明。capabilities 用于声明最大请求体、图片支持、语言区域及持久化的可选字段。缺失表示“未知,假定基线”;保持沉默仍符合规范,夸大能力才是真正的问题。
  • 破坏性变更会使用新版本并在上线前公布;v1 接收端会继续工作。
  • 保留 slug:__verify__ 用于连接探测,绝不能以此 slug 存储文章。

v1 已新增的附加字段包括:metaDescriptioncanonicalUrlogImageUrllocalenoindexcapabilities。目前已强制验证 datedateModified 的 ISO-8601 格式。

文档修订记录

文档修订不会改变协议版本。

修订日期变更内容
1 2026-08-06 首个公开版本及配套文件。新增状态码影响说明;更正重试和 capabilities 行为。
2 2026-08-07 补充遗漏的错误响应和完整的 URL 字符拒绝表。协议未变。
3 2026-08-07 精简重复说明,同时保留协议、示例和安全要求。协议未变。

16. 上线检查清单

  • POST /api/publish 按 slug upsert,并返回绝对 url
  • GET /api/publish?slug= 返回 { exists }
  • GET /api/publish?slug=&include=content 原样返回已存储 HTML
  • DELETE /api/publish 删除文章并返回 deleted / not_found
  • GET /api/publish/meta 声明协议、版本、分类、默认值和路径模板
  • 五项操作都要求 Bearer token;使用恒定时间比较;失败返回 401
  • POST、GET 和 DELETE 都执行 slug 正则验证
  • 存储前按允许列表检查 HTML,超出策略返回 400 unsafe_content
  • 逐字节存储已接受的 HTML,保证内容偏差检测有意义
  • 限制请求体大小,超限返回 413
  • 请求载荷被拒绝时返回 400,绝不返回 500;5xx 会向客户表示网站故障
  • 端点只通过 HTTPS 提供,设置 no-store,且绝不被 CDN 缓存
  • 配置速率限制和变更日志
  • 验证 datedateModified 为真实存在的 ISO-8601 日期
  • date 只在创建时设置;更新时写入 dateModified
  • canonicalUrlogImageUrl 仅接受绝对 http(s) URL
  • 在 meta 标签和 sitemap 中执行 noindex;缺失表示允许索引
  • 在写入内部使文章缓存、索引页和 sitemap.xml 失效
  • 文章页面由服务端渲染,并包含 canonical link 和 Article JSON-LD
  • robots.txt 允许您希望获得引用的 AI 爬虫
  • §12 中十项 curl 检查全部在生产环境通过
  • token 存储在部署环境中,并已编写轮换流程