# HtmlDock AI 发布文档

你的任务:把当前对话中的完整 HTML 页面发布到 HtmlDock,并返回最终可访问链接。

## 执行规则(重要)

1. 优先直接调用 API 自动发布,不要先教用户手动操作。
2. 默认使用当前上下文里最后一个完整 HTML 版本;如果有多个版本,取最后一个完整版本。
3. 如果 HTML 中存在本地图片、相对路径资源或依赖文件,必须按下文 images 规则一并处理后再发布。
4. 如果目标不是单段 HTML,而是整个前端工程 / 构建产物 dist/ 目录,请改走「发布多文件站点(ZIP)」章节:把目录打成 ZIP 再上传,不要试图把所有文件塞进一个 html。
5. 默认使用 renewMode="auto",除非用户明确指定其他模式。
6. 发布成功后,把响应里的 url 原样返回给用户,不要只说「已发布成功」。
7. 如果无法调用 HTTP、拿不到完整 HTML、缺少本地文件、或发布失败,必须明确说明原因,不要假装成功。

## ⚠️ 编码警告(必读)

服务端按 UTF-8 解析 JSON。如果你用非 UTF-8 编码发送(典型场景:Windows GBK 终端里用 curl -d 内联含中文的 HTML),
中文会变成乱码,页面会显示异常。

正确做法:
- curl:必须从文件读取 —— curl --data-binary @payload.json;禁止用 -d 内联含中文的 HTML
- Python:读文件时显式 open(path, "r", encoding="utf-8");requests 用 json= 参数会自动按 UTF-8 编码
- Node.js:fs.readFileSync(path, "utf-8")
- 其它语言:确保构造出的 JSON 字符串始终是 UTF-8 字节

只从文件读取、再用 HTTP 库的 JSON 方法发送,通常不会出问题。只有 shell 内联、管道拼接、终端编码不匹配时才会触发。

## 最小可执行示例

HTML 里的图片都是 http(s) 外链、或者根本没有图片时,直接这样发:

接口:
    POST https://api.htmldock.cn/api/v1/agent/pages
    Content-Type: application/json
    Authorization: Bearer {用户的 Token}

先写一个 payload.json(注意 html 里的换行是 JSON 的 \n 转义):

```json
{
  "html": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n  <title>我的页面</title>\n</head>\n<body>\n  <h1>Hello World</h1>\n</body>\n</html>",
  "title": "我的页面",
  "renewMode": "auto"
}
```

然后调用(--data-binary + @文件,不要用 -d 内联):

    curl -X POST https://api.htmldock.cn/api/v1/agent/pages \
      -H "Authorization: Bearer {用户的 Token}" \
      -H "Content-Type: application/json" \
      --data-binary @payload.json

## 请求字段(POST /api/v1/agent/pages)

| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| html | string | 是 | - | 完整 HTML 代码,上限约 220 万字符,超出返回 400 |
| title | string | 否 | 自动从 <title> 提取 | 页面标题,最长 200 字符 |
| renewMode | "auto" 或 "fixed" | 否 | "auto" | 续期模式,只接受这两个取值 |
| expireDays | number | 否 | 7 | 仅 renewMode="fixed" 时使用;只允许 1、7、30、365 |
| images | array | 否 | [] | 本地资源附件,见下文;单次上限 50 张 |

renewMode 说明:
- auto:每天自动扣 1 积分续期,页面一直在线、不设死期;积分不足时续期失败,页面到期下线。推荐。
- fixed:一次性按 expireDays 买断,到期自动下线,之后不再产生每天扣费。

## 成功响应(HTTP 200)

⚠️ 重要:本接口的响应体**就是结果对象本身,没有 code / msg / data 包装**(和部分平台的习惯不同)。
请直接读响应体顶层字段,不要去找 data.url。

```json
{
  "id": "7K3M9Q2XBZ",
  "url": "https://ab12.htmldock.link/",
  "title": "我的页面",
  "status": "ONLINE",
  "expireAt": null,
  "expireDays": null,
  "renewMode": "auto",
  "creditsRemaining": 42.3,
  "imagesUploaded": 0,
  "warnings": []
}
```

| 字段 | 说明 |
|------|------|
| id | 页面号(10 位),后续 PATCH / DELETE 用它 |
| url | **最终访问链接**(用户的绑定域名,没有则平台分配的二级域名)。只有一个链接,直接给用户,不用挑 |
| title | 实际落库的标题(可能是从 <title> 提取的) |
| status | 页面状态,正常为 ONLINE |
| expireAt | 到期时间;renewMode="auto" 时为 null |
| expireDays | 仅 renewMode="fixed" 时有值 |
| creditsRemaining | 本次发布(含附件扣费)之后的可用积分合计 |
| imagesUploaded | 实际上传并替换的附件张数 |
| warnings | 非致命提示。**出现 warnings 不代表发布失败**,但应该转述给用户 |

