> ## 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](/ko/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](/ko/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`는 페이지 요청마다
실행합니다. URL만으로 캐시하는 CDN이라면 에이전트를 캐시 키에 넣어 사람에게 사본이 가지 않게 하세요.

다음 동기화가 끝난 뒤 게시한 페이지를 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은 스크립트를 직접 실행합니다.

## 검색 엔진 규칙 지키기

봇에게 사람과 다른 내용을 보내면 클로킹입니다. 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 비용이 없습니다. [크레딧과 한도](/ko/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이거나, 사본을 만들지 못한 GEO Score입니다. |
| `429`, `503` | | 플랜 동시 실행이나 렌더 큐가 찼습니다. `Retry-After` 뒤에 다시 보내세요. |

엔드포인트는 [API reference](/ko/api-reference/ai-rendering/create-site.md)에 있습니다.


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