> ## Documentation Index
> Fetch the complete documentation index at: https://docs.querying.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# AI Rendering：AI 代理能读懂的页面副本

> querying.ai 按人看到的样子渲染你发布的每个页面，并把图片里的文字写进 alt 文本，生成副本。你的 CDN 保存这些副本并发给 ChatGPT 等 AI 代理，人看到的仍是你的网站。

AI 代理读取服务器发来的 HTML。它们不运行脚本，所以脚本绘制的价格、评价和规格到不了它们那里，图片里的文字也只能从 alt 文本读到。
AI Rendering 按人看到的样子渲染你发布的每个页面，去掉脚本，并把每张图片里的文字写进它的 alt 文本。你的 CDN 下载并保存这些副本，
用它们回应 AI 代理。人、搜索引擎和其他所有请求照常到达你的网站，querying.ai 从不在访问你网站的请求路径上。

[GEO Score](/zh/research/geo-score.md) 衡量这个差距：`rendering.score` 是副本包含的页面比例。CDN 发出副本后，收到副本的代理就能读到这么多。

## 工作方式

1. **连接网站。** 网站密钥只显示一次。它只能读取你网站的副本。
2. **发布页面。** 发送页面 URL，querying.ai 从网站所在国家渲染页面并保存副本，之后每 `refreshDays` 天（默认 28 天）重新渲染。
3. **同步副本。** 你的 CDN 每 10 分钟用网站密钥读取副本列表，下载新副本，删除列表里已没有的副本。
4. **回应 AI 代理。** AI 代理发来 GET 时，CDN 用保存的副本回应。没有副本的页面、过期的副本和任何错误都交给源站。

## 连接网站

```bash theme={"theme":"css-variables"}
curl -X POST https://api.querying.ai/v1/ai-rendering/sites \
  -H "Authorization: Bearer $QUERYING_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "host": "www.example.com" }'
```

```json theme={"theme":"css-variables"}
{
  "success": true,
  "site": {
    "id": "5b0e9f52-6a3c-4f0e-8d0b-2c7e4a1f9d33",
    "host": "www.example.com",
    "refreshDays": 28,
    "country": "US",
    "keyPrefix": "rs_8f2c4a1b",
    "pages": 0,
    "createdAt": "2026-10-23T01:00:00.000Z",
    "updatedAt": "2026-10-23T01:00:00.000Z"
  },
  "key": "rs_8f2c4a1b…"
}
```

请保存 `key`：只有这个响应里有它，CDN 会发送它。`POST /v1/ai-rendering/sites/{id}/key` 会给出新密钥，并立即停用旧密钥。

| 字段 | 必填 | 说明 |
| - | - | - |
| `host` | 是 | CDN 服务的公开主机名，例如 `www.example.com`。每个主机连接一次，每个账户最多 20 个。 |
| `refreshDays` | 否 | 已发布页面重新渲染的间隔（天），`1`–`28`，默认 `28`。副本文件 30 天后过期。 |
| `country` | 否 | 渲染页面的国家：`US`（默认）或 `KR`，与 GEO Score 的读者国家相同。请选择客户访问网站所在的国家。 |

之后可用 `PATCH /v1/ai-rendering/sites/{id}` 修改。

## 发布页面

```bash theme={"theme":"css-variables"}
curl -X POST https://api.querying.ai/v1/ai-rendering/sites/5b0e9f52-6a3c-4f0e-8d0b-2c7e4a1f9d33/pages \
  -H "Authorization: Bearer $QUERYING_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://www.example.com/products/sunscreen" }'
```

页面正在渲染：

```json theme={"theme":"css-variables"}
{
  "success": true,
  "page": {
    "id": "a3d1c9e0-7b52-4e1f-9c84-0f6b2d5e7a10",
    "url": "https://www.example.com/products/sunscreen",
    "path": "/products/sunscreen",
    "status": "rendering",
    "copy": null,
    "taskId": null,
    "renderedAt": null,
    "expiresAt": null,
    "publishedAt": "2026-10-23T01:00:00.000Z",
    "refreshAt": "2026-10-23T07:00:00.000Z",
    "refreshTaskId": "7c4e2a90-1d3b-4f6a-8e25-9b0c1f3d4e57"
  },
  "taskId": "7c4e2a90-1d3b-4f6a-8e25-9b0c1f3d4e57"
}
```

