# 立创 EDA Pro 工程源 (pro.lceda.cn) — 数据源调研

**定位**：立创 EDA **专业版**（EDA Pro，域名 `pro.lceda.cn`）的工程源抓取链。与 EasyEDA **标准版**（`u.lceda.cn` / `oshwhub.com`，见 `oshwhub.md`）并列。
**首版调研**：2026-04-24
**状态**：抓取链路 + 加密 + 解压 + 格式 + 多 document 重放 **全部打通**；对他人公开 Pro 工程已验证。

---

## TL;DR

| 事项 | 结论 |
|---|---|
| 登录态 | **必须**（`pro.lceda.cn` 有独立 session，与 `u.lceda.cn` 不共享） |
| 对他人 public Pro 项目可用性 | **✅** 验证（立创开发板官方账号的"泰山派3M (RK3576)"项目用我方主号成功拉全量源） |
| 核心枚举 API | `/api/v4/projects/<P>/branches/<B>/structures` — 返回整个项目树（boards / schematics / sheets / pcbs / panels / blockSymbols） |
| 核心历史 API | `/api/v4/projects/branches/histories?project=P&branch=B&history=H&limit=N` — 批量返回 history chain（linear, parent 链表） |
| 源文件分发 | `https://modules.lceda.cn/projects/histories/<hash>`（无 cookie 鉴权，但 URL 由 API 响应提供） |
| 缩略图 CDN | `https://image-pro.lceda.cn/projects/thumbs/<hash>.webp`（文档缩略图） |
| 加密 | **AES-128-GCM**（`{key, iv}` 由 history API 响应给出，16-byte auth tag 附在 ciphertext 末尾） |
| 压缩 | 解密后是 **gzip**，gunzip 后得到源流 |
| 数据格式 | **EPRO2** — 自定义消息流（`\n` 分隔消息，`||` 分字段），40+ 种 message types |
| 小项目样例 | `无界PLUS`: 1 document / 1 history / 417 KB blob / 2.7 MB EPRO2 / 8 357 消息 |
| 大项目样例 | `泰山派3M (RK3576)`: 36 documents / **35 histories** / **14 MB 总 blob** / **66 MB 解压** / **226 K 消息** / 端到端 2.6 秒（未限速） |
| Editor version snapshot | `3.2.127` （2026-04；会随 pro 编辑器升级变化，见 `Editor-Version` header） |

---

## 1. 与标准版 (EasyEDA Std) 的区别

| 维度 | EasyEDA Std (`u.lceda.cn`) | EasyEDA Pro (`pro.lceda.cn`) |
|---|---|---|
| 编辑器入口 | `lceda.cn/editor` | `pro.lceda.cn/editor?entry=mgr-project-worker.js` |
| Cookie | `lceda_session`（hostOnly `u.lceda.cn`） | `lceda_pro_session`（hostOnly `pro.lceda.cn`）**独立** |
| 项目 API | `/api/projects/<uuid>`（一次返回全部） | `/api/v4/projects/<uuid>`（返 metadata）+ 多个辅助端点（见 §2.4） |
| 版本控制 | 单版本，无 branch 概念 | **Git 风格**：branches + linear history chain（含 `parent`，无 snapshot 压缩） |
| 源数据加密 | 未完整探（`modules.lceda.cn/histories/<hash>.json` 返 403 AccessDenied，推测同样有签名机制） | **AES-128-GCM**（确认） |
| 源格式 | 早期 EasyEDA JSON（扁平结构） | **EPRO2** 消息流（事件溯源式） |
| 多 document 支持 | 单文档 | **多 document**（一工程 = 1 board + 1 schematic + N 个 sheet + ...） |
| oshwhub 标记 | 项目 `origin: "std"` | 项目 `origin: "pro"` |
| 转 KiCad 成熟度 | `easyeda2kicad.py` 等第三方工具 | **无现成工具**，需自写转换器或用 Pro 编辑器内置"导出 KiCad"功能（其端点待挖） |