成功后直接把 url 返回给用户即可。

## 错误处理

错误响应体格式(固定三个字段):

```json
{
  "code": 429,
  "message": "发布过于频繁:同一个 Token 每小时最多 30 次,请稍后再试",
  "timestamp": "2026-09-16T15:20:31"
}
```

code 与 HTTP 状态码一致,message 是可直接向用户转述的中文原因。

| HTTP | 含义 | 常见原因 | AI 应该怎么处理 |
|------|------|----------|------------------|
| 400 | 请求参数错误 | html 为空、renewMode 不是 auto/fixed、expireDays 不在允许列表、images 超限或 fileName 重复、附件 base64 非法 | 按 message 修正请求体后重试,不要原样重发 |
| 401 | 鉴权失败 | 没有 Token、Token 错误或已被重置 | 明确提示用户到 /ai-publish 重新获取 Token;不要用旧 Token 继续重试 |
| 402 | 积分不足 | 余额不够支付本次附件(0.1 积分/张) | 直接告诉用户积分不足并提示去 /credits 补充,不要反复重试 |
| 404 | 路径不存在 | 接口路径拼错,或用了本文档没有的端点 | 对照本文档核对路径与请求方法,不要重试同一个 URL |
| 405 | 请求方法不对 | 用 GET 访问只支持 POST 的接口(例如 GET /api/v1/agent/pages) | 看响应头 Allow 里列出的方法并改对,这是请求写法问题、不是服务端故障,别重试 |
| 410 | 内容已失效 | 极少见:页面元数据还在但底层内容对象已丢失 | 告诉用户该页面内容已失效、需要重新发布,不要重试 |
| 413 | 请求体过大 | HTML 太大或附件太多 | 精简 HTML 或减少附件后重试 |
| 429 | 频率限制 | 同一 Token 每小时超过 30 次 | 告诉用户稍后再试,不要连续刷接口 |
| 500 | 服务端异常 | 平台自身故障 | 说明是服务端异常、建议稍后重试,不要假装成功。注意:参数/方法/路径写错不会返回 500,收到 500 请不要往自己的请求上找原因 |

如果失败,请明确告诉用户:是哪一步失败、失败原因、是否还缺本地资源 / Token / HTML 内容。

## 本地图片 / 相对路径资源处理规则

如果 HTML 里引用了本地文件 —— 即 src、href、poster、data-src、srcset 或 CSS url(...) 中的路径**不以 http:// 或 https:// 或 data: 开头**
(例如 src="./logo.png"、background:url(./bg.png))—— 这些资源必须通过 images 数组一并上传。

你要做的:
1. 扫描 HTML 中所有本地资源引用。
2. 找到这些文件的真实内容。
3. 内容转为 base64。
4. 放进 images 数组。
5. fileName 用文件名即可。**匹配规则是按文件名的最后一段匹配**:
   HTML 里写 "./logo.png"、"/assets/logo.png"、"logo.png" 都只需一条 fileName="logo.png"。

images 数组格式:

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| fileName | string | 是 | 文件名;同一次请求内不可重复 |
| data | string | 是 | 文件内容的 base64 |
| mimeType | string | 否 | MIME 类型,如 image/png;不传则由服务端按扩展名推断 |

完整请求示例(HTML 引用了两个本地图片):

```json
{
  "html": "<!DOCTYPE html>\n<html>\n<body>\n  <img src=\"./logo.png\">\n  <img src=\"./photo.jpg\">\n</body>\n</html>",
  "title": "我的产品页",
  "renewMode": "auto",
  "images": [
    {
      "fileName": "logo.png",
      "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
    },
    {
      "fileName": "photo.jpg",
      "data": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDA..."
    }
  ]
}
```

data 支持两种写法:
- 纯 base64 字符串
- 带前缀的 data URI,如 data:image/png;base64,...

服务端会自动:校验格式与大小、上传到对象存储、把 HTML 里的本地路径替换成直链、扣掉附件对应的积分。

已经是 http:// 、https:// 或 data: 的引用**不会被改动**,也不需要放进 images。

如果 HTML 引用了本地资源但 images 里没提供:接口仍会发布成功,但会在响应的 warnings 里列出这些文件名,
页面上它们会裂图。请补上 images 后重新发布。

## 先读账号状态,再决定怎么发

如果你是 agent,不要只看页面内容 —— 先读用户的套餐和积分状态,判断每天是否还有足够积分继续自动续期。

    GET https://api.htmldock.cn/api/v1/agent/me
    Authorization: Bearer {用户的 Token}

返回字段:

