Files
FacereDataset/docs/sources/easyeda_pro_source.md
Knowit 3282a028c4 Add EasyEDA Pro EPRO2 source ingestion (3/5 batch test)
打通 oshwhub origin=pro 现代 Pro 3.x 工程的 EPRO2 源抓取链路。3/5
modern Pro 项目完整解出(共 8423 docs / 542 MB plain):

- X86 主板        7374 docs / 481 MB plain (chain=85, editor=3.2.15)
- 220V 桌面电源     771 docs /  26 MB plain (chain=28, editor=3.2.69)
- ESP-VoCat       278 docs / 7.5 MB plain (chain=12, editor=3.2.91)

剩余 2/5 是 legacy Pro 2.x(立创泰山派 RK3566、梁山派),项目 meta
返回 branch_uuid=null + editorVersion="2.1.40",没有 git-style chain
模型,文档直接挂在 boards[].sch/pcb 字段上,访问端点暂未挖通;元
数据落库 metadata.json,source/ 留空。

实现要点:
- fetch_pro_source(): 4 步流程(project → branch HEAD → structures
  → /branches/<B>/histories/<HEAD> 即返完整 chain,无需 ?limit 批量
  端点)+ 逐 history 走 AES-128-GCM 解密(16 字节 IV,pycryptodome
  原生支持)+ gunzip + 按 DOCHEAD 切 per-doc EPRO2 流
- EPRO2 解析坑:行末单 `|` 是行终止符不是字段分隔符,必须先
  rstrip("|") 再 split("||"),否则 payload JSON 解析失败 silently
  swallow 导致 cur_doc 不设 → 第一轮 X86 板 7374 docs 抽出来只剩 2 个
- docType 实测远不止 BOARD/PCB/SCH/SCH_PAGE,还含 SYMBOL /
  FOOTPRINT / DEVICE / BLOB / FONT / CONFIG —— Pro 把组件库快照也
  随项目存到 history,下游做 EPRO2→KiCad 转换时必须先把这些 lib
  doc 加载进 symbol cache
- Pro 2.x vs 3.x 是不同存储模型 —— 3.x 走 branch 模型(已打通),
  2.x 走 boards[] 直链(未打通);判别条件:project meta 的
  branch_uuid 是否为 null

CLI 新增 --with-pro-source / --backfill-pro-source / --pro-cookie /
--origin(按 origin 字段服务端过滤 listing API),crawl_one() 按
origin=pro 自动 dispatch 到 Pro fetcher。

schema:docType 类型从 integer 放宽到 [integer, string, null]
(兼容 Std 的 1/3 + Pro 的 BOARD/SCH 等),新增 message_count 字段。

License 注意:本批 5 个项目全是 NC-SA / GPL,未达 Pro source doc
§4.2 Forge 白名单(MIT/BSD/Apache/CC0/CC-BY/CERN-OHL-P/Unlicense)。
按 CLAUDE.md "研究用、不再分发" 原则 raw 入库无碍;Forge 投影时
另过白名单。

详细技术细节见 docs/sources/easyeda_pro_source.md rev 3 + log.md。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:45:52 +08:00