---

## 2. 抓取流程

整体逻辑：**枚举结构 → 拿 history chain → 逐条解密 → 合并消息流 → 按 DOCHEAD 切 per-document**。

### 2.1 枚举项目结构 `/structures`

```
GET /api/v4/projects/<PROJ_UUID>/branches/<BRANCH_UUID>/structures
```

返回嵌套结构（`result.structure` 是**字符串形式的 JSON**，需二次 parse）：

```jsonc
{
  "result": {
    "ticket": "82443",
    "structure": "{\"boards\":{...},\"schematics\":{...},\"sheets\":{...},\"pcbs\":{...},...}"
  }
}
```

顶层分组（至少观察到）：

| Key | 含义 | 示例（泰山派） |
|---|---|---|
| `boards` | 板子定义（版本/标题） | 1 个 |
| `schematics` | 父原理图 | 1 个 `原理图` |
| `sheets` | 原理图子表（multi-sheet hierarchy） | 33 个（MIPI_DSI / KEY / POWER / Audio / LPDDR5 / ...） |
| `pcbs` | PCB 文档 | 1 个 |
| `panels` | 拼板 | 0 个 |
| `blockSymbols` | 模块符号 | 0 个 |
| `owner` | 权限相关（非 document，结构不同） | 5 条 |

每个 sheet 通过 `schematic_uuid` 关联到父 `schematics`。所有 document uuid 都是 **16 hex**（64 bit），区别于 project/branch 的 32 hex uuid。

### 2.2 枚举完整 history chain

```
GET /api/v4/projects/branches/histories?project=<P>&branch=<B>&history=<H>&limit=<N>&path=
```

- `project` / `branch` / `history` 必填；`history` 传 branch 当前 HEAD 即可
- `limit` 上限实测 ≫ chain 实际长度，设 50 000 就够（泰山派实际 35，limit 值 500/5000/50000 返回一致）
- `path` 参数**无作用**（各种值返回相同结果）

返回：

```jsonc
{
  "result": [
    { "uuid": "H35", "parent": "H34", "snapshot": null,
      "key": "...",  "iv": "...",  "num": 0, "snapshot_num": 0, "level": "35",
      "dataStrUrl": "https://modules.lceda.cn/projects/histories/H35" },
    { "uuid": "H34", "parent": "H33", ... },
    ...
    { "uuid": "H1",  "parent": null, "snapshot": null, "level": "1", ... }
  ]
}
```

**观察**（泰山派 & 无界PLUS）：
- Chain **严格线性**（每条有唯一 parent，只一个 root）
- `level = "1"` 是 root，单调递增到 HEAD
- `snapshot` 字段**全为 null**（没见过有值的；存在但未使用？）
- 没有"merge commit" 结构

### 2.3 解密 + 解压 + 重放（Python）

```python
from Crypto.Cipher import AES
import gzip, httpx, json
from collections import defaultdict

# 1. 拿到 chain（按 level 升序）
chain = httpx.get(HIST_API, params={...}, cookies=COOKIE).json()["result"]
chain.sort(key=lambda h: int(h["level"]))

# 2. 逐条下载 + 解密 + gunzip，拼接事件流
all_lines = []
for h in chain:
    blob = httpx.get(h["dataStrUrl"]).content
    ct, tag = blob[:-16], blob[-16:]     # WebCrypto 约定：auth tag 附末
    gz = AES.new(
        bytes.fromhex(h["key"]),
        AES.MODE_GCM,
        nonce=bytes.fromhex(h["iv"]),
    ).decrypt_and_verify(ct, tag)
    data = gzip.decompress(gz)
    all_lines.extend(data.split(b"\n"))

# 3. 按 DOCHEAD 切到 per-document 流
docs = defaultdict(list)          # doc_uuid -> [msg_line, ...]
current_doc = None
for ln in all_lines:
    if not ln.strip(): continue
    head = json.loads(ln.split(b"||", 1)[0])
    if head["type"] == "DOCHEAD":
        payload = json.loads(ln.split(b"||")[1])
        current_doc = payload["uuid"]     # 16-hex document uuid
    if current_doc:
        docs[current_doc].append(ln)
```