渲染大约需要 30 秒。用 `GET /v1/async/task/{taskId}` 跟踪，或从 `GET /v1/ai-rendering/sites/{id}` 读取页面的 `status`：

* `ready`：副本在副本列表里。`copy` 是它的 HTML 文件。
* `rendering`：正在生成第一个副本。
* `failed`：第一次渲染失败。页面会在 `refreshAt` 重新渲染。
* `expired`：副本已过期，在下次渲染前不在列表里。

每个网站最多 1,000 个页面。再次发送已发布的页面会立即重新渲染。页面按路径和查询区分，去掉 `utm_*`、`gclid` 等追踪参数。
`DELETE /v1/ai-rendering/sites/{id}/pages/{pageId}` 下线页面。

要不重新渲染就发布 [GEO Score](/zh/research/geo-score.md) 生成的副本，请发送它的 task id：`{ "taskId": "0b6c1f7e-…" }`。它必须是你账户里
已完成的 GEO Score，并且在网站所在国家测量。

## 设置 CDN

把下面的代码保存为 CDN 代码旁边的 `ai-rendering.mjs`。`syncCopies` 让存储与副本列表一致，`copyFor` 用存储里的副本回应 AI 代理的 GET：

```js theme={"theme":"css-variables"}
export const COPIES_URL = "https://api.querying.ai/v1/ai-rendering/copies";

export const AGENTS = [
	"chatgpt-user",
	"codex",
	"claude-user",
	"claude-code",
	"perplexity-user",
	"gemini-deep-research",
	"duckassistbot",
	"amzn-user",
	"mistralai-user",
	"oai-searchbot",
];

export const DOWNLOADS_PER_SYNC = 40;

const EXPIRY_MARGIN_MS = 10 * 60_000;

const TRACKING_PARAM =
	/^(?:utm_[a-z0-9_]+|gclid|gbraid|wbraid|dclid|fbclid|msclkid|twclid|ttclid|srsltid|mc_cid|mc_eid|_ga|_gl)$/i;

export function pagePath(url) {
	const kept = [...url.searchParams].filter(([name]) => !TRACKING_PARAM.test(name));
	return (url.pathname || "/") + (kept.length ? "?" + new URLSearchParams(kept).toString() : "");
}

export async function copyKey(host, path) {
	const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(host.toLowerCase() + path));
	return "copy:" + [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

export function agentRequest(request, agents = AGENTS) {
	if (request.method !== "GET") return false;
	const userAgent = (request.headers.get("user-agent") ?? "").toLowerCase();
	return agents.some((token) => userAgent.includes(token));
}

export async function syncCopies(options) {
	const {
		siteKey,
		store,
		copiesUrl = COPIES_URL,
		downloads = DOWNLOADS_PER_SYNC,
		fetch: send = fetch,
		now = Date.now,
	} = options;
	if (!siteKey) throw new Error("AI Rendering: no site key");
	const answer = await send(copiesUrl, { headers: { "X-Querying-Site-Key": siteKey } });
	if (!answer.ok) throw new Error(`AI Rendering: the copy list answered ${answer.status}`);
	const { host, copies } = await answer.json();
	const stored = await store.list();
	const listed = new Set();
	const result = { listed: copies.length, downloaded: 0, failed: 0, waiting: 0, removed: 0 };
	for (const copy of copies) {
		const key = await copyKey(host, copy.path);
		listed.add(key);
		if (stored.get(key) === copy.renderedAt || Date.parse(copy.expiresAt) - now() < EXPIRY_MARGIN_MS) continue;
		if (result.downloaded + result.failed >= downloads) {
			result.waiting++;
			continue;
		}
		try {
			const file = await send(copy.copy);
			if (!file.ok) throw new Error(String(file.status));
			await store.put(key, await file.arrayBuffer(), { renderedAt: copy.renderedAt, expiresAt: copy.expiresAt });
			result.downloaded++;
		} catch {
			result.failed++;
		}
	}
	for (const key of stored.keys())
		if (!listed.has(key)) {
			await store.delete(key);
			result.removed++;
		}
	return result;
}

export async function copyFor(request, store, agents = AGENTS) {
	if (!agentRequest(request, agents)) return null;
	try {
		const url = new URL(request.url);
		const copy = await store.get(await copyKey(url.hostname, pagePath(url)));
		if (!copy || !(Date.parse(copy.expiresAt) > Date.now())) return null;
		return new Response(copy.body, {
			status: 200,
			headers: {
				"Content-Type": "text/html; charset=utf-8",
				"Cache-Control": "private, no-store",
				Vary: "User-Agent",
				"X-Querying-Rendering": "HIT",
				"X-Querying-Rendered-At": copy.renderedAt,
			},
		});
	} catch {
		return null;
	}
}
```