494 lines
25 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"功能(其端点待挖) |
### 1.1 Pro 2.x 旧版与 3.x 新版(重要)
**Pro 不是单一存储模型**。2026-04 实测发现:
| 维度 | Pro 2.x 旧版 | Pro 3.x 新版 |
|---|---|---|
| `editorVersion` | "2.1.40" 等 | "3.2.91" / "3.2.127" |
| `branch_uuid` | **null**(无分支模型) | UUID有 main 分支) |
| 文档定位 | 项目 meta 的 **`boards: [{sch, name, pcb}]`** 直接指向 doc UUID | `/structures` 返回多 document 树 |
| 历史链 | **不存在**(无 `/branches/<B>/histories/<H>` 端点) | 完整 git-style chain本节后续描述 |
| 状态 | ⏳ **本爬虫暂不支持**HAR 待录 | ✅ 已打通 |
| 实例 | `7360e73d...`立创·泰山派RK3566`0c467598...`(立创·梁山派) | `2507dcb6...`立创·泰山派RK3576`b7784066...`X86电脑主板`ba64bd6f...`ESP-VoCat |
**判别**:先 GET `/api/v4/projects/<P>`,看 `branch_uuid` 是否非空。`null` 即旧版;记录跳过原因到 `data/state/oshwhub_excluded.jsonl`。**旧版工程的 sch/pcb document UUID 在 `boards[]` 字段里**(每个 board = 一对 sch+pcb但访问端点未知 —— 候选探测:`/api/documents/<doc>`(实测 401需要不同 cookie scope`/api/v4/projects/<P>/snapshots`200 但返回的是 project meta 而非 doc
---
## 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
**两条等价路径**(任选其一,均返回相同 chain 数组):
**(a) 编辑器实测路径**(首选 — 2026-04-28 HAR 验证):
```
GET /api/v4/projects/<P>/branches/<B>/histories/<HEAD>
```
URL 看起来像"取单 history 元信息",但 server 实际返回**整条 chain**HEAD 在数组首位,按 parent 链回溯。无需分页crawler 默认走这条。
**(b) 批量端点**(可选 — 早期挖出的接口,仍可用):
```
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 为 HEAD 的完整 chain 数组**编辑器加载实测HAR 2026-04-28非"单 history" |
| `/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 段
- **行末单个 `|`**:所有消息行都以**单个** `|` 结尾,是行终止符(不是字段分隔符)。解析前应先 `rstrip("|")` 再按 `||` split否则末尾片段含尾随 `|` 会让 JSON 解析失败 —— 2026-04-28 实测的坑
本格式是**事件溯源 / 增量操作日志**:一个工程的完整状态 = 所有消息按 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 取值
2026-04-28 在 ESP-VoCatchain=12editor=3.2.91278 docs+ X86 主板chain=85editor=3.2.127,待补全)上实测覆盖情况:
| docType | 含义 | 计数ESP-VoCat 12-history 全量) |
|---|---|---|
| `BOARD` | PCB 板(板物理边框 + 板级元 = "外壳" 容器) | 6 |
| `PCB` | PCB primitives焊盘 / 走线 / 铺铜 / 网络等"内容" | 6 |
| `SCH` | 原理图容器 | 6 |
| `SCH_PAGE` | 子图multi-sheet hierarchy 的具体一页,对应 structures 的 `sheets` | 9 |
| `SYMBOL` | 元件符号定义(每个独特符号一份) | 105 |
| `FOOTPRINT` | 封装定义 | 55 |
| `DEVICE` | 元件库元数据LCSC 等) | 88 |
| `CONFIG` | 项目级配置layer 表、规则模板、网络颜色等) | 1 |
| `BLOB` | 二进制资源(图片 / 3D 模型等) | 1 |
| `FONT` | 字体 | 1 |
**几个重要观察**
- `BOARD``PCB` 是**两类不同的 doc**(不是同义;之前误以为是命名变化)。一个完整的"PCB 板"由一对 `BOARD` + `PCB` document 组成 —— `BOARD` 持有板物理 / 元属性,`PCB` 持有具体几何元素。
- `SCHEMATIC` 在 EPRO2 流里实际叫 **`SCH`**`structures.schematics` ↔ DOCHEAD `docType: SCH`
- `SHEET` 在 EPRO2 流里实际叫 **`SCH_PAGE`**`structures.sheets` ↔ DOCHEAD `docType: SCH_PAGE`
- `SYMBOL` / `FOOTPRINT` / `DEVICE` 是项目使用到的**组件库快照**,每个独特组件一份。这意味着抓 EPRO2 源 = 抓项目 + 完整的局部组件库;下游做转换时**必须**先把这些 lib doc 加载进 symbol cache。
- ESP-VoCat 共 278 docs结构树 (`structures`) 只有 15 个用户级 doc。比例 ~18× 是因为组件 / 符号 / 封装定义都是独立 doc。
> docType doc_uuid 长度并非恒定 16-hex`SYMBOL` / `FOOTPRINT` / `DEVICE` 等组件类的 uuid 是 32 hex与 project / branch 同长);`BOARD` / `PCB` / `SCH` / `SCH_PAGE` 等用户文档是 16 hex。`CONFIG` / `FONT` / `BLOB` 用 ASCII 字面量名作为 uuid。
---
## 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/` 端点清单 |
| 2026-04-28 (rev 3) | HAR 实测:`/branches/{branch}/histories/{head}` 即返回整条 chain无需走 `?limit=N` 批量端点);落地 `crawlers/oshwhub/crawler.py:fetch_pro_source` 端到端打通5 项目批量抓 EPRO2schema docType 兼容 string 取值 + 增 message_count 字段 |