GeoAns Guide

附录:站点插件对接契约(API)

契约 v2 的接口约定与请求示例——面向在 WordPress 插件或自研 CMS 上实现对接的站点开发者。

本篇是《接入站点》的技术附录,面向站点侧开发人员:在 WordPress 插件或任意 CMS 适配层上实现 GeoAns 发布契约的工程师。使用官方站点插件的用户无需阅读本篇,装好插件、生成令牌、点「测试连接」即可。

一句话:GeoAns 平台是 HTTP 客户端,你的网站是服务端。实现下列 9 个 JSON 端点后,平台即可向你的站点发布/更新/删除文章、上传图片、加载栏目,并把存量内容同步进知识库。

端点 → 平台功能映射(缺哪个端点,对应功能不可用):

端点 支撑的平台功能
GET /health 「测试连接」/ 契约校验(全部功能的前置闸门)
GET /categories 站点栏目加载、发布通道绑定栏目
POST /media 素材分发(配图自动上传到你的站点)、内容管理手动传图
POST /channels/{key}/articles 文章发布(计划任务 / 手动生成)
GET /channels/{key}/articles 内容管理列表、发布对账、站点标题抓取(关键词来源之一)
GET /channels/{key}/articles/{id} 发布后取文章 URL(收录检测依赖)
PATCH /channels/{key}/articles/{id} 内容刷新(原址更新)、内容管理编辑
DELETE /channels/{key}/articles/{id} 内容管理删除
GET /channels/{key}/articles/export 存量内容同步进平台知识库(KB 同步)

通信总则

  • 请求/响应体均为 UTF-8 JSON(/media 为 multipart)。没有 {code,message,data} 包裹:HTTP 状态码即结果,2xx 的响应体就是资源本身;
  • 时间戳字段为 Unix epoch 秒(如 created_at);published_at 等为 RFC 3339 字符串;
  • 未知字段宽容:请求体中不认识的字段一律忽略,平台新增字段不构成破坏性变更;
  • 路由必须容忍尾斜杠(/articles/ 与 /articles 等价);
  • 单请求超时 15 秒(连接 5 秒);平台读取响应体上限 4MB,列表/导出每页最多取 50 条;
  • 平台出网仅允许公网地址(拒内网/回环 IP),站点必须公网可达;平台可能经 SOCKS5 中转访问,对你透明。

入口与部署形态

入口公式(唯一拼装规则):ENTRY = 去掉末尾斜杠(base) + "/api/v2",base 即站点管理中填的「请求地址」。三种部署形态等价可用:

形态 base 填法 实际请求
rewrite https://www.example.com https://www.example.com/api/v2/health
PATH_INFO https://www.example.com/api/index.php https://www.example.com/api/index.php/api/v2/health
query 路由 同上 https://www.example.com/api/index.php?route=/api/v2/health
  • 入口自动发现:只填域名根也可以。平台按 rewrite → PATH_INFO → query 顺序逐一握手,命中的入口自动回写站点记录——三种填法等价,填错会被自动纠正;
  • base 不得包含 query、fragment 或用户信息;
  • 选型建议:能开 rewrite(.htaccess / Nginx 规则 / 框架路由)就用第一种;服务器锁死、零配置用 query 形态。

认证

凭据只经一个通道:

Authorization: Bearer {site_token}
  • site_token 为两端各存一份的共享密钥,要求 ≥32 字符高熵随机串(平台界面提供「生成」按钮),所有端点(含 health)都带此头;
  • 校验失败 → 401;Authorization 之外的凭据通道(URL query、自定义头)一律忽略;
  • 轮换:换 token 后下一请求即生效,无需重启插件(请从配置/缓存热读取,不要启动时固化)。两端分别修改会有短暂不一致窗口(401 属永久失败、不自动重试)——低峰期操作,改完立即点「测试连接」;插件支持新旧 token 并存过渡则无窗口;
  • health 的 auth 字段固定回报 "bearer"(签名档位为规范预留,平台当前未启用——站点若强制验签,全部调用将 401)。

端点总览