**Cloudflare Worker**

Worker 把副本放在 Workers KV 中，并通过 cron 每 10 分钟同步一次。每个副本在 KV 中与副本文件同时过期。

```bash theme={"theme":"css-variables"}
npx wrangler kv namespace create AI_RENDERING
npx wrangler secret put QUERYING_SITE_KEY
```

`wrangler.toml`：

```toml theme={"theme":"css-variables"}
name = "ai-rendering"
main = "cloudflare-worker.mjs"
compatibility_date = "2026-10-01"
routes = [{ pattern = "www.example.com/*", zone_name = "example.com" }]
kv_namespaces = [{ binding = "AI_RENDERING", id = "<namespace id>" }]

[triggers]
crons = ["*/10 * * * *"]
```

`cloudflare-worker.mjs`：

```js theme={"theme":"css-variables"}
import { copyFor, syncCopies } from "./ai-rendering.mjs";

export function kvStore(kv) {
	return {
		async get(key) {
			const { value, metadata } = await kv.getWithMetadata(key, "arrayBuffer");
			return value && metadata ? { body: value, ...metadata } : null;
		},
		async list() {
			const stored = new Map();
			let cursor;
			do {
				const page = await kv.list({ prefix: "copy:", cursor });
				for (const key of page.keys) stored.set(key.name, key.metadata?.renderedAt);
				cursor = page.list_complete ? undefined : page.cursor;
			} while (cursor);
			return stored;
		},
		put: (key, body, meta) =>
			kv.put(key, body, { expiration: Math.floor(Date.parse(meta.expiresAt) / 1000), metadata: meta }),
		delete: (key) => kv.delete(key),
	};
}

export default {
	async fetch(request, env) {
		return (await copyFor(request, kvStore(env.AI_RENDERING))) ?? fetch(request);
	},
	async scheduled(_controller, env, ctx) {
		ctx.waitUntil(syncCopies({ siteKey: env.QUERYING_SITE_KEY, store: kvStore(env.AI_RENDERING) }));
	},
};
```

Workers 免费计划的 KV 每天只能写入 1,000 次，大型网站的首次同步可能超过一天。

**其他 CDN 和服务器**

把同一个模块接到你平台上的存储：一个带有 `get(key)`、`list()`、`put(key, body, meta)` 和 `delete(key)` 的对象。`list()`
返回每个已存的键及其保存时的 `renderedAt`。定期运行 `syncCopies`，每个页面请求运行 `copyFor`。如果你的 CDN 只按 URL 缓存，
请把代理加入缓存键，确保人永远拿不到副本。

下一次同步完成后，像 ChatGPT 打开页面那样检查已发布的页面。副本带有 `X-Querying-Rendering: HIT`：

```bash theme={"theme":"css-variables"}
curl -sI -A "Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com/bot)" https://www.example.com/products/sunscreen
```

## 副本列表

CDN 在 `X-Querying-Site-Key` 中放入网站密钥，读取 `GET https://api.querying.ai/v1/ai-rendering/copies`：

```json theme={"theme":"css-variables"}
{
  "success": true,
  "host": "www.example.com",
  "copies": [
    {
      "path": "/products/sunscreen",
      "url": "https://www.example.com/products/sunscreen",
      "copy": "https://api.querying.ai/v1/evidence/1792717200-9f3c2a7d1e4b.html",
      "renderedAt": "2026-10-23T01:00:31.000Z",
      "expiresAt": "2026-11-22T01:00:00.000Z"
    }
  ]
}
```

列表包含所有副本未过期的已发布页面。`copy` 无需密钥即可下载。页面重新渲染后 `renderedAt` 会改变，所以只在 `renderedAt` 是新的时候
下载。删除列表中已没有的页面的副本。列表和下载都免费。

