Files
FacereDataset/docs/sources/easyeda_pro_source.md
Knowit c6279bff08 Add EasyEDA Pro 2.x legacy source ingestion (5/5 batch closure)
补齐前一批失败的 2 个 legacy Pro 项目(立创·泰山派 RK3566、立创·梁山派),
打通 Pro 2.x 旧版工程的源抓取链路。结合上一 commit 的 modern Pro 3.x
路径,本仓库 5/5 Pro 项目 EPRO2/dataStr 全部端到端打通。

Pro 2.x 与 Pro 3.x 是两个完全不同的存储模型:
- Pro 3.x:git-style branch + linear history chain,AES-128-GCM 加密的
  EPRO2 增量消息流,按 history 重放(已在前一 commit 打通)
- Pro 2.x:无 branch / 无 history。文档以 EasyEDA Std plaintext dataStr
  存储(同 ["DOCTYPE","SCH","1.1"] 格式),按 doc UUID 通过
  /api/v2/documents/lists 批量 GET,主体无加密,只组件库走 AES

Pro 2.x 抓取链由 HAR (tmp/prodownload3.har, 178 请求) 反推:

  GET  /api/v4/projects/<P>                     → boards: [{sch, pcb, name}]
  GET  /api/projects/<P>/ticket?uuid=&g_ticket=-1
                                                → 完整项目 manifest
  POST /api/schematic/lists {uuids:[<sch>]}     → sort: [{uuid:<sheet>}]
  POST /api/v2/documents/lists {uuids,docType:1} → schematic plaintext
  POST /api/v2/documents/lists {uuids,docType:3} → PCB plaintext
  POST /api/coppers/search {paths}              → 铺铜层
  POST /api/textpath/search {paths,project_uuid}→ 字体/文字
  POST /api/v2/resources/search {hash,project_uuid} → BLOB 图片

实现:
- crawlers/oshwhub/crawler.py:
  - fetch_pro_source() refactor 成 dispatcher,先 GET project meta
    检查 branch_uuid,null 即旧版走 _fetch_pro_legacy(),非空走
    _fetch_pro_modern()
  - _fetch_pro_legacy() 新增(按上面 9 步流程拉所有 doc + 辅助层)
  - _pro_post_json() POST helper(与 _pro_get_json 对称)
- schemas/project.schema.json: source_format enum 加 easyeda-pro-legacy
- docs/sources/easyeda_pro_source.md rev 4: §1.1 旧版 vs 新版判别表更新、
  §2.7 新增旧版抓取流程 + 实测数据

落盘约定(旧版):
  source/ticket.json                     完整 manifest
  source/<sheet_uuid>.json               每张原理图(含 dataStr)
  source/pcb_<pcb_uuid>.json             每块 PCB
  source/coppers.json/textpath.json/blobs.json  辅助 PCB 层资源
  source/manifest.json                   索引

实测:
  立创·梁山派      editor=2.1.30, 2 sheets+1 pcb,    1.0 MB,  78 sym/191 fp/128 dev
  立创·泰山派 RK3566 editor=2.1.40, 29 sheets+1 pcb, 0.8 MB, 299 sym/524 fp/295 dev

旧版项目体量比新版小两个数量级(梁山派 1 MB vs RK3576 66 MB)—— 没有
增量 history,组件库走单独端点,本身就是当前快照。

5/5 Pro 项目终极汇总:
  X86 主板          easyeda-pro        3.2.15  7374 docs / 481 MB
  泰山派 RK3566     easyeda-pro-legacy 2.1.40    30 docs / 0.8 MB
  梁山派            easyeda-pro-legacy 2.1.30     3 docs / 1.0 MB
  220V 桌面电源     easyeda-pro        3.2.69   771 docs /  26 MB
  ESP-VoCat         easyeda-pro        3.2.91   278 docs / 7.5 MB

共 8456 docs / ~516 MB plain。

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

