Files
FacereDataset/docs/sources/easyeda_pro_source.md
2026-04-24 00:40:18 +08:00

450 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 立创 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 chainlinear, 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 就够(泰山派实际 35limit 值 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**linearlevel 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 IDPART 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.1MIT / 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/` 端点清单 |