# 方法 路径 Idempotency-Key 语义
1 GET /api/v2/health — 健康检查 + 版本/能力协商
2 GET /api/v2/categories — 站点栏目树
3 POST /api/v2/media 必须 图片上传(multipart)
4 POST /api/v2/channels/{key}/articles 必须 发布文章
5 GET /api/v2/channels/{key}/articles — 文章列表
6 GET /api/v2/channels/{key}/articles/{id} — 文章详情
7 PATCH /api/v2/channels/{key}/articles/{id} — 部分更新(Merge Patch)
8 DELETE /api/v2/channels/{key}/articles/{id} — 删除文章
9 GET /api/v2/channels/{key}/articles/export — 增量导出(KB 同步)

带 Idempotency-Key 的两个端点缺失该头必须拒绝:400 + problem type missing-idempotency-key。{key} 为通道标识(如 news),正则 ^[a-z][a-z0-9_]{1,31}$。

各端点约定与示例

1. GET /health —— 契约握手

curl -H "Authorization: Bearer $TOKEN" "$ENTRY/health"
{
  "contract_version": "2",
  "plugin": "mp-connector/1.0.0",
  "system": "WordPress 6.5 / PHP 8.2",
  "tls": true,
  "auth": "bearer",
  "entry": "rewrite",
  "token_set": true,
  "time": "2026-09-22T12:00:00+08:00"
}

握手判定 = contract_version 为 "2" 且 token_set 为 true。通过后站点进入 ok 状态、放行全部业务调用;未通过时平台拒绝发布并展示失败原因。tls 按请求实际到达协议如实回报;plugin/system/entry 为诊断信息自由填写。

2. GET /categories —— 栏目树

{
  "items": [
    { "id": 1, "title": "资讯", "children": [
      { "id": 12, "title": "行业动态" },
      { "id": 13, "title": "公司新闻" }
    ]},
    { "id": 2, "title": "产品" }
  ]
}

平台读取 items[] 的 id(发布时作为 category_id 回传)、title、children[]。扁平实现可用 pid 表父级代替嵌套,叶子节点必须有 id 与 title。

3. POST /media —— 图片上传

curl -X POST "$ENTRY/media" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: geo-mat-88-site-3" \
  -F "file=@./cover.jpg"

multipart 字段名固定 file;必须带 Idempotency-Key(重发同键必须返回原媒体 URL,不得生成第二个文件);建议接受 JPEG/PNG/WebP,单文件 ≤10MB,超限回 413。

{ "id": "med_3fa2", "url": "https://www.example.com/uploads/2026/09/cover.jpg", "width": 1280, "height": 720 }

平台读取 url(必填,缺失按上传失败处理)。

4. POST /channels/{key}/articles —— 发布文章

POST /api/v2/channels/news/articles HTTP/1.1
Authorization: Bearer mp_tk_9f2c…
Idempotency-Key: 0b8df2e0-5f7a-4c6a-9b1e-3a2c1d4e5f60
Content-Type: application/json

{
  "title": "如何在 AI 搜索里让客户找到你",
  "content": "<h2>…</h2><p>…</p>",
  "summary": "一文讲清 GEO 优化基础。",
  "category_id": 12,
  "cover_url": "https://www.example.com/uploads/2026/09/cover.jpg",
  "tags": ["GEO", "AI 搜索"],
  "seo": { "title": "GEO 入门", "description": "…", "keywords": ["geo", "ai 搜索"] },
  "status": "published",
  "published_at": "2026-09-22T12:00:00+08:00",
  "language": "zh-CN",
  "external_id": "0b8df2e0-5f7a-4c6a-9b1e-3a2c1d4e5f60"
}
字段 说明
title / content 标题;正文为 HTML 片段
summary 列表摘要(可与 seo.description 同值)
category_id 栏目 ID;校验失败回 422
cover_url 封面图 URL(通常是 /media 上传所得)
tags 标签名数组——有标签库的站点按名字 find-or-create 后关联,纯文本站点直接存字符串,不得要求平台提供标签 ID
seo {title, description, keywords[]},keywords 为数组
status "published" | "draft"(平台恒发 published,draft 需可接收)
published_at RFC 3339 显式发布时间
language 可选,BCP 47 语言码(计划级配置)——多语言站点据此归入对应语言版本;同一 external_id 重发恒命中同一篇文章,站点不得按 language 分叉幂等语义
external_id 平台任务标识(发布时与 Idempotency-Key 同值)——必须原样落库

