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_TOKEN 和 CMS_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" }
返回绝对 url。GeoVector 会通过该地址监测 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 — 声明不支持的能力
该对象及其中所有字段均为可选。请在设置阶段声明限制,避免到发布时才发现。
| 字段 | 类型 | 缺失时的含义 |
|---|---|---|
protocolVersions | string[] | ["1"] |
supportsDelete | boolean | 支持;DELETE 属于 v1 基线。仅追加的接收端应发送 false。 |
maxBodyBytes | integer | 未知。声明触发 413 的上限。 |
imageMimeTypes | string[] | 未知。明确的 [] 表示不接受图片;请声明,而不是静默丢弃。 |
locales | string[] | 未知。列出实际可路由的语言区域。 |
fields | string[] | 未知。列出实际持久化的可选字段,使发送端知道哪些内容会被保留。 |
缺少某个字段或整个对象时,发送端会“假定支持 v1 基线”。请省略对象,不要发送 {};也不要声明会被静默丢弃的字段。
GeoVector 目前只会根据 categories、defaultCategory 和 articlePathTemplate 改变行为。capabilities 内的字段会被验证,但目前仅作提示;例如 supportsDelete: false 尚不会隐藏“撤回发布”。仍请准确声明,以便未来执行这些限制。
状态码
| 状态码 | 使用场景 | 响应体 |
|---|---|---|
200 | 成功,包括删除时的 not_found | 按操作定义 |
400 | 请求体不是 JSON | {"error":"invalid_json"} |
400 | JSON 不符合字段 schema | {"error":"invalid_payload","issues":{…}} |
400 | GET 缺少 slug 查询参数 | {"error":"slug_required"} |
400 | HTML 不符合内容策略 | {"error":"unsafe_content","violations":[…]} |
401 | Bearer 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-After;429 会使本次发布失败,但不会把网站标记为不健康。
4. 文章字段
| 字段 | 类型 | 要求 | 说明 |
|---|---|---|---|
title | string | MUST | 纯文本,不是 HTML。正文已包含自己的 <h1>。 |
slug | string | MUST | 主键;匹配 ^[a-z0-9][a-z0-9-]*$。接受最长 200 个字符,并在每个 HTTP 方法中验证。 |
category | string | MUST | 应为 /meta 声明的值之一。遇到未知值时使用默认分类,不要拒绝。 |
date | string | MUST | ISO-8601,例如 2026-08-06 或 2026-08-06T09:30:00Z。拒绝长格式日期以及 2026-02-30 等不存在的日期。 |
content | string | MUST | HTML 片段。文章有问答部分时,会包含 <script type="application/ld+json"> 的 FAQPage 区块,位于 </article> 之后;请原样渲染。参见 §10。 |
format | "html" | MUST | 固定值。拒绝其他值,避免未来格式被错误存储。 |
featured | boolean | MAY | 默认为 false。可以采用或忽略。 |
excerpt | string | SHOULD | 用于卡片摘要和 <meta name="description">。缺失时从首段生成。 |
author | string | SHOULD | 显示名称。默认为您的品牌。 |
readTime | string | MAY | 已格式化文本("7 min read")。缺失时可按约 200 wpm 计算。 |
tags | string[] | MAY | 自由格式。 |
dateModified | string | SHOULD | ISO-8601,规则同 date。写入 Article JSON-LD;更新时缺失则填入当前时间。 |
metaDescription | string | SHOULD | <meta name="description">,最多 500 个字符。缺失时使用 excerpt。与 title 一样,这是纯文本;写入属性时须进行 HTML 转义。 |
canonicalUrl | string | SHOULD | 用于转载内容的绝对 http(s) URL。缺失表示使用自身规范网址。见下方 URL 规则。 |
ogImageUrl | string | MAY | 已托管图片的绝对 http(s) URL。发送端只会提供此字段或 imageBase64 之一。 |
locale | string | MAY | BCP-47 标签(en、en-GB、zh-Hans),用于 <html lang>。在 capabilities.locales 中声明支持的值。 |
noindex | boolean | MAY | 输出 <meta name="robots" content="noindex">,并从 sitemap 中排除页面。缺失表示允许索引。 |
thumbnail | object | MAY | 在没有主图的网站上用于卡片图形的 {gradientFrom, gradientTo} 十六进制颜色对。 |
imageBase64 | string | MAY | 原始 base64(无 data-URI 前缀),不声明 MIME 类型。检查 magic bytes,限制解码后大小,存入对象存储并用作 OG 图片。v1 允许忽略。 |
忽略未知字段;可选字段会在不改变协议 v1 的情况下新增。
您不会收到 jsonLd 字段。结构化数据以内联方式包含在 content 中,内容策略会验证其中的 <script type="application/ld+json">。单独的字段会迫使双方合并两个来源。
canonicalUrl 和 ogImageUrl 必须是绝对 http 或 https URL。仅检查 scheme 并不安全:许多解析器会接受能够逃逸 HTML 属性的原始字符。存储前请拒绝下表中的所有字符。
| URL 字段中任何位置都应拒绝 | 原因 |
|---|---|
" ' ` | 结束 href="…" 的引号,使剩余内容被解释为标记。 |
< > | 可直接开启标签;在未加引号的属性中单独出现就足以造成问题。 |
任何空白字符,包括 U+00A0 | 把一个属性拆成两个。语言中的 \s 字符类通常已涵盖完整集合。 |
任何控制字符:U+0000–U+001F 和 U+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,并对 canonicalUrl 和 ogImageUrl 拒绝 §4 表格中的所有原始字符。scheme 匹配不区分大小写;字符规则不可省略。详见§4。 |
| HTML 内容策略 | 主要工作 | 根据允许列表编写扫描器,并使用一致性向量测试。这是最需要认真处理的部分。 |
您会在自己的域名上渲染远程生成的 HTML。没有允许列表时,泄露的 API key 会直接变成存储型 XSS。
编写扫描器
直接读取并执行 article-html-policy.v1.json:elements 把允许的元素映射到其额外属性;globalAttributes 适用于所有元素;urlAttributes 列出必须通过 url 规则的属性;jsonLd 描述唯一允许的 <script> 形式;structure 定义标记结构。
以下四条容易忽略,并且都有对应测试向量:
- 每个
<都必须开始一个格式正确且允许的标签。拒绝裸露的小于号。 - 检查 URL scheme 前,先解码实体并移除控制字符。浏览器会解析
javascript:等形式。 - 拒绝未加引号的属性值。不同 tokenizer 对
href=x onclick=y等含糊标记的解释并不一致。 - 不要把 script 内容交回标签扫描器。对于允许的 JSON-LD,禁止正文中出现
<,防止提前用</script>逃逸。
查询允许元素时只检查对象自身的 key;继承的 toString、constructor 等名称必须失败。两个一致性向量专门覆盖这一点。
拒绝策略之外的内容;不要删掉违规标记后存储剩余部分。
重写会破坏基于字节的内容偏差检测,手写删除逻辑还可能组合出不安全的残余标记。请拒绝整个请求载荷。
恒定时间比较 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>;
}
要求
- 按
slugupsert,并报告实际操作。写入前检查是否存在,以区分created和updated;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。
- 接收时应用 HTML 策略。拒绝下方精确允许列表之外的所有内容;否则 token 泄露会变成存储型 XSS。
- 对 POST、GET 和 DELETE 的
slug都执行正则验证。读取和删除路径最容易遗漏,也是目录穿越风险所在。 - 解析前限制请求体大小,超限返回
413。不接收imageBase64时可用 5 MB,接收时用 10 MB。必须计量数据流;Content-Length可能缺失。 - 对固定长度摘要进行恒定时间 token 比较。
- 只允许 HTTPS,并启用 HSTS。绝不能通过明文 HTTP 接收 token。
- 端点不可缓存。设置
Cache-Control: no-store,并确保 CDN 规则不会缓存/api/publish。
此外强烈建议:
- 速率限制 — 每个 token 每分钟 60 次已远高于真实发布量。返回
429和Retry-After。 - 记录每次变更 — 时间、HTTP 方法、slug、action、响应码和 request id。
- 回传 request id — 返回
X-Request-Id;请求中已有时沿用,便于跨系统排查。 - 监控
500。5xx 会把连接标记为失败,并在控制面板中显示您的通用响应消息;详细异常只应保留在日志中。 - 保持快速。客户端会在 30 秒后中止。图片处理和网站构建等耗时工作应放入响应后启动的后台任务。
内容策略 — 允许什么,以及为何拒绝而非删除
下表是接收端允许列表的易读版本。请根据自动生成的 article-html-policy.v1.json 实现,并使用 html-policy-conformance.v1.json 测试;它们包含表格无法表达的规则。
| 类别 | 允许内容 |
|---|---|
| 结构 | article, section, header, footer, main, aside, div, figure, figcaption |
| 标题 | h1–h6 |
| 文本 | 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 — 可用于上述任何元素 |
| URL | href/src/cite 必须是相对地址,或使用 http, https, mailto |
唯一允许的 script 是 <script type="application/ld+json">:必须使用这个精确 type,不得有其他属性,正文必须是有效 JSON,且不得包含 <。嵌入其他不可信 JSON-LD 时,把 < 转义为 \u003c。
返回 400 {"error":"unsafe_content","violations":[…]},且不存储任何内容:
- 内容偏差检测依赖字节相等。重写会让每篇文章立即显示为已修改。
- 拒绝本身是重要信号。异常 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,其中包含 headline、datePublished、dateModified、author、publisher | 提供机器可读的来源信息,更有利于内容被引用而非仅被改写。 |
正文有问答部分时输出 FAQPage JSON-LD;该内容已包含在 content 中,只需原样渲染 | 问题形式的内容块容易被助手直接引用。 |
输出 <meta name="description">,内容取自 excerpt,并添加 OG/Twitter 标签 | 用于不同平台的摘要和链接预览。 |
发布后几分钟内把文章加入 sitemap.xml | 在 upsert 的同一写入路径中更新 sitemap。 |
robots.txt 允许您希望获得引用的 AI 爬虫 | GPTBot、OAI-SearchBot、ClaudeBot、PerplexityBot、Google-Extended、Bingbot。屏蔽它们会使发布失去意义。 |
| 文章网址没有 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 中连接
- 打开 Settings → Integrations → GeoVector CMS。
- 输入 Site URL:只包含 scheme 和规范主机,不带末尾斜杠(
https://www.acme.com),也不要填写/api/publish路径。 - 输入 API key:即您的 Bearer token。它会被加密存储,之后不再显示。
- 点击 Verify。我们会探测
GET /api/publish?slug=__verify__,随后获取/api/publish/meta,仅在探测成功时保存。 - 从由
/meta填充的下拉菜单中选择默认分类。
发布后,GeoVector 会记录并开始监测返回网址上的 AI 引用。
14. 故障排查
| GeoVector 报告 | 原因 | 解决办法 |
|---|---|---|
unauthorized | token 不匹配(401/403) | 逐字节比较;echo 产生的末尾换行最常见。确认环境变量已设置在部署环境,而非仅在本地。 |
not_geovector_cms | 404,或 200 但响应体不是 JSON | /api/publish 未部署,或 catch-all 路由/CDN 抢先响应。使用 curl 步骤 2 验证。 |
invalid_response | 200 JSON 中没有 boolean 类型的 exists | GET handler 必须直接返回 {"exists": <boolean>},不要添加外层 envelope。 |
unreachable | DNS 失败、TLS 错误或响应超过 8 秒 | 检查证书链和冷启动延迟。WAF 对探测请求发出挑战时也会显示此错误。 |
blocked_private_host | 主机解析到私有或 loopback 地址 | 我们拒绝非公网目标。请使用公网网址;staging 也必须可公开访问。 |
redirect_offsite | 网站重定向到其他注册域 | 直接填写目标主机;GeoVector 会把它显示为一键建议。 |
server_error | handler 抛出异常 | 查看该请求的日志。常见原因是缺少 CMS_PUBLISH_TOKEN 或数据库尚未迁移。 |
| 发布成功,但页面返回 404 | url 与真实路由不一致,或缓存未清理 | 返回路由实际提供的地址;在写入内部使缓存失效。 |
| 未修改的文章显示 “Modified externally” | 读回 HTML 与存储内容不同 | 只在写入时清洗或归一化一次,存储结果并原样返回;读取时不要再次转换。 |
| 每次发布都会创建重复内容 | 使用 insert 而不是 upsert | 以 slug 作为唯一约束。 |
| 发布在 30 秒后超时 | 请求内同步执行构建或图片处理 | 先响应,再在后台任务中执行耗时工作。 |
| Settings 显示 “Publishing failing” | 上次到达您的请求返回 5xx、超时,或以 401/403 拒绝 | 横幅消息来自您的响应体。修复原因后,任何成功发布都会自动清除标记;重新验证并保存连接也可以。连接并未被禁用。 |
| 一次错误草稿后显示 “Publishing failing” | 接收端返回了 500,而实际应为 400 | 拒绝请求载荷时返回 400。5xx 表示您的网站故障(参见 §3)。 |
15. 向前兼容
- 忽略未知请求字段。v1.x 只会增加可选字段;严格拒绝会在新字段发布时破坏接收端。
- 响应中可以自由返回 GeoVector 尚不认识的字段;我们会忽略它们。
- 在
/meta中如实声明。capabilities用于声明最大请求体、图片支持、语言区域及持久化的可选字段。缺失表示“未知,假定基线”;保持沉默仍符合规范,夸大能力才是真正的问题。 - 破坏性变更会使用新版本并在上线前公布;v1 接收端会继续工作。
- 保留 slug:
__verify__用于连接探测,绝不能以此 slug 存储文章。
v1 已新增的附加字段包括:metaDescription、canonicalUrl、ogImageUrl、locale、noindex 和 capabilities。目前已强制验证 date 和 dateModified 的 ISO-8601 格式。
文档修订记录
文档修订不会改变协议版本。
| 修订 | 日期 | 变更内容 |
|---|---|---|
| 1 | 2026-08-06 | 首个公开版本及配套文件。新增状态码影响说明;更正重试和 capabilities 行为。 |
| 2 | 2026-08-07 | 补充遗漏的错误响应和完整的 URL 字符拒绝表。协议未变。 |
| 3 | 2026-08-07 | 精简重复说明,同时保留协议、示例和安全要求。协议未变。 |
16. 上线检查清单
POST /api/publish按 slug upsert,并返回绝对urlGET /api/publish?slug=返回{ exists }GET /api/publish?slug=&include=content原样返回已存储 HTMLDELETE /api/publish删除文章并返回deleted/not_foundGET /api/publish/meta声明协议、版本、分类、默认值和路径模板- 五项操作都要求 Bearer token;使用恒定时间比较;失败返回
401 - POST、GET 和 DELETE 都执行 slug 正则验证
- 存储前按允许列表检查 HTML,超出策略返回
400 unsafe_content - 逐字节存储已接受的 HTML,保证内容偏差检测有意义
- 限制请求体大小,超限返回
413 - 请求载荷被拒绝时返回
400,绝不返回500;5xx 会向客户表示网站故障 - 端点只通过 HTTPS 提供,设置
no-store,且绝不被 CDN 缓存 - 配置速率限制和变更日志
- 验证
date和dateModified为真实存在的 ISO-8601 日期 date只在创建时设置;更新时写入dateModifiedcanonicalUrl和ogImageUrl仅接受绝对http(s)URL- 在 meta 标签和 sitemap 中执行
noindex;缺失表示允许索引 - 在写入内部使文章缓存、索引页和
sitemap.xml失效 - 文章页面由服务端渲染,并包含 canonical link 和
ArticleJSON-LD robots.txt允许您希望获得引用的 AI 爬虫- §12 中十项 curl 检查全部在生产环境通过
- token 存储在部署环境中,并已编写轮换流程