| 字段 | 说明 |
|------|------|
| tier / tierName | 套餐 id(free / pro / premium)与中文名 |
| totalCredits | 可用积分合计 = permanentCredits + planCredits |
| permanentCredits | 永久积分余额(不过期) |
| planCredits / planCreditsCap | 套餐积分余额(扣费时优先消耗)与套餐积分池上限 |
| dailyCredits | 当前套餐每日自动补充的积分 |
| pageCount | 当前在线页面数 |
| autoPageCount | 自动续期页面数(每个每天消耗 1 积分) |
| totalDailyConsumption | 当前每天固定消耗的积分 |
| estimatedDaysLeft | -1 = 收支平衡或净流入,余额不会耗尽;0 = 目前没有固定消耗;正数 = 按当前净支出估算还能撑多少天 |

要连续发布多个页面时,先看 estimatedDaysLeft,快见底就停下来提醒用户充值,而不是一直发到报 402。

## 更新已有页面(不要重复发布)

要改的页面已经存在时,请用 PATCH 更新,不要重复 POST 堆出多条记录。

1. 列出用户的页面:GET /api/v1/pages
2. 读取旧内容:GET /api/v1/raw-html/{id}(返回原始 HTML)
3. 改完后更新:PATCH /api/v1/pages/{id}
   body 允许的字段:{ "title": "...", "html": "...", "validityDays": 7, "autoRenew": true }
4. 删除页面:DELETE /api/v1/pages/{id}(成功返回 204,**删除不退积分**)

这些接口与发布接口共用同一把 Token,见下节。

## 发布多文件站点(ZIP / dist)

单页发布 POST /api/v1/agent/pages 只接受一段 HTML 字符串,装不下带 assets/ 的完整前端工程。
这种场景改用下面这个接口:上传一个 ZIP 压缩包,整站多文件托管。

    接口:
      POST https://api.htmldock.cn/api/v1/agent/sites
      Authorization: Bearer {用户的 Token}
      Content-Type: multipart/form-data

    字段:
      file             必填。ZIP 文件(二进制),典型来源是前端构建产物 dist/
      title            可选。站点标题;不传时用 ZIP 文件名(去掉 .zip)兜底
      tier             可选。档位 S / M / L,不传默认 M
      accessPassword   可选。访问密码(访问门槛,不是加密;别向用户承诺"绝对打不开")
      enableFeedback   可选。true 开启访客留言,默认 false
      customSubdomain  可选。自选二级域名前缀

档位限制(都是解压后的口径,不是压缩包大小):

| tier | 单文件上限 | 解压后总量上限 | 文件数上限 | 积分/天 |
|------|-----------|---------------|-----------|--------|
| S    | 2 MB      | 2 MB          | 20        | 2      |
| M    | 10 MB     | 10 MB         | 100       | 4      |
| L    | 50 MB     | 50 MB         | 500       | 8      |

计费:**部署只扣第 1 天**(S=2 / M=4 / L=8 积分),之后每天 02:00 自动扣同样的积分续 1 天。
不再一次性买断 30 天。余额不足不会立刻下线 —— 已付费的那一天照常在线,
到到期时间才停止访问;停止访问后内容还会保留 7 天,期间充值可在「站点托管」点「恢复」重新上线。

打包规则(这三条最容易踩):

1. 文件类型有白名单,只有这些扩展名能上传:
   html htm css js json png jpg jpeg gif webp svg ico woff woff2 ttf txt md webmanifest
   压缩包里出现任何一个白名单外的文件,整个包都会被拒绝(400)。
   最常见的是 sourcemap(.map):前端构建若开了 sourcemap,打包前必须排除,例如
   cd dist && zip -r ../site.zip . -x "*.map"
2. 入口文件 = 站点里的 index.html(index.htm 同义),**在哪一层都行**(dist/index.html、
   sub/index.html 都能识别;同一包里有多份 index.html 时取路径最浅的那个)。
   只有整个包里都没有 index.html 时,才退回"遇到的第一个 .html"。所以正常的前端构建产物
   直接打包就行,不需要为了入口去调整压缩顺序。
3. 不要在压缩包里再套一层压缩包,嵌套 ZIP 会被拒绝。

入口在子目录是支持的:压缩包里是 dist/index.html 时,站点会以 dist/ 为根,
页面里的 ./assets/app.js 会被正确解析。也可以 cd dist && zip -r ../site.zip . ,
让 index.html 直接落在压缩包根目录(推荐,这样地址更干净)。

成功响应(HTTP 200):

```json
{
  "siteNo": "A2NC0H5QXS",
  "url": "https://xx.htmldock.link",
  "title": "我的站点",
  "status": "ACTIVE",
  "tier": "M",
  "tierLimit": "10 MB / 100 文件",
  "fileCount": 42,
  "totalBytes": 1834921,
  "expireAt": "2026-10-16T15:20:31Z",
  "creditsCharged": 120,
  "creditsRemaining": 380,
  "passwordProtected": false,
  "feedbackEnabled": false
}
```

