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

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

---

## TL;DR

| 事项 | 结论 |
|---|---|
| 登录态 | **必须**（`pro.lceda.cn` 有独立 session，与 `u.lceda.cn` 不共享） |
| 核心 API | `/api/v4/projects/<uuid>/branches/<branch>/histories/<history>` (GET) |
| 源文件分发 | `https://modules.lceda.cn/projects/histories/<hash>`（私有 CDN，**不需 cookie**，但只在 API 返回上下文中可下） |
| 加密 | **AES-128-GCM**（`{key, iv}` 由 API 响应给出），16-byte auth tag 附在 ciphertext 末尾 |
| 压缩 | 解密后是 **gzip**，gunzip 后得到源流 |
| 数据格式 | **EPRO2** —— 自定义消息流（`\n` 分隔消息，`||` 分字段），40+ 种 message types |
| 一次工程完整源 | 2.7 MB (示例 `无界PLUS`) / 8 357 条消息 / 含 PART / PAD / NET / WIRE / LAYER / RULE / 等全要素 |
| 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，需 4 步拿源） |
| 版本控制 | 单版本，无 branch 概念 | **Git 风格**：branches + histories（含 `parent`/`snapshot`） |
| 源数据加密 | 未完整探（见 `oshwhub.md` §3.5，`modules.lceda.cn/histories/<hash>.json` 也是 403 AccessDenied，推测同样有签名机制） | **AES-128-GCM**（确认） |
| 源格式 | 早期 EasyEDA JSON（扁平结构） | **EPRO2** 消息流（事件溯源式） |
| oshwhub 标记 | 项目 `origin: "std"` | 项目 `origin: "pro"` |
| 转 KiCad 成熟度 | `easyeda2kicad.py` 等第三方工具 | **无现成工具**，需自写转换器或用 Pro 编辑器内置"导出 KiCad"功能（需研究其 API） |

---

## 2. 抓取 4 步链

### 2.1 URL 链路

```
1. GET /api/v4/projects/<PROJ_UUID>
   → 返回 metadata + `branch_uuid`（默认分支）

2. GET /api/v4/projects/<PROJ_UUID>/branches/<BRANCH_UUID>
   → 返回该分支详情 + `history_uuid`（当前 HEAD history）
   ── 或走列表：
   GET /api/v4/projects/<PROJ_UUID>/branches?getStartNode=true&isNotPage=yes

3. GET /api/v4/projects/<PROJ_UUID>/branches/<BRANCH_UUID>/histories/<HISTORY_UUID>
   → 返回 `[{ uuid, parent, snapshot, key, iv, dataStrUrl, num, snapshot_num }]`
   ── **`key` 和 `iv` 是源数据的 AES-GCM 解密密钥/IV**（都是 32 hex = 16 byte）
   ── `dataStrUrl = https://modules.lceda.cn/projects/histories/<HISTORY_UUID>`

4. GET <dataStrUrl>
   → 返回加密 blob（Content-Type 未声明，二进制流）
```

### 2.2 必需 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.3 解密流程（Python）

```python
from Crypto.Cipher import AES
import gzip, httpx

# 1-3: 走完 API 链拿到 key / iv / dataStrUrl
key = bytes.fromhex(api_resp["key"])   # 16B
iv  = bytes.fromhex(api_resp["iv"])    # 16B (非标准 GCM nonce 长度，12B 会失败)
blob_url = api_resp["dataStrUrl"]

# 4: 下载
blob = httpx.get(blob_url).content
ciphertext, tag = blob[:-16], blob[-16:]  # WebCrypto convention: tag 附末

# 5: AES-GCM decrypt_and_verify
plain_gz = AES.new(key, AES.MODE_GCM, nonce=iv).decrypt_and_verify(ciphertext, tag)