> **关于 GCM nonce 长度**：Pro 实现用 **16 字节 IV**（标准 GCM 用 12 字节）。`pycryptodome` 的 `MODE_GCM` 支持非标准 nonce 长度，直接用即可。

### 2.4 Pro 编辑器全套 `/api/v4/` 端点清单

从 `https://modules.lceda.cn/pro-mgr/3.2.127.fa33aea3/js/project-worker.js` 反查得到 29 条：

| 端点 | 方法 | 作用 |
|---|---|---|
| `/api/v4/projects/{uuid}` | GET | 项目详情 |
| `/api/v4/projects/{uuid}/delete` | POST? | 删项目 |
| `/api/v4/projects/add` | POST | 建项目 |
| `/api/v4/projects/{uuid}/branches` | GET | 分支列表 |
| `/api/v4/projects/{uuid}/branches/{branch}` | GET | 分支详情 |
| `/api/v4/projects/{uuid}/branches/{branch}/histories` | GET | **单分支 history 列表**（有时 404；用下面的 batch 版） |
| `/api/v4/projects/{uuid}/branches/{branch}/histories/{history}` | GET | 单 history 元信息（含 dataStrUrl/key/iv） |
| `/api/v4/projects/{uuid}/branches/{branch}/structures` | GET | **项目结构树** |
| `/api/v4/projects/{uuid}/branches/{branch}/thumbs` | GET | 文档列表（含缩略图 URL） |
| `/api/v4/projects/{uuid}/branches/{branch}/thumbs/{thumbUuid}` | GET/POST | 单文档缩略图 |
| `/api/v4/projects/branches/histories?...&limit=N` | GET | **批量 history chain**（有效） |
| `/api/v4/projects/{uuid}/draft-history` | GET | 草稿历史列表 |
| `/api/v4/projects/{uuid}/draft-history/{draft}` | GET | 单个草稿 |
| `/api/v4/documents/checkin/{project}/{branch}` | POST | 提交 document 变更 |
| `/api/v4/documents/checkout/{project}/{branch}` | POST | 取出 document |
| `/api/v4/components/{uuid}/delete` | POST | 删组件 |
| `/api/v4/components/{uuid}/histories` | GET | 组件 history |
| `/api/v4/components/{uuid}/histories/{historyUuid}` | GET | 组件单 history |
| `/api/v4/componentLogs` | GET | 组件日志 |
| `/api/v4/devices/{uuid}/delete` | POST | 删设备 |
| `/api/v4/snapshots/apply` | POST | 应用 snapshot |
| `/api/v4/snapshots/create` | POST | 建 snapshot |
| `/api/v4/snapshots/upload-confirm` | POST | snapshot 上传确认 |
| `/api/v4/snapshots/upload-signatures` | POST | snapshot 上传签名 |

**爬取只用到 GET**：`{uuid}` / `{uuid}/branches` / `{uuid}/branches/{branch}` / `{uuid}/branches/{branch}/structures` / `{uuid}/branches/{branch}/thumbs` / `projects/branches/histories?...` / `{uuid}/branches/{branch}/histories/{history}`。

### 2.5 必需 HTTP Headers

```
Cookie:         <含 lceda_pro_session 的 pro.lceda.cn cookie>
Editor-Version: 3.2.127
Referer:        https://pro.lceda.cn/editor
path:           <PROJ_UUID>        # 自定义头，CSRF 类校验
User-Agent:     Mozilla/5.0 (...) Chrome/147.0.0.0 ...
Accept:         application/json
```

缺 `Editor-Version` 或 `path` 会返回 `{"success":false,"code":1111111,"message":"没有权限操作"}`。