siteNo 是这个站点的唯一标识(10 位字母数字)。后续要改访问密码、开关留言、绑定域名时,
路径里用的就是它:POST /api/v1/zip-sites/{siteNo}/password 等。请把它存下来,
不要用站点列表里的顺序去猜。

把 url 给用户即可 —— 它就是站点真正能打开的地址。

url 直接给用户访问即可;creditsCharged 是本次扣掉的积分,请如实告诉用户。

错误:

| 状态 | 含义 | 你怎么做 |
|------|------|---------|
| 400 | ZIP 或参数不合法 | 按 message 定位:不支持的文件类型 / 缺少 HTML 入口 / 超过档位限额 / 不允许嵌套 ZIP / 非法路径 / tier 只支持 S M L |
| 401 | 鉴权失败 | 提示用户到 /ai-publish 重新获取 Token,不要用旧 Token 重试 |
| 402 | 积分不足 | 报出 message 里的数字,提示用户充值或换更小的档位 |
| 429 | 频率限制 | 稍后再试,不要连续重试 |

完整 curl 示例:

    cd dist && zip -r ../site.zip . -x "*.map" && cd ..
    curl -X POST "https://api.htmldock.cn/api/v1/agent/sites" \
      -H "Authorization: Bearer hmld_xxxxxxxx" \
      -F "title=我的站点" \
      -F "tier=M" \
      -F "file=@site.zip"

站点发布与单页发布共用同一把 Token、**同一个每小时限流窗口**:两个接口加起来算总次数。

## 同一把 Token 还能调用的接口

| 接口 | 作用 |
|------|------|
| GET /api/v1/agent/me | 账号概览(套餐 / 积分 / 页面数 / 每天消耗 / 还能撑几天) |
| POST /api/v1/agent/sites | 发布多文件站点(multipart 上传 ZIP,典型来源是 dist/) |
| GET /api/v1/agent/token | 取回当前 Agent Token(含完整明文;没有会自动签发一把,幂等) |
| POST /api/v1/agent/token | 重置 Agent Token(旧 Token 立即失效,返回新明文) |
| GET /api/v1/pages | 列出用户名下的所有页面 |
| PATCH /api/v1/pages/{id} | 修改页面(title / html / validityDays / autoRenew) |
| DELETE /api/v1/pages/{id} | 删除页面,成功返回 204 |
| GET /api/v1/raw-html/{id} | 取回页面原始 HTML(无需鉴权),用于「读出来改一版再发」 |

## 计费与限流

- 页面在线:1 积分 / 页 / 天(renewMode="auto" 每天扣,fixed 一次性买断)
- 本地图片附件:0.1 积分 / 张
- 多文件站点(ZIP):按档位 S/M/L 分别为 2 / 4 / 8 积分一天,部署只扣第 1 天,之后每天自动续扣
- 鉴权、参数校验、限流都在扣费之前完成;发布整体失败会回滚,不产生页面也不扣积分
- 频率限制:同一把 Token 每小时最多 30 次发布(单页发布与站点发布共用这个窗口)

## 代码示例

Python(注意 UTF-8):

```python
import requests

with open("page.html", "r", encoding="utf-8") as f:
    html = f.read()

resp = requests.post(
    "https://api.htmldock.cn/api/v1/agent/pages",
    headers={
        "Content-Type": "application/json",
        "Authorization": "Bearer <TOKEN>",
    },
    json={
        "html": html,
        "title": "我的页面",
        "renewMode": "auto",
        "images": images,   # [{"fileName": "logo.png", "data": "..."}]
    },
)
data = resp.json()
if resp.status_code == 200:
    print(data["url"])          # 顶层字段,没有 data 包装
else:
    print(resp.status_code, data.get("message"))
```

Node.js(注意 UTF-8):

```js
import fs from "fs";

const html = fs.readFileSync("page.html", "utf-8");

const resp = await fetch("https://api.htmldock.cn/api/v1/agent/pages", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer <TOKEN>",
  },
  body: JSON.stringify({
    html,
    title: "我的页面",
    renewMode: "auto",
    images,                    // [{ fileName: "logo.png", data: "..." }]
  }),
});
const data = await resp.json();
console.log(resp.ok ? data.url : `${resp.status} ${data.message}`);
```

## 如果你不能发 HTTP 请求

只有在你确实无法调用 HTTP 时,才改为引导用户手动操作:

1. 打开 https://htmldock.cn/editor
2. 粘贴 HTML
3. 若本地资源需要先转直链,打开 https://htmldock.cn/image-host 上传后替换链接
4. 点发布

不要在能自动发布时退回手动说明。