# 6: gunzip
source_stream = gzip.decompress(plain_gz)  # EPRO2 消息流
```

### 2.4 实测数据（示例项目 `无界PLUS`, `37879d790e1e450dad3375232bd5110f`）

| 步骤 | 输入 | 输出 |
|---|---|---|
| blob 下载 | `modules.lceda.cn/.../<hash>` | 416 839 bytes |
| AES-GCM 解密 | blob | 416 823 bytes (16B tag 验证通过) |
| gzip 解压 | 加密载荷 | **2 712 282 bytes** (源消息流) |
| EPRO2 切分 | 按 `\n` 分行 | **8 358 条消息** |

---

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

### 3.1 总体结构

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

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

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

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

### 3.2 消息类型分布（示例 `无界PLUS` — docType=`BOARD`）

| 类别 | types（按数量） |
|---|---|
| 文档 & 元 | `EDIT_HEAD` (1) · `DOCHEAD` (369) · `META` (138) · `CANVAS` (93) · `PREFERENCE` (1) · `PANELIZE` (1) |
| 零件 / 符号 | **`ATTR` (2 059)** · **`PART` (62)** · **`COMPONENT` (181)** · `PIN` (240) |
| 几何图元 | `LINE` (737) · `POLY` (385) · `RECT` (39) · `CIRCLE` (8) · `ARC` (7) · `ELLIPSE` (2) · `TEXT` (17) · `STRING` (19) |
| PCB 专属 | `PAD` (204) · `VIA` (59) · **`WIRE` (128)** · **`NET` (52)** · `PAD_NET` (259) · `TEARDROP` (289) |
| 铺铜 | `FILL` (314) · `POUR` (2) · `POURED` (2) |
| 层堆叠 | **`LAYER` (1 572)** · `LAYER_PHYS` (54) · `ACTIVE_LAYER` (50) · `SILK_OPTS` (2) |
| 设计规则 | `RULE` (16) · `RULE_SELECTOR` (53) · `RULE_TEMPLATE` (1) · `PRIMITIVE` (37) |
| 其他 | `FONT` (6) · `IMAGE` (2) · `BLOB` (1) · `TABLE` (1) · `OBJ` (1) · `ELE_PLACEHOLDER` (894) |

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

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

// DOCHEAD — 文档头（每个 document 一条，ticket 等于当前版本号）
{"type":"DOCHEAD","ticket":2} || {"docType":"BOARD","uuid":"...","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,"isMirror":false,"attrs":{}}

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

// PIN — 元件引脚
{"type":"PIN","ticket":6,"id":"e3"} || {"partId":"...","x":20,"y":0,"length":10,"rotation":180,"pinShape":"NONE",...}

// 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","padLen":0}

// PAD — 焊盘（PCB）
{"type":"PAD","ticket":134,"id":"e7"} || {"netName":"","layerId":1,"num":"2","centerX":39.37,"centerY":0,"defaultPad":{"padType":"RECT","width":55.512,"height":53.15},...}

// LAYER — 叠层定义（每层一条）
{"type":"LAYER","ticket":2,"id":"[\"LAYER\",1]"} || {"layerType":"TOP","layerName":"Top Layer","activeColor":"#ff0000",...}

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

// POUR / POURED — 铺铜定义 / 铺铜渲染结果
{"type":"POUR",...} || {"netName":"GND","layerId":1,"pourType":{"pourType":"SOLID","fineness":8},...}

// RULE / RULE_SELECTOR — 设计规则
{"type":"RULE","ticket":225,"id":"[\"RULE\",\"SAFE\",\"copperThickness1oz\"]"} || {"ruleContext":{"unit":"mm","safeSpacing":[...]}}
```

### 3.4 docType 取值

| docType | 含义 |
|---|---|
| `BOARD` | PCB 布线文档（本次解出来的示例） |
| `SCHEMATIC` | 原理图文档（**未验证**，需要含 schematic 的项目样本） |
| 其它可能 | 待探：`SYMBOL` / `FOOTPRINT` / `3D_MODEL` 等 |

**关键待验证**：一个 Project 下多个 document 的关系 —— 每个 document 是否有独立的 branch/history 链？还是所有 document 共享一条分支？从目前 1 个项目的样本看，API 里一个 project → 一个 branch → 一条 history → 一坨 blob，blob 内含 **1 个 `DOCHEAD`**（本例 `BOARD`）。如果原理图是另一个 document，说明需要：

- 先在 API 里枚举项目的所有 documents（需找对应端点，目前 HAR 里未见）
- 然后每个 document 走独立的 4 步链

---

## 4. 安全与合规考量

### 4.1 登录态与账号

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

### 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 审核
- `~/.secrets/` 之外的本地存储也要做权限控制
- 后续建索引时，每条记录的 `license_verified` 字段必须为 true 才可进入 Forge 交付批次