### 2.6 实测数据

#### A. 小项目 `无界PLUS`（1 document）

| 步骤 | 数值 |
|---|---|
| history chain 长度 | **1** 条 |
| blob 总下载 | 416 839 B |
| 解密 + gunzip | 2 712 282 B |
| EPRO2 消息数 | **8 358** |
| docType | `BOARD` |

chain 只有 1 条就包含整个 project，因为只有 1 个 document。

#### B. 大项目 `泰山派3M (RK3576)`（36 documents）

| 步骤 | 数值 |
|---|---|
| structures 返回 documents | 1 board + 1 schematic + **33 sheets** + 1 pcb ≈ 36 |
| thumbs 返回（有缩略图的） | 26（部分 sheet 未渲染） |
| history chain 长度 | **35** 条（linear，level 1..35） |
| blob 总下载 | 14.0 MB |
| 解密 + gunzip 总量 | **65.9 MB** |
| EPRO2 消息数 | **225 880** |
| 端到端耗时（未限速） | **2.6 秒** |
| 消息类型 top | LINE 60K · ATTR 53K · ELE_PLACEHOLDER 35K · LAYER 17K · PAD_NET 7.9K · FILL 7.6K · COMPONENT 5.8K · PAD 4.3K · VIA 4.1K · WIRE 3.9K |

**说明**：单 history 的 blob 小到 395 B、大到 4 KB 量级（主要是 LAYER + ATTR 为主的增量事件）；**大体量在 gzip 压缩率上**（14 MB → 66 MB ≈ 4.7× 压缩比）。

---

## 3. EPRO2 源格式（消息流）

### 3.1 总体结构

gunzip 后是 UTF-8 文本流，按 `\n` 分成 N 条消息，每条形如：

```
{"type":"X","ticket":N,"id":"..."}||{payload JSON}||{optional extra fields}
```

- `type`：消息类型（见 §3.2）
- `ticket`：单调递增的事件序号（可视为版本 diff 编号）
- `id`：对象的 stable ID（PART id、NET name、document uuid、等）
- `payload`：JSON 对象，内容随 type 变化
- `||`：字段分隔符；消息内最多见 2-3 段

本格式是**事件溯源 / 增量操作日志**：一个工程的完整状态 = 所有消息按 level 重放的结果。

**重放原则**：严格按 `level` 升序处理 history chain；同一 history 内按 `\n` 出现顺序处理。DOCHEAD 消息标记接下来的消息属于哪个 document（用 payload.uuid 的 16-hex）。

### 3.2 消息类型（≥ 40 种）

> 数字为"泰山派"项目的计数（35 条 history 合并后），给出相对权重参考。

| 类别 | types |
|---|---|
| 文档 & 元 | `EDIT_HEAD` · `DOCHEAD` · `META` · `CANVAS` · `PREFERENCE` · `PANELIZE` |
| 零件 / 符号 | **`ATTR` (53 208)** · `PART` · **`COMPONENT` (5 790)** · `PIN` |
| 几何图元 | **`LINE` (60 392)** · `POLY` · `RECT` · `CIRCLE` · `ARC` · `ELLIPSE` · `TEXT` · `STRING` |
| PCB 专属 | **`PAD` (4 276)** · `VIA` (4 140) · **`WIRE` (3 932)** · `NET` · `PAD_NET` (7 892) · `TEARDROP` |
| 铺铜 | `FILL` (7 646) · `POUR` · `POURED` |
| 层堆叠 | **`LAYER` (16 971)** · `LAYER_PHYS` · `ACTIVE_LAYER` · `SILK_OPTS` |
| 设计规则 | `RULE` · `RULE_SELECTOR` · `RULE_TEMPLATE` · `PRIMITIVE` (4 577) |
| 其他 | `FONT` · `IMAGE` · `BLOB` · `TABLE` · `OBJ` · **`ELE_PLACEHOLDER` (35 014)** |