成功响应(幂等重放另加 Idempotency-Replayed: true 头):

HTTP/1.1 201 Created

{ "id": 876, "url": "https://www.example.com/news/876.html", "status": "published", "external_id": "0b8df2e0-…" }

平台读取 id(数字 >0,缺失按发布失败处理)与 url(收录检测依赖,必须返回)。

安全红线:cover_url 及其他 URL 字段请直接存字符串,严禁在插件内服务端抓取——图片已由平台经 /media 上传,服务端抓取只会在你的站点引入 SSRF 攻击面。

5. GET /channels/{key}/articles —— 列表

curl -H "Authorization: Bearer $TOKEN" \
  "$ENTRY/channels/news/articles?page=1&page_size=10&category_id=12&external_id=0b8df2e0-…"

支持 page/page_size(≤50)、category_id、keyword(标题模糊)、status(published|draft)、external_id(精确过滤)。

{
  "items": [
    { "id": 876, "title": "…", "url": "https://…/876.html", "category_id": 12,
      "status": "published", "external_id": "0b8df2e0-…",
      "created_at": 1725260400, "updated_at": 1725260400 }
  ],
  "page": 1, "page_size": 10, "total": 42
}

external_id 过滤是硬要求:真的按它过滤;命中行的 external_id 逐字回显,平台校验一致才采纳。这是平台崩溃恢复/重放确认的确定性反查通道。

6. GET /channels/{key}/articles/{id} —— 详情

{ "id": 876, "title": "…", "url": "https://…/876.html", "status": "published", "content": "…", "external_id": "0b8df2e0-…", "updated_at": 1725260400 }

平台读取 url(发布后回取文章地址)。不存在 → 404。

7. PATCH /channels/{key}/articles/{id} —— 部分更新

body 为 JSON Merge Patch(RFC 7386):只更新显式出现的键。平台白名单字段:

{ "title": "新标题", "content": "<p>新正文</p>", "summary": "…", "seo": { "description": "…" } }

红线:更新不得改变文章 URL/slug 与 status。响应 200 或 204 均可,平台不读响应体。Merge Patch 天然幂等,无需 Idempotency-Key。内容刷新走 PATCH 原址更新(URL 不变),这条红线同时保护你的 SEO 权重与平台的收录监测。

8. DELETE /channels/{key}/articles/{id} —— 删除

成功 → 204(空 body);不存在 → 404。

9. GET /channels/{key}/articles/export —— 增量导出(KB 同步)

curl -H "Authorization: Bearer $TOKEN" \
  "$ENTRY/channels/news/articles/export?since=1725260400&page=1&page_size=50"
  • since 为 epoch 秒游标,必须实现过滤且用 >=(严格 > 在同秒多行跨页时会永久漏行;边界重复回传由平台按 URL + 内容哈希去重吸收);
  • 必须按 (updated_at ASC, id ASC) 排序分页返回——降序会让多页同步在第一页后中断;
  • 单页响应建议 ≤3.5MB(平台读取上限 4MB);返回少于 page_size 完全合法,翻页照常。
{
  "items": [
    { "id": 876, "title": "…", "content": "<p>全文 HTML</p>",
      "url": "https://…/876.html", "cover_url": "https://…/cover.jpg",
      "updated_at": 1725260500, "type": "article" }
  ]
}

行字段:title/url 必需(url 是平台去重键),updated_at 必需(游标推进),content 建议进知识库语料,type: "product" 标记商品类条目。

幂等与错误规范

幂等规则(create 与 media):

  1. 缺 Idempotency-Key → 400 + problem type missing-idempotency-key;
  2. 键命中 → 不重复创建,返回 201 + 原资源 + Idempotency-Replayed: true;
  3. 键未命中 → 落库,并在与业务数据同一事务内写入 key → 资源 映射;
  4. 同键不同请求体 → 409 + idempotency-conflict;
  5. 映射保留 ≥7 天。发布场景 external_id 与 Idempotency-Key 同值——给 external_id 建唯一索引即可用「INSERT 撞索引 → 返回已有行」实现文章幂等,映射表只有 /media 真正必需。

错误表示:所有非 2xx 响应 Content-Type: application/problem+json:

