打通 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>
25 KiB
立创 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 分隔消息,` |
| 小项目样例 | 无界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):
{
"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 就够(泰山派实际 35,limit 值 500/5000/50000 返回一致)path参数无作用(各种值返回相同结果)
返回:
{
"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,单调递增到 HEADsnapshot字段全为 null(没见过有值的;存在但未使用?)- 没有"merge commit" 结构
2.3 解密 + 解压 + 重放(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 条(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 段- 行末单个
|:所有消息行都以单个|结尾,是行终止符(不是字段分隔符)。解析前应先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 各主要类型样例(简化)
// 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-VoCat(chain=12,editor=3.2.91,278 docs)+ X86 主板(chain=85,editor=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+PCBdocument 组成 ——BOARD持有板物理 / 元属性,PCB持有具体几何元素。SCHEMATIC在 EPRO2 流里实际叫SCH;structures.schematics↔ DOCHEADdocType: SCH。SHEET在 EPRO2 流里实际叫SCH_PAGE;structures.sheets↔ DOCHEADdocType: 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.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 流:
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 项目批量抓 EPRO2);schema docType 兼容 string 取值 + 增 message_count 字段 |