### 3.3 各主要类型样例（简化）

```jsonc
// EDIT_HEAD — 编辑者上下文
{"type":"EDIT_HEAD"}||{"uuid":"...","username":"...","updateTime":1776959670248}

// DOCHEAD — 文档头（每个 document 一次，标记后续消息归属）
{"type":"DOCHEAD","ticket":2}||{"docType":"BOARD","uuid":"35086b7d90787675","version":"...","editVersion":"3.2.127","user":{...}}

// META — 文档元
{"type":"META","ticket":1,"id":"META"}||{"title":"无界PLUS","zIndex":1}

// CANVAS
{"type":"CANVAS","ticket":1,"id":"CANVAS"}||{"originX":0,"originY":0}

// PART — 元件封装定义
{"type":"PART","ticket":2,"id":"0603WAF1002T5E.1"}||{"BBOX":[-10,-4,10,4],"title":"0603WAF1002T5E.1"}

// COMPONENT — 元件放置实例
{"type":"COMPONENT","ticket":2,"id":"e1"}||{"partId":"pid8a...","x":0,"y":0,"rotation":0,"attrs":{}}

// ATTR — 属性（value / designator / rotation / color / ...）
{"type":"ATTR","ticket":3,"id":"e1"}||{"partId":"...","key":"Symbol","value":"0603WAF1002T5E",...}

// PIN
{"type":"PIN","ticket":6,"id":"e3"}||{"partId":"...","x":20,"y":0,"length":10,"rotation":180,...}

// NET — 电气网络
{"type":"NET","ticket":123,"id":"[\"NET\",\"+12V\"]"}||{"netType":null,"retLine":true,...}

// PAD_NET — 焊盘 ↔ 网络
{"type":"PAD_NET","ticket":1230,"id":"[\"PAD_NET\",\"e793\",\"1\",\"e18\"]"}||{"padNet":"+12V",...}

// PAD — PCB 焊盘
{"type":"PAD","ticket":134,"id":"e7"}||{"netName":"","layerId":1,"num":"2","centerX":39.37,...}

// LAYER
{"type":"LAYER","ticket":2,"id":"[\"LAYER\",1]"}||{"layerType":"TOP","layerName":"Top Layer",...}

// WIRE + LINE —— WIRE 是总线，LINE 是组成它的线段，通过 lineGroup 关联
{"type":"WIRE","ticket":1009,"id":"e3514"}||{"groupId":"","locked":false,"zIndex":48}
{"type":"LINE","ticket":1010,"id":"..."}||{"lineGroup":"e3514","startX":165,"startY":-708,...}

// POUR / POURED
{"type":"POUR",...}||{"netName":"GND","layerId":1,"pourType":{"pourType":"SOLID",...},...}

// RULE
{"type":"RULE","ticket":225,"id":"[\"RULE\",\"SAFE\",...]"}||{"ruleContext":{"unit":"mm",...}}
```

### 3.4 docType 取值

| docType | 含义 | 样例来源 |
|---|---|---|
| `BOARD` | PCB 板 | 无界PLUS HEAD history |
| `PCB` | PCB 板（另一种叫法） | 泰山派 HEAD history |
| `SCHEMATIC` | 原理图父文档 | structures 中 `schematics` 类别，尚未在 DOCHEAD 中见到样例 |
| 其它待观察 | `SHEET` / `SYMBOL` / `FOOTPRINT` / `3D_MODEL` 等 | 需覆盖不同类型的 history 事件 |

> `BOARD` 与 `PCB` 可能是同义（两个工程的 editVersion 不同：3.2.127 给 BOARD / 3.2.91 给 PCB，疑似 Pro 3.2.x 中期改过命名）。需在更多样本里验证。

---

## 4. 安全与合规考量

### 4.1 登录态与账号