## 代理

模块中的 `AGENTS` 决定谁能拿到副本，按 User-Agent 中任意位置的标记匹配，不区分大小写。

* **默认：** 人在提问时发往页面的代理（`chatgpt-user`、`codex`、`claude-user`、`claude-code`、`perplexity-user`、
  `gemini-deep-research`、`duckassistbot`、`amzn-user`、`mistralai-user`），以及 ChatGPT 搜索的爬虫 `oai-searchbot`。ChatGPT
  搜索根据这个爬虫保存的页面回答，GEO Score 的 `score` 衡量的就是这个页面。OpenAI 重新抓取页面后，它的索引才会变化。
* **按需添加：** `claude-searchbot` 和 `perplexitybot`。
* **不要加入：** 训练爬虫（GPTBot、ClaudeBot）和网页搜索爬虫（Googlebot、Bingbot）。Googlebot 会自己运行脚本。

## 遵守搜索引擎规则

给机器人发送与人不同的内容就是伪装（cloaking）。Google 只要内容相近，就不把为机器人渲染的页面视为伪装
（[Dynamic rendering](https://developers.google.com/search/docs/crawling-indexing/javascript/dynamic-rendering)）。OpenAI 没有公开伪装政策，
因此让副本符合 Google 的规则：

* 副本就是人的浏览器绘制的页面。querying.ai 去掉脚本，只把图片里的文字加进该图片的 alt 文本，alt 正是放图片文字的地方。
* 副本只在页面自己的 URL 上发出。
* 副本保持最新。querying.ai 每 `refreshDays` 重新渲染一次，模块不会发送过期的副本。
* 如果编辑副本，只做删除或更正：改正识别错的字、去掉弹窗都可以。不要加入人看不到的说法、价格或链接。

## 副本的内容

副本是人的浏览器把页面滚动到底后绘制的 HTML：包含脚本加载的文字和嵌在原位的框架页面，除结构化数据（JSON-LD）外的脚本全部去掉，
每张大图的文字加进它的 alt 文本。链接和图片地址保持绝对地址，副本读起来就像页面本身。副本是最后一次渲染时的页面：价格或库存的变化要到
下一次渲染（最多 `refreshDays` 天后）再加上 CDN 同步的时间才会到达代理。图片文字按识别结果写入，识别错的字也会照样发出。

## 计费

每次渲染 5 积分，每张读取文字的图片 5 积分，你的账户过去 90 天读过的图片（同一文件、同一读取方式）每张 2 积分，最多 12 张。提交时预留
65 积分，完成时按实际用量扣除。失败的渲染不扣费。刷新按同样规则向网站所属账户计费，积分不足的账户 1 小时后重试。连接网站、发布
GEO Score 的副本、副本列表和下载副本都免费。副本由你的 CDN 发出，所以代理流量在 querying.ai 不产生费用。参见
[积分与限制](/zh/concepts/credits-and-limits.md)。

## 错误

| 状态 | 代码 | 原因 |
| - | - | - |
| `401` | `UNAUTHORIZED` | 副本列表请求没有网站密钥，或使用了已更换的密钥，或已删除网站的密钥。 |
| `402` | `INSUFFICIENT_CREDITS` | 余额不足以预留一次渲染的 65 积分。 |
| `403` | `KEY_SCOPE_DENIED` | API 密钥限定了引擎，其中没有 `GEO_SCORE`。 |
| `404` | `NOT_FOUND` | 你的账户里没有该 id 的网站、页面或 GEO Score。 |
| `409` | `SITE_EXISTS`、`SITE_LIMIT`、`PAGE_LIMIT`、`TASK_NOT_COMPLETED` | 主机已连接、账户已有 20 个网站、网站已有 1,000 个页面，或 GEO Score 尚未完成。 |
| `410` | `COPY_EXPIRED` | GEO Score 的副本已过期。请用 URL 发布页面。 |
| `422` | `VALIDATION_ERROR`、`NO_RENDERED_COPY` | 字段无效、URL 属于其他主机、GEO Score 在其他国家测量，或它没有生成副本。 |
| `429`、`503` | | 套餐并发或渲染队列已满。请在 `Retry-After` 之后重试。 |

端点见 [API reference](/zh/api-reference/ai-rendering/create-site.md)。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.