543 lines
28 KiB
Markdown
Raw Permalink 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.30" / "2.1.40" 等 | "3.2.15" / "3.2.69" / "3.2.91" / "3.2.127" 等 |
| `branch_uuid` | **null**(无分支模型) | UUID有 main 分支) |
| 文档定位 | 项目 meta 的 **`boards: [{sch, name, pcb}]`** + ticket 端点 manifest | `/structures` 返回多 document 树 |
| 历史链 | **不存在** | 完整 git-style linear chain |
| 文档存储 | **plaintext** dataStr同 Std `["DOCTYPE","SCH","1.1"]\n[HEAD,...]`),按 doc UUID 批量 GET | AES-128-GCM 加密的 EPRO2 增量消息流,按 history 重放 |
| 加密 | 仅组件库symbols/devices走 AES文档本身明文 | 全部 history blob 都加密 |
| 实例 | `7360e73d...`RK3566 / 2.1.40 / 30 docs`0c467598...`(梁山派 / 2.1.30 / 3 docs | `2507dcb6...`RK3576`b7784066...`X86 / 7374 docs`ba64bd6f...`ESP-VoCat / 278 docs |
| 状态 | ✅ 已打通HAR `tmp/prodownload3.har` 反推) | ✅ 已打通 |
| schema source_format | `easyeda-pro-legacy` | `easyeda-pro` |
**判别**:先 GET `/api/v4/projects/<P>`,看 `branch_uuid` 是否非空。`null` 即旧版,走 §2.7 的旧版抓取路径;非空走 §2.1-§2.6 的新版路径。`crawlers/oshwhub/crawler.py:fetch_pro_source()` 是 dispatcher根据 `branch_uuid` 自动分流到 `_fetch_pro_modern()``_fetch_pro_legacy()`
---
## 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。
### 2.7 Pro 2.x 旧版抓取(无 branch / 无 history chain
适用于 `branch_uuid: null` 的工程(`editorVersion` 形如 `2.1.40`)。文档以 **EasyEDA Std plaintext dataStr** 形式存储,按 UUID 批量 GET不需要解密 / 解压 / 重放。
#### 流程
```
GET /api/v4/projects/<P> → boards: [{sch, pcb, name}]
GET /api/projects/<P>/ticket?uuid=<P>&g_ticket=-1 → manifest: {schematics, schs, pcbs, coppers, textpath, blobs, symbols, footprints, devices, ...}
POST /api/schematic/lists {uuids:[<sch>]} → 父 schematic 容器,含 sort: [{uuid: <sheet>, ticket}, ...]
POST /api/v2/documents/lists {uuids:[<sheets>], docType:1} → 每个 sheet 的 dataStr 明文
POST /api/v2/documents/lists {uuids:[<pcbs>], docType:3} → 每个 PCB 的 dataStr 明文
POST /api/coppers/search {paths:[<copper_paths>]} → 铺铜数据PCB 增量层)
POST /api/textpath/search {paths:[<textpath_paths>], project_uuid, path} → 字体 / 文字路径
POST /api/v2/resources/search {hash:[<sha256>], project_uuid} → BLOB嵌入图片等
POST /api/v2/components/searchByIds {uuids:[<symbol_uuids>]} → 元件符号定义(含 dataStrId/key/ivAES 加密的 lib 数据;可选)
POST /api/devices/searchByIds {uuids:[<device_uuids>]} → 元件库 metadata
```
#### 必需 headers
与 §2.5 相同(`Editor-Version` / `path: <PROJ_UUID>` / `Cookie`)。
#### 实测数据
| 工程 | editorVersion | sheets | pcbs | symbols | footprints | devices | blobs | coppers | size |
|---|---|---|---|---|---|---|---|---|---|
| 立创·梁山派 | 2.1.30 | 2 | 1 | 78 | 191 | 128 | 1 | 29 | 1.0 MB |
| 立创·泰山派 RK3566 | 2.1.40 | 29 | 1 | 299 | 524 | 295 | 32 | 0 | 0.8 MB |
**关键观察**
- 旧版项目体量比新版小**两个数量级**(梁山派 1 MB vs RK3576 66 MB plain—— 因为没有 history 增量、组件库走单独端点、本身就是当前快照。
- `manifest_ticket['schematics']` ↔ schematic CONTAINER板级"原理图"实体);`manifest_ticket['schs']` ↔ 单个 SHEET一页图纸`boards[].sch` 指向前者,需要 schematic/lists 一步把 sort 拆出 sheet UUIDs。
- 抓 plaintext docs 后,**无需** AES 解密(这点跟 Std 一样)。但 symbols/devices 端点返回的 lib 数据**仍然是 AES 加密的 dataStrId blob**,跟 Pro 3.x 同方案;如需还原 lib 内容需照 §2.3 解密流程。
#### 落盘约定
- `source/ticket.json` — 完整 manifest_ticket保留以备后续 lib 重建)
- `source/<sheet_uuid>.json` — 每张原理图(含 `dataStr` 字段)
- `source/pcb_<pcb_uuid>.json` — 每块 PCB
- `source/coppers.json` / `source/textpath.json` / `source/blobs.json` — 辅助层资源
- `source/manifest.json` — 索引 + structure_summary
实现:`crawlers/oshwhub/crawler.py:_fetch_pro_legacy()`
#### 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 字段 |
| 2026-04-28 (rev 4) | HAR `prodownload3.har` 反推 **Pro 2.x 旧版抓取链**`/api/projects/<P>/ticket` + `/api/v2/documents/lists` 批量端点plaintext dataStr+ supplementary coppers/textpath/blobs/components/devices新增 `_fetch_pro_legacy()` + 在 `fetch_pro_source()``branch_uuid` 自动 dispatchschema source_format enum 增 `easyeda-pro-legacy`5/5 Pro 项目3 modern + 2 legacy全部打通 |