---

## 5. 接入 Forge 的 gap

按 `OSHWHUB_INGEST_SPEC.md`（消费侧）要求：每项目三件套 `schematic.kicad_sch` + `manifest.json` + `source/`。本章列出从 EPRO2 到 Forge 约束的**缺口**。

| Forge 要求 | 当前状态 | 缺口 |
|---|---|---|
| `schematic.kicad_sch`（KiCad 7+ S-expression） | 只有 EPRO2 BOARD 源流 | **需 EPRO2 → KiCad 转换器**。现成工具（`easyeda2kicad.py`）不支持 Pro 格式。候选路线：(a) 调用 Pro 编辑器的"导出 KiCad"功能 API（需抓 HAR 找端点）；(b) 自写转换器（解析 §3 全部 types → 生成 kicad_sch） |
| Schematic 单独交付（非 BOARD） | 本次样本是 BOARD | 需要找原理图 document 的 API 入口（待验证） |
| `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>/` | 需额外生成 `batch_<date>/oshwhub_<id>_<slug>/` 结构（建议放 `data/processed/batches/`，保留 raw 不动） |
| License whitelist | 当前 whitelist 较宽（含 GPL / CC-BY-SA） | 按 §4.2 收紧；下游过滤器写进 `scripts/filter_for_forge.py` |
| 自测 4 项检查 | 无 | 写 `scripts/forge_batch_check.py`（见 SPEC §6） |

---

## 6. 已知未解决 / 待补

| # | 项 | 优先级 | 行动 |
|---|---|---|---|
| 1 | 对**他人**公开 Pro 工程能否跑通同样 4 步链？ | 高 | 下一批 HAR：挑一个 oshwhub 里 `origin: pro` 的公开项目，复现链 |
| 2 | `SCHEMATIC` docType 是否在独立 document？API 入口？ | 高 | 打开含原理图的 Pro 工程录 HAR |
| 3 | 多 document 枚举端点（project → documents 列表） | 高 | 同上 |
| 4 | Pro 编辑器的"导出 KiCad"功能走什么端点？产物格式？ | 高 | 在编辑器里手动点一次导出，录 HAR |
| 5 | 批量化对风控的敏感度 | 中 | 先从 10 → 100 → 1000 阶梯放量，监控 403/429/1111111 |
| 6 | Std 版 `u.lceda.cn` 的同构源 API（如果有） | 中 | 用 Std 编辑器打开 oshwhub 上 `origin: std` 项目录 HAR |
| 7 | EPRO2 → KiCad 转换完整性 | 中 | 选完路线（§5 gap）后逐 type 实现，自测对比 Pro 编辑器渲染 |

---

## 附录 A — 重跑一次的完整命令

```bash
# 登录态（cookie 已备好在 ~/.secrets/pro-lceda-cookie-header.txt）
# PROJ_UUID / BRANCH / HIST 从 HAR 或 API 响应中提取
uv run python - <<'PY'
import json, gzip, httpx
from Crypto.Cipher import AES

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,
}
PROJ = 'YOUR_PROJECT_UUID'

with httpx.Client(headers=headers, timeout=30) as c:
    p = c.get(f'https://pro.lceda.cn/api/v4/projects/{PROJ}', headers={'path': PROJ}).json()['result']
    br = p['branch_uuid']
    h = c.get(f'https://pro.lceda.cn/api/v4/projects/{PROJ}/branches/{br}', headers={'path': PROJ}).json()['result'][0]
    blob = c.get(h['dataStrUrl']).content
    ct, tag = blob[:-16], blob[-16:]
    pt = AES.new(bytes.fromhex(h['key']), AES.MODE_GCM, nonce=bytes.fromhex(h['iv'])).decrypt_and_verify(ct, tag)
    stream = gzip.decompress(pt)

print(f'messages: {stream.count(chr(10).encode())}')
PY
```

## 附录 B — 变更历史

| 日期 | 变更 |
|---|---|
| 2026-04-24 | 首版：4 步 API 链路 + AES-128-GCM + gzip + EPRO2 消息流格式完整解析（40 种 types 覆盖 BOARD 全要素）；与 Std 版、Forge SPEC 的 gap 列出 |