- **必须合法账号登录**；我方主号测试已通过（见 `docs/infra.md`）
- 风控风险：主号被封影响大；**放量 QPS ≤ 0.1**（10 秒一次请求）以降低风控概率；多账号轮询作为后备
- 登录后建议**立即登出 + 重登**一次，让测试期间暴露过的 session 失效（见 `CLAUDE.md` §登录态）

### 4.2 抓取范围的合法性

- 仅抓 **public: true** 且作者有明确 license 声明的项目
- License whitelist（见 `OSHWHUB_INGEST_SPEC.md` §2.1）：MIT / BSD / Apache-2.0 / CC0 / CC-BY-4.0 / CERN-OHL-P / Unlicense
- **拒抓**：CC-BY-SA（立创默认选项，占比可能 50%+）、GPL 系列、"未声明"、NC（禁商用）变种
- 项目 `permissions` 字段可反查本号是否有导出权限（如 `project.file.export`）

### 4.3 加密密钥泄漏

`key` / `iv` 随每次 API 响应明文返回 — 这不是保密凭据，而是服务端通过**登录态 + 签发**控制谁能拿到这对值。解密后的明文落盘即可，不需保护 key/iv。

但 **解密后的工程源是作者的知识产权**：
- 不要将他人工程源上传到 Gitea 公开仓库，先过 license 审核
- 本地存储权限 600（在 `~/` 下也要注意）
- 建索引时 `license_verified = true` 才进入 Forge 交付批次

---

## 5. 接入 Forge 的 gap

按 `OSHWHUB_INGEST_SPEC.md`（消费侧）：每项目三件套 `schematic.kicad_sch` + `manifest.json` + `source/`。

| Forge 要求 | 当前状态 | 缺口 |
|---|---|---|
| `schematic.kicad_sch`（KiCad 7+ S-expression） | 只有 EPRO2 消息流 | **EPRO2 → KiCad 转换器**。候选路线：(a) 调用 Pro 编辑器"导出 KiCad"API（端点待挖）；(b) 自写转换器（解析 §3 全部 types 生成 .kicad_sch） |
| Schematic 单独交付 | 已能按 DOCHEAD 切分 per-document 流 | schematic & sheet 层级的消息流重组（主 sheet 为 root，子 sheet 关联 `schematic_uuid`） |
| `manifest.json` 完整字段 | `metadata.json` 部分覆盖 | 补：`components_used`（从 ATTR / COMPONENT 聚合）、`kicad_sch_version`、`file_checksum_sha256`、`converted_from`、`conversion_tool`、`license_source` |
| 目录命名 `oshwhub_<id>_<slug>` | 当前 `data/raw/oshwhub/<uuid>/` | 写 `scripts/export_for_forge.py` 投影到 `data/processed/batches/batch_<date>/`，保留 raw 不动 |
| License whitelist | 当前较宽 | 按 §4.2 收紧；写 `scripts/filter_for_forge.py` |
| 自测 4 项检查 | 无 | `scripts/forge_batch_check.py`（见 SPEC §6） |

---

## 6. 已验证 / 未解决

| # | 项 | 状态 | 备注 |
|---|---|---|---|
| 1 | 对他人 public Pro 工程能否通 | **✅** | 泰山派 (`2507dcb6...`, 立创开发板团队) 完整链打通 |
| 2 | 多 document 项目的枚举 | **✅** | `/structures` 返回全树；`/thumbs` 返回有缩略图的子集 |
| 3 | 完整 history chain 拉取 | **✅** | `/api/v4/projects/branches/histories?...&limit=N` 批量返回 |
| 4 | 按 DOCHEAD 切分 per-document | **✅** | payload.uuid 是 16-hex document uuid |
| 5 | `SCHEMATIC` docType 样例 | ⏳ 部分 | structures 中确认存在；尚未从实际 history 消息中解出 `docType: "SCHEMATIC"`；可能就用 `SHEET`（每个 sheet 是 document）、`SCHEMATIC` 只作为父容器 |
| 6 | Pro 编辑器"导出 KiCad"端点 | ❌ 未查 | 录 HAR 观察编辑器内"文件 → 导出 → KiCad"操作 |
| 7 | 风控敏感度 | ❌ 未测 | 阶梯放量（10 → 100 → 1000）监控 403/429/1111111 |
| 8 | Std 版 `u.lceda.cn` 同构源 API | ❌ 未查 | 需录 Std 编辑器打开 oshwhub 上 `origin: std` 项目的 HAR |
| 9 | EPRO2 → KiCad 转换实现 | ❌ 未做 | 是 Forge 交付的硬门槛（见 §5） |