{
  "type": "channel-disabled",
  "title": "通道已停用",
  "status": 409,
  "detail": "通道 product 已停用,无法接收发布"
}

type/title/detail 会原样进入平台任务错误信息展示给用户,请写可读的中文。状态码语义:201 创建(幂等重放也是 201)、204 删除、400 结构错误、401 认证失败、404 资源不存在、409 冲突(停用/幂等冲突)、413 超限、422 业务校验失败、429 限流(建议带 Retry-After)、5xx 瞬态。

平台重试分类:网络错误/429/5xx → 重试并复用同一键;400/401/403/404/409/413/422 → 不重试,任务置失败并展示 problem 详情;响应为非 JSON(HTML 错误页/WAF 拦截页)→ 按瞬态重试。

对接自测(curl 全套)

TOKEN="把生成的32位以上随机串放这里"
ENTRY="https://www.example.com/api/v2"

# 1) 契约握手——contract_version 必须是 "2"、token_set 必须是 true
curl -sS -H "Authorization: Bearer $TOKEN" "$ENTRY/health"

# 2) 栏目树——items[].id/title 必须有值
curl -sS -H "Authorization: Bearer $TOKEN" "$ENTRY/categories"

# 3) 图片上传(重发同键应得同一 url)
curl -sS -X POST "$ENTRY/media" \
  -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: selftest-media-001" \
  -F "file=@./test-cover.jpg"

# 4) 发布文章(重发整条命令:应返回同一 id + Idempotency-Replayed: true)
curl -sS -X POST "$ENTRY/channels/news/articles" \
  -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: selftest-art-001" \
  -H "Content-Type: application/json" \
  -d '{"title":"对接自测","content":"<p>hello geoans</p>","summary":"自测",
       "category_id":12,"status":"published","external_id":"selftest-art-001"}'

# 5) 列表 + external_id 精确过滤(命中行必须逐字回显)
curl -sS -H "Authorization: Bearer $TOKEN" \
  "$ENTRY/channels/news/articles?page=1&page_size=10&external_id=selftest-art-001"

# 6) 详情(url 必须有值)
curl -sS -H "Authorization: Bearer $TOKEN" "$ENTRY/channels/news/articles/876"

# 7) 部分更新(merge-patch:只动出现的键)
curl -sS -X PATCH "$ENTRY/channels/news/articles/876" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"对接自测(改)"}'

# 8) 增量导出(since=0 全量;updated_at 升序)
curl -sS -H "Authorization: Bearer $TOKEN" \
  "$ENTRY/channels/news/articles/export?since=0&page=1&page_size=50"

# 9) 删除
curl -sS -X DELETE -H "Authorization: Bearer $TOKEN" "$ENTRY/channels/news/articles/876" -i

# 负路径:缺幂等键必须 400;错 token 必须 401
curl -sS -X POST "$ENTRY/media" -H "Authorization: Bearer $TOKEN" -F "file=@./t.jpg" -i
curl -sS -H "Authorization: Bearer wrong-token" "$ENTRY/health" -i

常见问题排查

症状(平台侧显示) 原因 处置
握手失败:contract_version 不匹配 health 未回报 "2" 或路由未命中契约端点 核对 health 响应;确认入口形态
握手失败:token_set=false 插件未保存 token 插件侧保存非空 token 后如实回报
「站点返回了 HTML 页面而非接口数据」 rewrite 未配置 / WAF 拦截 / 插件未启用 浏览器直接访问 $ENTRY/health 看返回;改用 PATH_INFO 或 query 形态
出站防护拦截 站点地址解析为内网/回环 IP 站点需公网可达(或联系我们配置中转)
全部调用 401 两端 token 不一致 / 站点其他鉴权层先行拦截 重新核对 token;放行 /api/v2 路径
发布 409 channel-disabled 站点侧该通道停用 开启通道,或联系运营调整
KB 同步只有第一页 / 漏文章 export 排序或 since 过滤实现不符 按升序 + >= 修正

实现策略提醒

通道(channel key)是平台侧命名空间,数量无上限;你的实现数应等于内容类型数(页面型内容 1 个即可)——所有 articles 端点对 {key} 透明地写同一内容模型即可,绝不要按通道逐个实现。某个通道确需独立存储时,在插件内部做 key → 存储分派即可,契约零改动。