# 网页转 JSON API 怎么接：从 Prompt 到可校验的结构化数据

把网页内容变成 JSON，真正困难的部分通常不是“得到一段看起来像 JSON 的文本”，而是让字段含义、数据类型、缺失值、来源和更新规则都能被程序稳定处理。

[语义化获取站点 JSON 结构内容 API](https://www.gugudata.com/api/details/url2json) 可以根据网页 URL 和自然语言 Prompt 提取自定义结构。它适合商品列表、文章索引、表格内容、站点研究和自动化数据准备等场景，但接口返回成功并不代表所有字段都已经过事实验证。生产接入仍需要本地 Schema 校验、来源证据和版本控制。

## 先选对接口：固定文章结构还是自定义字段

GuGuData 同时提供文章抽取和网页转 JSON 两类能力，它们解决的问题不同。

| 需求                                 | 更适合的接口         | 原因                               |
| ------------------------------------ | -------------------- | ---------------------------------- |
| 提取标题、正文、作者、摘要和发布时间 | `article-extract`    | 返回相对固定的文章结构             |
| 从商品页提取名称、价格、库存状态     | `url2json`           | 字段由 Prompt 按业务需要定义       |
| 从列表页提取多条链接和标签           | `url2json`           | 可以描述列表范围和每项字段         |
| 把正文送入知识库                     | 先 `article-extract` | 正文识别比任意字段抽取更明确       |
| 对文章进一步提取人物、机构或事件     | 两步组合             | 先得到正文，再按下游模型或规则处理 |

不要只因为两个接口都返回 JSON 就把它们当成同一种能力。`article-extract` 的结构主要由接口定义；`url2json` 的结构更多由 Prompt 和目标页面共同决定。

## 请求前先把 Prompt 写成数据契约

“提取这个网页的重要信息”很容易得到可读结果，却不适合直接入库。一个可执行的 Prompt 至少应说明以下内容：

| 契约项   | 应写清楚的内容                   | 示例                           |
| -------- | -------------------------------- | ------------------------------ |
| 提取范围 | 从页面哪个区域、提取多少项       | 只提取产品列表中的前 20 项     |
| 字段名称 | 使用稳定、唯一的键名             | `name`、`price`、`productUrl`  |
| 字段类型 | 字符串、数字、布尔值、数组或对象 | `price` 为字符串，保留货币符号 |
| 缺失值   | 页面没有字段时如何表示           | 使用 `null`，不要猜测          |
| 文本规则 | 是否去除空白、是否保留原文       | 标题去除首尾空白，不翻译       |
| 链接规则 | 相对链接如何处理                 | 返回页面中可见的原始链接       |
| 输出外壳 | 单对象还是数组                   | 返回 `{ "products": [] }`      |

例如，商品列表页可以使用这样的 Prompt：

```text
从页面的产品列表中提取最多 20 项，返回 JSON 对象：
{
  "products": [
    {
      "name": "string",
      "price": "string|null",
      "availability": "string|null",
      "productUrl": "string|null"
    }
  ]
}

要求：
1. 只使用页面明确出现的信息；
2. 缺失字段返回 null，不推断；
3. 保持页面原始语言；
4. 不添加上述结构之外的字段。
```

这里的 JSON 示例是对输出的自然语言约束，不等同于服务端强制执行的 JSON Schema。调用方仍要在收到响应后自行验证。

## 从 Prompt 到可入库 JSON 的数据流

![网页转 JSON 的契约校验数据流](https://assets.devopen.club/uPic/202608/url2json-schema-validation-flow.png?v=b432d4b18581)

这条链路把“抽取成功”和“可以入库”明确分开：URL2JSON API 先根据公开网页与版本化 Prompt 生成候选 JSON，本地 Schema 再检查字段类型、必填项、数量上限和业务规则。通过校验的结果进入版本化数据集，失败结果进入隔离队列，不覆盖旧版本。缺失字段保持 `null`，而不是为了通过校验自动补猜测值。

## 调用中文接口

中文接口为：

```text
POST https://api.gugudata.com/websitetools/url2json
```

参数包括 `appkey`、`url` 和 `prompt`。当前中文文档使用查询参数传递这些值，因此要注意反向代理、APM 和访问日志可能记录完整查询字符串。真实 AppKey 应只由受控服务端持有，并在日志层进行脱敏。

下面使用 Python 标准库发送请求，同时校验 HTTP 状态、JSON 格式、业务状态和 `Data` 类型：

```python
import json
import os
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen


ENDPOINT = "https://api.gugudata.com/websitetools/url2json"


class UrlToJsonError(RuntimeError):
    def __init__(self, message: str, http_status: int | None = None):
        super().__init__(message)
        self.http_status = http_status


def extract_webpage(url: str, prompt: str) -> object:
    query = urlencode(
        {
            "appkey": os.environ["GUGUDATA_APPKEY"],
            "url": url,
            "prompt": prompt,
        }
    )
    request = Request(f"{ENDPOINT}?{query}", method="POST")

    try:
        with urlopen(request, timeout=60) as response:
            http_status = response.status
            payload = json.load(response)
    except HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise UrlToJsonError(detail or str(exc), exc.code) from exc
    except (URLError, TimeoutError, json.JSONDecodeError) as exc:
        raise UrlToJsonError(str(exc)) from exc

    if http_status != 200:
        raise UrlToJsonError("Unexpected HTTP status", http_status)

    data_status = payload.get("DataStatus", {})
    if int(data_status.get("StatusCode", 0)) != 100:
        description = data_status.get("StatusDescription", "Extraction failed")
        raise UrlToJsonError(description)

    data = payload.get("Data")
    if not isinstance(data, (dict, list)):
        raise UrlToJsonError("Data is not a JSON object or array")
    return data
```

调用时只传公开且有权处理的网页：

```python
prompt = """
Extract at most 20 products as
{"products":[{"name":"string","price":"string|null"}]}.
Use null for missing fields and do not infer values.
""".strip()

data = extract_webpage("https://example.com/products", prompt)
```

不要在示例或日志中打印完整请求 URL，因为它包含 AppKey、目标 URL 和 Prompt。生产环境可以记录请求 ID、目标域名哈希、Prompt 版本、HTTP 状态和业务状态，但应避免记录凭证和完整采集内容。

## 英文接口的请求结构不同

英文接口为：

```text
POST https://api.gugudata.io/v1/websitetools/url2json
```

当前 OpenAPI 契约要求 `appkey` 位于查询参数，`url` 和 `prompt` 位于 JSON 请求体：

```bash
curl -X POST "https://api.gugudata.io/v1/websitetools/url2json?appkey=YOUR_APPKEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/products",
    "prompt": "Extract product names and prices. Use null for missing values."
  }'
```

英文接口的响应包装字段使用小驼峰，例如 `dataStatus.statusCode` 和 `data`；中文接口使用 `DataStatus.StatusCode` 和 `Data`。两套接口的成功码也不同，不能共用一条写死字段大小写和成功码的校验逻辑。

## 返回 JSON 后必须再做本地 Schema 校验

假设业务期望的数据结构是：

```json
{
  "products": [
    {
      "name": "Example Product",
      "price": "¥99",
      "availability": null,
      "productUrl": "https://example.com/products/1"
    }
  ]
}
```

最低限度的校验应覆盖：

1. 顶层必须是对象；
2. `products` 必须是数组；
3. 每一项必须是对象；
4. `name` 必须是非空字符串；
5. 可空字段只能是指定类型或 `null`；
6. 数量不能超过 Prompt 约定的上限；
7. URL 字段只能接受业务允许的协议和域名范围；
8. 未声明字段是拒绝、保留还是隔离，必须有明确策略。

不依赖第三方库时，可以先做一层窄校验：

```python
from urllib.parse import urlparse


def validate_products(data: object, max_items: int = 20) -> list[dict]:
    if not isinstance(data, dict):
        raise ValueError("Expected a JSON object")

    products = data.get("products")
    if not isinstance(products, list):
        raise ValueError("products must be an array")
    if len(products) > max_items:
        raise ValueError("products exceeds the configured limit")

    validated = []
    for index, item in enumerate(products):
        if not isinstance(item, dict):
            raise ValueError(f"products[{index}] must be an object")

        name = item.get("name")
        if not isinstance(name, str) or not name.strip():
            raise ValueError(f"products[{index}].name is invalid")

        product_url = item.get("productUrl")
        if product_url is not None:
            if not isinstance(product_url, str):
                raise ValueError(f"products[{index}].productUrl is invalid")
            parsed = urlparse(product_url)
            if parsed.scheme not in {"http", "https"} or not parsed.hostname:
                raise ValueError(f"products[{index}].productUrl is invalid")

        validated.append(item)
    return validated
```

正式项目可以使用 JSON Schema、Pydantic、Zod 或团队现有的契约框架，但不要为了“提高成功率”而把必填字段、类型和数量上限全部放宽。验证失败的数据应进入隔离队列，而不是直接覆盖线上记录。

## 为 Prompt 和结果建立版本

网页结构、Prompt 和模型行为都会变化。只保存最终 JSON，会让后续很难回答“这个字段为什么变了”。建议至少记录：

| 字段               | 用途                                   |
| ------------------ | -------------------------------------- |
| `sourceUrl`        | 原始来源                               |
| `canonicalUrl`     | 规范化后的来源标识                     |
| `promptVersion`    | 生成该结果的 Prompt 版本               |
| `schemaVersion`    | 本地校验规则版本                       |
| `fetchedAt`        | 本次采集时间                           |
| `requestId`        | 对应服务端请求，便于排查               |
| `contentHash`      | 规范化结果哈希，用于变化检测           |
| `validationStatus` | `passed`、`rejected` 或 `needs_review` |
| `rawPayloadRef`    | 受控原始响应引用，而不是公开日志内容   |

更新时不要仅按 URL 执行覆盖。更稳妥的流程是：

1. 获取新结果；
2. 验证响应包装和业务状态；
3. 执行本地 Schema 校验；
4. 规范化字段并计算哈希；
5. 与上一版本比较；
6. 无变化时只更新检查时间；
7. 有变化时创建新版本，并保留差异和来源；
8. 关键字段变化进入人工复核或业务规则判断。

## 列表页需要稳定主键和去重策略

从网页提取多条记录时，标题通常不是稳定主键。商品可能改名，文章标题可能修订，同名记录也可能同时存在。

主键优先级可以设计为：

1. 页面明确提供的业务 ID；
2. 规范化后的详情页 URL；
3. 多个稳定字段组成的复合键；
4. 最后才考虑标题等易变文本。

去重还要区分三种情况：

| 情况                   | 建议处理                          |
| ---------------------- | --------------------------------- |
| 主键相同、内容哈希相同 | 记录本次检查，不创建内容版本      |
| 主键相同、内容哈希不同 | 创建新版本并记录字段差异          |
| 没有稳定主键           | 标记 `needs_review`，不要自动合并 |

## 错误处理要分清 HTTP 与业务状态

中文接口在 JSON 包装中使用 `DataStatus.StatusCode`。当前本地事实文档列出的状态包括参数错误、频率限制、账号或 AppKey 问题、配额限制和提取失败。英文接口则通过 HTTP `400`、`401`、`403`、`429`、`500`、`503` 表达请求级状态，并在成功响应中提供 `dataStatus`。

处理策略可以分为三类：

| 类型         | 示例                                       | 是否重试                 |
| ------------ | ------------------------------------------ | ------------------------ |
| 请求不可恢复 | 参数缺失、URL 格式错误、Schema 不匹配      | 修正输入，不自动重试     |
| 权限或配额   | AppKey 错误、无权限、配额耗尽              | 停止并核查账号状态       |
| 暂时性失败   | 频率限制、上游不可用、明确的服务端暂时错误 | 指数退避并设置总次数上限 |

网页提取失败不一定是 AI 服务故障，也可能是目标网页无法访问、只返回脚本壳、加载超时、页面要求登录，或者 Prompt 与页面内容不匹配。重试前应先分类原因，避免对同一不可恢复目标持续请求。

## 安全边界不能只依赖接口

调用方仍需负责自己的采集合规与数据安全：

- 只处理公开且有权使用的页面；
- 不绕过登录、验证码、付费墙或目标站点访问控制；
- 不把 Cookie、Authorization、浏览器存储或客户会话交给提取链路；
- 对目标域名、允许协议、重定向和结果中的 URL 再做业务侧校验；
- 对 Prompt 做长度和模板限制，避免把不受控用户文本直接变成系统级指令；
- 对结果中的 HTML、链接和富文本执行输出编码或净化；
- 对个人信息、联系方式和敏感字段设置最小化采集与保留策略。

Prompt 注入也需要单独考虑。目标网页中的文字可能包含“忽略之前要求”等内容。调用方不应把网页输出直接当成下一阶段 Agent 的可信指令；提取结果只能作为数据，进入工具调用或自动决策前必须通过固定 Schema、权限和业务规则。

## Demo 能证明什么，不能证明什么

2026 年 8 月 25 日（Asia/Shanghai）核验时：

- 中文 Demo 返回 HTTP 200、`DataStatus.StatusCode=100`，并按固定 Prompt 返回 `Data.products`；
- 英文 Demo 返回 HTTP 200、`dataStatus.statusCode=200`、`status=SUCCESS`，响应头明确标记为缓存 Demo；
- 两个 Demo 都返回了非空结构化数据。

这些证据可以证明当前固定样例链路、包装字段和结果形状可读，但不能证明：

- 任意第三方网页都能访问或成功渲染；
- 任意 Prompt 都严格遵守字段与类型要求；
- 生产 AppKey、配额、并发和长时间稳定性已经验收；
- 提取字段与来源页面在事实层面完全一致；
- 缓存 Demo 代表实时抓取结果。

正式上线前，应使用自有 AppKey、已授权测试网页和固定 Prompt 完成小范围冒烟，并人工对照来源页面核验关键字段。

## 上线前检查清单

- [ ] 明确选择 `url2json`，而不是更适合固定文章结构的 `article-extract`
- [ ] Prompt 写明范围、字段、类型、缺失值、数量上限和输出外壳
- [ ] AppKey 只保存在服务端，不进入代码、前端或普通访问日志
- [ ] 目标 URL 属于公开且有权处理的网页
- [ ] 同时检查 HTTP 状态和对应域名的业务状态字段
- [ ] 对 `Data` 执行本地 Schema 和数量限制校验
- [ ] Schema 失败结果进入隔离，不覆盖旧数据
- [ ] 保存 Prompt 版本、Schema 版本、请求 ID、来源和采集时间
- [ ] 使用稳定主键和内容哈希区分重复与更新
- [ ] 对结果中的 URL、HTML 和敏感字段再次执行安全策略
- [ ] 为 429 或暂时性错误设置有上限的退避，不重试参数和权限错误
- [ ] 用真实 AppKey 和自有测试页完成生产冒烟，不把 Demo 当成生产验收

网页转 JSON 的价值，是让同一套采集链路适配不同页面和字段需求；它的工程边界，是输出结构仍然需要调用方验证。把 Prompt 当成可版本化的数据契约，把 Schema 校验、来源证据和失败隔离放在入库之前，才能让“能提取”变成“可长期维护”。