---

## 附录 A — 端到端重跑脚本

给定项目 UUID，完整拿到 per-document EPRO2 流：

```bash
uv run python - <<'PY'
import json, gzip, httpx
from collections import defaultdict
from Crypto.Cipher import AES

PROJ = "2507dcb6e20646e29377e4abd4922bfa"   # or any Pro project
cookie = open('/home/ubuntu/.secrets/pro-lceda-cookie-header.txt').read().strip()
headers = {
    "User-Agent": "Mozilla/5.0 Chrome/147.0.0.0",
    "Referer":    "https://pro.lceda.cn/editor",
    "Editor-Version": "3.2.127",
    "Accept":     "application/json",
    "Cookie":     cookie,
    "path":       PROJ,
}

with httpx.Client(headers=headers, timeout=60, follow_redirects=False) as c:
    # 1. project → branch_uuid
    proj = c.get(f"https://pro.lceda.cn/api/v4/projects/{PROJ}").json()["result"]
    branch = proj["branch_uuid"]

    # 2. branch → history_uuid (HEAD)
    br = c.get(f"https://pro.lceda.cn/api/v4/projects/{PROJ}/branches/{branch}").json()["result"]
    head = br["history_uuid"]

    # 3. structures (optional: 打出 document 树)
    st = c.get(f"https://pro.lceda.cn/api/v4/projects/{PROJ}/branches/{branch}/structures").json()["result"]
    structure = json.loads(st["structure"])
    for cat, items in structure.items():
        if isinstance(items, dict):
            print(f"{cat}: {len(items)}")

    # 4. 完整 history chain
    r = c.get(f"https://pro.lceda.cn/api/v4/projects/branches/histories",
              params={"project": PROJ, "branch": branch, "history": head, "limit": 50000, "path": ""})
    chain = sorted(r.json()["result"], key=lambda h: int(h["level"]))
    print(f"chain length: {len(chain)}")

    # 5. 下载 + 解密 + gunzip + DOCHEAD 分组
    docs = defaultdict(list)
    cur = None
    for h in chain:
        blob = c.get(h["dataStrUrl"]).content
        ct, tag = blob[:-16], blob[-16:]
        gz = AES.new(bytes.fromhex(h["key"]), AES.MODE_GCM, nonce=bytes.fromhex(h["iv"])).decrypt_and_verify(ct, tag)
        for ln in gzip.decompress(gz).split(b"\n"):
            if not ln.strip(): continue
            head_msg = json.loads(ln.split(b"||",1)[0])
            if head_msg.get("type") == "DOCHEAD":
                cur = json.loads(ln.split(b"||")[1])["uuid"]
            if cur:
                docs[cur].append(ln)
    print(f"documents reconstructed: {len(docs)}")
PY
```

---

## 附录 B — 变更历史

| 日期 | 变更 |
|---|---|
| 2026-04-24 (rev 1) | 首版：单 history 4 步链 + AES-128-GCM + gzip + EPRO2 消息流（40 种 types 覆盖 BOARD 全要素） |
| 2026-04-24 (rev 2) | 本次：加入 `/structures` 枚举、`/projects/branches/histories?...` 批量 chain 端点、完整重放流水线；大项目（泰山派 36 docs / 35 histories / 66 MB）实测；对他人 public Pro 项目已验证；29 条 `/api/v4/` 端点清单 |
