Publish agent-radar via gitea-publish skill

This commit is contained in:
gitea-publish
2026-09-01 15:14:54 +08:00
commit 78b87e8b2f
17 changed files with 2279 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# 源码镜像是全量裸仓,单个项目可达数百 MB,绝不能进版本库
.mirrors/
# Python
__pycache__/
*.py[cod]
.venv/
venv/
# 系统
.DS_Store
*.swp
# 临时
/tmp/
*.tmp
+137
View File
@@ -0,0 +1,137 @@
# agent-radar · 全球智能体开源应用追踪
持续追踪全球 AI Agent 开源应用,输出**深度分析文章**(发布在 [blog.apescale.com](https://blog.apescale.com) 可巴蒂栏目),
并**镜像源码**到 `gitinbox` 组织做代码级跟踪。
设计原则:**每新增一个项目,边际成本递减,横向对比能力自动增强。**
所有项目共用同一套五维 schema,因此对比矩阵、赛道图、总纲索引都能由脚本自动生成,
人只需要做「研判与写作」这部分不可替代的工作。
---
## 收录标准
满足任一即可收录,但会标注不同 `status`:
| status | 含义 | 判定 |
| --- | --- | --- |
| `active` | 持续追踪 | 有实际影响力,或技术路线有代表性,值得长期跟进 |
| `watch` | 观察中 | 早期项目,方向有意思但尚未验证 |
| `archived` | 归档 | 停更 / 被收购 / 路线变更 |
**不收录**:纯模型权重、纯数据集、无公开代码的论文复现、营销型空壳仓库。
---
## 目录结构
```
agent-radar/
├── README.md # 本文件
├── schema/
│ ├── project.schema.yaml # 五维档案字段定义 + 枚举(也是脚本的骨架生成源)
│ └── license-tiers.yaml # 许可证成色分级 → 镜像可见性规则
├── data/
│ ├── registry.yaml # 收录总表,驱动批量抓取
│ ├── projects/<id>.yaml # 每项目一份五维档案
│ └── snapshots/<id>.jsonl # 指标时间序列(追加式,一行一天)
├── articles/
│ ├── 00-overview.md # 总纲文源文(含矩阵占位符,由脚本回填)
│ └── <id>.md # 项目深度文源文
├── templates/project-article.md # 文章固定章节模板
├── scripts/ # 抓取 / 生成 / 镜像 / 发布脚本
└── site/index.html # 静态总览页
```
---
## 五维档案
每个项目一份 `data/projects/<id>.yaml`,字段定义见 `schema/project.schema.yaml`:
| 维度 | 回答的问题 |
| --- | --- |
| `identity` | 它是谁、谁在做 |
| `business` | 它靠什么活着、为谁活着 |
| `scenario` | 它在什么场合被用、产出什么 |
| `tech` | 它怎么做到的、抽象在哪一层 |
| `ecosystem` | 它的社区动能(多数由脚本自动刷新) |
| `risk` | 它的坑在哪、宣称与实测的落差 |
> `risk.claim_vs_reality` 是最不该留空的字段。它逼着每篇分析去核实
> 「项目方宣称 vs 实际状态」,防止文章退化成 README 复读。
---
## 许可证分级 → 镜像可见性
**新增项目时的第一判断项。** 规则见 `schema/license-tiers.yaml`:
| 级 | 判定 | 成色 | 镜像可见性 |
| --- | --- | --- | --- |
| L1 | MIT / Apache-2.0 / BSD / ISC / GPL / MPL | 完全开源 | `public` |
| L2 | AGPL / SSPL / BUSL / Elastic-2.0 | 有限开源 | `public`(档案标注限制) |
| L3 | NOASSERTION + 自定义 source-available | 源码可得、非开源 | **`private`** |
| L4 | 无 LICENSE / proprietary | 不可得 | 不镜像,仅存链接 |
定错级的代价:L3 项目被公开镜像,等于替对方做了一次未经授权的公开分发。
---
## 新增一个项目的 SOP
```bash
# 1. 建档(自动抓许可证并定级 L1-L4)
python3 scripts/new_project.py --repo owner/name
# 2. 手工填档案五维度 —— 重点填 risk.claim_vs_reality
$EDITOR data/projects/<id>.yaml
# 并把它加进 data/registry.yaml
# 3. 刷新指标
python3 scripts/fetch_stats.py --id <id>
# 4. 镜像源码(按许可证等级自动决定 private / public)
bash scripts/mirror_sync.sh --id <id>
# 5. 成文(套 templates/project-article.md)
$EDITOR articles/<id>.md
# 6. 发布到 emlog(可巴蒂 sid=17),emlog_id 会自动回写档案
python3 scripts/publish_emlog.py --article <id>
# 7. 回填总纲并更新总纲文
python3 scripts/build_matrix.py
python3 scripts/publish_emlog.py --article 00-overview
# 8. 入库
git add -A && git commit -m "track: <name>" && git push
```
### ⚠️ 更新总纲文时必读
emlog 的 update 接口**不传 `--content` 就会清空正文**。
`publish_emlog.py` 已内置保护(update 前先 `detail` 取回全文),
但如果你手工调 emlog 脚本更新总纲,**必须先 detail 再 update**。
---
## 脚本一览
| 脚本 | 作用 | 降级行为 |
| --- | --- | --- |
| `new_project.py` | 建档 + 自动判许可证等级 | API 失败时生成未定级骨架,要求人工填 |
| `fetch_stats.py` | 抓指标 + 累积时间序列快照 | 无 `GITHUB_TOKEN` 则匿名降级;当日缓存;失败沿用上次快照并标 `stale` |
| `build_matrix.py` | 生成对比矩阵 + 赛道图 / 象限图 | 项目 <4 个出分层图,≥4 个才出象限图 |
| `mirror_sync.sh` | 源码镜像同步 | 大仓(>300MB)提示并支持 treeless clone 降级 |
| `publish_emlog.py` | emlog 发布 / 更新 | update 前强制取回全文防空内容 |
---
## 已知约束
- **GitHub API 限额**:无 `GITHUB_TOKEN` 时匿名限额 60 次/小时。
项目数超过约 20 个后批量刷新会受限,建议配置 token。
- **磁盘占用**:源码镜像是全量裸仓。10 个量级约需数 GB,
大仓可用 `--filter=blob:none` 做 treeless 镜像。
- **象限图阈值**:样本 <4 个时象限图没有统计意义,此阶段只出赛道分层图。
+161
View File
@@ -0,0 +1,161 @@
# 全球智能体开源应用追踪 · 总纲
> 这是一个**持续更新**的追踪索引。每收录一个新项目,会先发一篇深度文,再回来回填这里。
> 所有数据来自 GitHub API 的定时快照,所有结论都标注了核实来源。
> 数据与脚本开源在 `gitinbox/agent-radar`。
---
## 这个系列在追什么
2026 年的 Agent 赛道,竞争焦点正在从"模型能力"转向"Harness / Agent Runtime 与执行工作流"。
Codex Harness 开放、DeepSeek Harness 开放,一个个执行引擎被拆出来单独发布——
**引擎层在碎片化,而使用引擎的人需要一个不随引擎漂移的落脚点。**
这个系列持续追踪全球 Agent 开源应用,回答三个问题:
1. 它到底站在 Agent 工具链的哪一层?
2. 它的宣称和实际状态之间,差多少?
3. 什么人该用它,什么人不该?
---
## 方法论
每个项目按**五个维度**建档,字段定义开源在 `schema/project.schema.yaml`:
| 维度 | 回答的问题 |
| --- | --- |
| 业务 | 它靠什么活着、为谁活着 |
| 场景 | 它在什么场合被用、产出什么 |
| 技术 | 它怎么做到的、抽象在哪一层 |
| 生态 | 它的社区动能(由脚本定时抓取,附带数据日期) |
| 风险 | 它的坑在哪、宣称与实测的落差 |
其中 **「宣称 vs 实测」是本系列最看重的一节**。
写它的时候我会专门去抓 README 里的限定词——`currently`、`today`、`in development`、`optional`——
这些词往往是落差的藏身处。项目方没说谎,但也没说全。
---
## 赛道坐标系
> Agent 工具大致分三层。判断一个项目,先判断它站在哪一层,以及它想往哪一层长。
```text
┌─────────────────────────────────────────────────────────────┐
│ 桌面工作台层 │
│ 跨引擎、跨项目、跨交付物的统一界面 │
│ 代表:iPolloWork │
├─────────────────────────────────────────────────────────────┤
│ 编辑器内 Agent 层 │
│ 嵌在 IDE / 编辑器里的交互层 │
│ 代表:Cline、Roo Code、Kilo Code │
├─────────────────────────────────────────────────────────────┤
│ 执行引擎层 │
│ 真正跑 agent loop 的地方 │
│ 代表:Codex、DeepSeek Harness、OpenCode、Gemini CLI │
└─────────────────────────────────────────────────────────────┘
```
越往上,离"模型"越远、离"交付物"越近,也越容易形成用户黏性——
但也越容易被下一层向上长出来的能力挤压。
---
<!-- MATRIX:BEGIN -->
### 业务维度
| 项目 | 定位 | 目标用户 | 变现 | 许可证 | 成色 |
| --- | --- | --- | --- | --- | --- |
| **iPolloWork** | 站在 Agent 工具链的**工作台层**而非引擎层:不替代 Codex / DSH / OpenCode 中任何一个,而是把它们收进同一个项目空间,并把交付物从代码延伸到文档、演示稿、网站、设计与视频 | 企业研发团队、需要人机协作的 agent 团队、需要产出并持续修改 PPT / 网页 / 设计 / 视频的内容团队 | 双轨:个人自用与少于 3 人的小规模内部使用免费;3 人及以上、以及任何售卖 / 转售 / 付费服务 / SaaS / 托管 / 白标 / 市场分发 / 面向客户的使用,均需书面授权。配套 iPolloCloud 承载身份、组织、权益与商业 App | iPolloWork Source Available License 1.0 | L3 |
### 场景维度
| 项目 | 交互形态 | 人机协作 | 交付物 |
| --- | --- | --- | --- |
| **iPolloWork** | desktop-app | approval-gated | code、doc、ppt、website、design、video |
### 技术维度
| 项目 | 形态 | 引擎抽象 | 接入深度 | 模型绑定 | 本地优先 |
| --- | --- | --- | --- | --- | --- |
| **iPolloWork** | Electron 桌面应用(另支持 dev:ui 只起浏览器 UI) | Engine Protocol(经 local API);Codex 与 MCP 客户端另经 ipollowork-ui-mcp 控制面接入 | **三种引擎的接入深度并不对等**:OpenCode 是默认本地执行 runtime,走 local API → Engine Protocol 的原生 adapter,以独立 sidecar 形式存在(首次桌面构建时下载,不 fork、可独立升级);DeepSeek Harness 是可选 peer runtime 与子代理委派目标,同样走 Engine Protocol;Codex 则**仅**通过 ipollowork-ui-mcp 的 MCP 控制面接入,README 明确写明 currently uses the MCP control surface rather than claiming a native Codex engine adapter | byom | 是 |
### 生态与风险
| 项目 | Star | Fork | Issues | 最新 release | 节奏 | 成熟度 | 数据日期 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| **iPolloWork** | 5201 | 976 | 80 | v0.50.12 | 一天多版 | beta | 2026-09-01 |
### 宣称 vs 实测
| 项目 | 宣称 | 实际 | 证据来源 |
| --- | --- | --- | --- |
| **iPolloWork** | 兼容 Codex、DeepSeek Harness、OpenCode 三大引擎 | 三者接入深度不对等。OpenCode 与 DSH 走 Engine Protocol 原生 adapter;Codex 只通过 ipollowork-ui-mcp 的 MCP 控制面接入,README 自己否认这是原生 Codex engine adapter | 仓库 README 的 Architecture boundary 一节原文 |
| **iPolloWork** | 集成 DeepSeek Harness 用于子代理委派 | 第三方插件目录标注为 integration in active development, not yet in the stable release;且 DSH 本身处于 developer preview 阶段。功能方向成立,但尚未进入稳定版 | deepseekharnessplugins.com 插件页;README 关于 DSH developer preview 的说明 |
| **iPolloWork** | 8 小时完成 Codex Harness 适配,登顶相关主题榜第一 | 响应速度属实且有第三方报道佐证,但「适配」指的是打通 MCP 控制面路径,不等于原生引擎级集成。快是因为走控制面,深度也受限于控制面 | ME News 报道;README 关于 Codex 接入方式的表述 |
| **iPolloWork** | 本地优先、不连云也能完整运行 | 这一点基本成立:iPolloCloud 明确为可选,负责身份、组织、权益、托管 worker 与商业 App。但默认本地 runtime 是 OpenCode sidecar,首次桌面构建时需联网下载,且开发模式与 Orchestrator sidecar 都由 iPolloWork 侧编排 | README 架构边界与安装章节 |
| **iPolloWork** | 统一的插件与 Skills 生态 | 统一的是**生命周期**(安装/启用/更新/卸载一次),不是能力本身。README 明确各 runtime 保留自己的 agents、Skills、插件与执行模型,引擎专属增强只是可选 | README 的 Agent runtime compatibility 一节 |
| **iPolloWork** | 仓库 topics 标注 claude-code | 中英文 README 均未描述任何 Claude Code 接入方式。topic 更像搜索曝光策略,不应视为已支持的能力 | GitHub topics 与 README 全文对照 |
| **iPolloWork** | 企业级(enterprise-grade) | 版本号仍在 0.50.x,无公开 Roadmap,发布节奏为一天多版,仓库体积 591 MB。工程投入可观,但版本语义上尚未到企业可用的稳定态 | GitHub releases;README 版本说明;仓库 size 字段 |
### 一句话结论
| 项目 | 结论 | 适合 | 不适合 |
| --- | --- | --- | --- |
| **iPolloWork** | 把多个 Agent 引擎收进同一个可编辑工作台的野心之作——方向踩在点上,但三种引擎的接入深度并不齐整,且许可证决定了它暂时只适合个人或极小团队试用 | 想在同一个界面管理多引擎项目、并希望 agent 产出的 PPT / 网页 / 设计 / 视频之后还能继续手工修改的个人开发者,或少于 3 人的极小团队 | 3 人以上需要正式落地的团队(许可证要求书面授权)、期待 Codex 原生级集成的用户(目前只有 MCP 控制面)、以及追求稳定的生产环境(v0.50.x、一天多版、DSH 集成未进稳定版) |
```text
┌─────────────────────────────────────────────────────────┐
│ 执行引擎层 (暂无收录)
│ └ 真正跑 agent loop 的地方
│ │
│ 编辑器内 Agent 层 (暂无收录)
│ └ 嵌在 IDE / 编辑器里的交互层
│ │
│ 桌面工作台层 iPolloWork★5201
│ └ 跨引擎、跨项目、跨交付物的统一界面
└─────────────────────────────────────────────────────────┘
```
> 当前收录 1 个项目,不足 4 个,此阶段只出分层图。样本够了之后会自动切换为象限图。
<!-- MATRIX:END -->
---
## 深度文索引
| 项目 | 一句话结论 | 文章 |
| --- | --- | --- |
| iPolloWork | 把多个 Agent 引擎收进同一个可编辑工作台的野心之作——方向踩在点上,但三种引擎的接入深度并不齐整,且许可证决定了它暂时只适合个人或极小团队试用 | 见同期发布的《iPolloWork 深度拆解》 |
---
## 收录标准
| status | 含义 |
| --- | --- |
| `active` | 有实际影响力,或技术路线有代表性,持续跟进 |
| `watch` | 早期项目,方向有意思但尚未验证 |
| `archived` | 停更 / 被收购 / 路线变更 |
不收录:纯模型权重、纯数据集、无公开代码的论文复现、营销型空壳仓库。
### 源码镜像的可见性规则
源码会镜像到 `gitinbox` 组织做代码级追踪,可见性**按许可证分级**,不做人工例外:
| 级 | 判定 | 镜像可见性 |
| --- | --- | --- |
| L1 | MIT / Apache-2.0 / BSD / ISC / GPL / MPL | 公开 |
| L2 | AGPL / SSPL / BUSL / Elastic-2.0 | 公开(标注限制) |
| L3 | 自定义 source-available(如 iPolloWork) | **私有** |
| L4 | 无 LICENSE / proprietary | 不镜像,仅存链接 |
L3 之所以必须私有:这类协议的条款通常明确要求「任何 SaaS / 托管 / 市场分发均需事先书面授权」,
公开镜像会落进这个解释空间里。替别人的源码做一次未经授权的公开分发,不值得。
---
<sub>本页由 `gitinbox/agent-radar` 的脚本自动生成与维护 · 数据快照为追加式时间序列,可回溯任意时点的指标</sub>
+4
View File
@@ -0,0 +1,4 @@
# 总纲文发布元数据(由 scripts/publish_emlog.py 维护)
emlog_id: 98
url: "https://blog.apescale.com/?post=98"
last_published: "2026-09-01"
+268
View File
@@ -0,0 +1,268 @@
# iPolloWork 深度拆解:当"多引擎中立"成为 Agent 工作台的新战场
> **速览**
>
> | 项 | 值 |
> | --- | --- |
> | 仓库 | [Devin-AXIS/iPolloWork](https://github.com/Devin-AXIS/iPolloWork) |
> | 一句话 | 面向人与智能体团队的企业级、本地优先 Agent 工作台 |
> | Star / Fork / Issues | 5201 / 976 / 80(2026-09-01) |
> | 主语言 / 体积 | TypeScript 49.5% / **591 MB** |
> | 最新版本 / 节奏 | v0.50.12 / **一天多版** |
> | 许可证 | iPolloWork Source Available License 1.0(**L3 · 源码可得,非开源**) |
> | 成熟度 | beta |
>
> **一句话结论**:把多个 Agent 引擎收进同一个可编辑工作台的野心之作——方向踩在点上,但三种引擎的接入深度并不齐整,且许可证决定了它暂时只适合个人或极小团队试用。
---
## 0. 先给结论
iPolloWork 不是一个编程 Agent,也不是某个引擎的"平替"。它想做的是 **Agent 时代的 IDE —— 但不是给代码用的 IDE,而是给"agent 项目"用的工作台**。
它真正有意思的地方有三个:
1. **它赌引擎会继续碎片化。** Codex Harness 开放了、DeepSeek Harness 开放了、OpenCode 在长——引擎层在分裂,而它把自己放在引擎之上,让引擎可换。这个站位是有战略眼光的。
2. **它把"交付物能不能继续改"当成核心卖点。** 大多数 Agent 工具交付的是一段聊天记录或一个成品文件;iPolloWork 要的是产出 PPT、网页、设计、视频之后,文字、图片、布局、时间线、场景都还能继续编辑。这是从"生成工具"跨向"工作台"的关键一步。
3. **它同时也是一个正在快速商业化的产品。** 源码可用,但不是开源。3 人以上就要书面授权。
而它最大的问题也有三个:**三种引擎的接入深度不对等**、**DSH 集成尚未进稳定版**、**版本还在 0.50.x 却一天发好几版**。这三件事放在一起,意味着它现在更适合观察,而不是押注。
---
## 1. 赛道定位:它站在哪一层
Agent 工具大致可以分成三层。判断一个项目,先判断它站在哪一层,以及它想往哪一层长:
```text
┌──────────────────────────────────────────────────────────────┐
│ 桌面工作台层 │
│ 跨引擎、跨项目、跨交付物的统一界面 │
│ ── iPolloWork 在这里 ── │
├──────────────────────────────────────────────────────────────┤
│ 编辑器内 Agent 层 │
│ 嵌在 IDE / 编辑器里的交互层(Cline、Roo Code、Kilo Code 一类) │
├──────────────────────────────────────────────────────────────┤
│ 执行引擎层 │
│ 真正跑 agent loop 的地方 │
│ Codex、DeepSeek Harness、OpenCode、Gemini CLI │
└──────────────────────────────────────────────────────────────┘
```
**iPolloWork 明确站在最上层,并且明确声明不往下走。** README 里写得很直白:它不会 fork OpenCode,也不会 fork DeepSeek Harness,两者都可以继续独立演进。这是相当克制的产品决策——放弃了对执行层的控制,换来了"任何引擎都能接"的合法性。
越往上,离模型越远、离交付物越近,用户黏性越强;但风险也一样明显:**它依赖的每一层都可能向上长**。OpenCode 可以自己做工作台,IDE 厂商可以从编辑器层往上爬。中立是它的护城河,反过来说,也是"它没有独占能力"的另一种表述。
---
## 2. 业务维度:它靠什么活着
**目标用户**是"人与 agent 团队"——注意不是个人开发者。README 反复出现的是 responsibilities、tasks、schedules、execution health、approves actions 这类词,全是团队协作语义。
**变现走的是典型双轨制**:个人自用免费,少于 3 人的小规模内部使用免费;超过这条线,以及任何售卖、转售、付费服务、SaaS、托管、白标、市场分发或面向客户的使用,都要书面授权。配套的 iPolloCloud 负责身份、组织、权益、托管 Worker 生命周期和商业 App。
### 许可证:这是"源码可用",不是"开源"
这一节值得单独说,因为很多人会看错。
iPolloWork 用的是自家写的 *iPolloWork Source Available License 1.0*,GitHub 上 SPDX 识别为 `NOASSERTION`。项目方自己在中英文 README 里都写清楚了:**这是源码可用协议,不是 OSI 认可的开源协议**。
关键条款有三条:
- **3 名及以上用户**的任何形式使用——不管个人、企业、内部、外部、商业还是非商业——都需要事先书面授权。
- 任何**售卖、转售、收费服务、SaaS、托管、白标、市场分发或面向客户**的使用,都需要事先书面授权。
- 面向用户的前端**必须保留** iPolloWork 名称、Logo 和产品归属,除非书面授权明确允许换品牌。
第三条尤其值得注意:**它连白标都要管**。这说明它的商业意图不只是卖授权,还要保住品牌资产。
顺带说一个实操层面的推论:正因为第二条里写了"托管、市场分发",我做源码追踪时把它的镜像设成了**私有**。替别人的源码做一次未经授权的公开分发,不值得。这也是本系列把"许可证分级"做成自动化规则的原因——这种判断不该每次都靠临时想起来。
---
## 3. 场景维度:它在什么场合被用
**人机协作模式是"审批式"**(approval-gated)。README 的原话是:你描述目标,agent 负责规划和执行,团队可以检查进度、批准操作,并在同一个地方继续编辑结果。不是全自动,也不是全程盯着,是**关键动作逐项批准**。
**交付物是它最想讲的差异化。** 覆盖代码、文档、网站、演示稿、设计和视频六类,而且强调生成之后**仍然可编辑**:
| 产出类型 | 生成后仍可修改的要素 |
| --- | --- |
| 文档 / 演示稿 | 文字、图片、布局 |
| 网站 | 文字、图片、布局 |
| 设计 | 文字、图片、布局 |
| 视频 | 时间线、场景 |
这个设计判断我认同。当前 Agent 工具最大的浪费不是生成得不够好,而是**生成出来的东西没法接着改**——一次不满意,整段推倒重来,最后只剩一段聊天记录。把"可编辑"做成一等公民,是把 Agent 从"一次性生成器"推向"生产工具"的必要一步。
典型用例大致是这三类:
- 同一项目里混用 OpenCode 和 DSH,任务状态与流式输出统一在工作台呈现,而不是散在多个终端会话里
- 让 agent 产出一份 PPT 或网页后,人工继续改文字、图片、布局,而不是只拿到一个成品文件
- 团队在同一项目视图里查看职责、任务、日程与执行健康度,并逐项批准 agent 的动作
---
## 4. 技术维度:它怎么做到的
### 架构
README 给的架构边界是这样的:
```text
Codex / MCP 客户端 ── ipollowork-ui-mcp ──> iPolloWork 桌面/UI
│
├── 本地 API ──> Engine Protocol ──> OpenCode(默认)
│ └──> DeepSeek Harness(可选)
└── 可选账号与控制请求 ──> iPolloCloud
```
我在这个基础上标一层**接入深度**,这才是理解它的关键:
```text
┌─ iPolloWork 桌面/UI ────────────────────────────────────────┐
│ │
│ Codex ──[ ipollowork-ui-mcp ]──────────┐ │ 深度:浅(仅控制面)
│ ▼ │
│ 本地 API ──[ Engine Protocol ]──> OpenCode(默认 sidecar) │ 深度:深(原生 adapter)
│ └──> DeepSeek Harness(可选)│ 深度:深(peer runtime)
│ │
│ 可选 ──> iPolloCloud(身份/组织/权益/托管/商业 App) │ 断网可跑
└──────────────────────────────────────────────────────────────┘
```
### monorepo 布局
仓库是标准的四 app 结构,值得关注的是 `apps/orchestrator`:
| 目录 | 职责 |
| --- | --- |
| `apps/app` | 共享 React UI |
| `apps/desktop` | Electron 外壳与打包 |
| `apps/server` | 服务端 API |
| `apps/orchestrator` | **headless 运行时编排**(用 Bun 1.3.10+ 构建) |
| `packages` | 共享 types、components、docs、integrations |
| `evals` | 可执行产品流与验证工具 |
| `external-plugins` | 面向**外部** agent host 独立发布的插件 |
| `vendor` | pinned 第三方源码,随主仓一起构建 |
`external-plugins` 这个目录透露了它的另一面:它不只把别人的引擎接进来,还把自己的插件**反向输出**到别人的生态里(比如发到 npm 上的 `deepseek-idesign` / `deepseek-ippt` / `deepseek-ivideo`,让 DSH 用户直接用上 iPolloWork 的 Design / PPT / Video 视图)。这是很聪明的双向渗透。
### 引擎抽象:三种引擎,三种深度
这是全文技术上最该看清的一点。
| 引擎 | 接入路径 | 深度 | 状态 |
| --- | --- | --- | --- |
| **OpenCode** | 本地 API → Engine Protocol | 原生 adapter | 默认本地执行 runtime |
| **DeepSeek Harness** | 本地 API → Engine Protocol | peer runtime + 子代理委派目标 | 可选,**仍在开发中** |
| **Codex** | `ipollowork-ui-mcp` MCP 控制面 | **仅控制面** | 已打通,但非原生 adapter |
README 对 Codex 的表述相当诚实,原话是:
> Codex compatibility **currently** uses the MCP control surface **rather than claiming** a native Codex engine adapter.
也就是说,项目方自己承认了 Codex 这条路径和 OpenCode / DSH 不是一回事。注意加粗的两个词——`currently`(目前)和 `rather than claiming`(而不是声称)——**限定词往往就是落差的藏身处**。
### 统一的是生命周期,不是能力
"统一插件与 Skills"是它主打的卖点之一,但要准确理解:
统一的是**生命周期**(安装、启用、更新、卸载一次搞定),不是能力本身。README 明确写着,各 runtime 保留自己的 agents、Skills、插件与执行模型,引擎专属增强只是可选。
这个设计其实是对的——硬要统一能力,要么牺牲深度,要么逼引擎做适配。但它意味着"一次安装处处可用"是有限度的。
### 本地优先是真的
iPolloCloud 明确是可选的,负责身份、组织、权益、托管 Worker 和商业 App。**不连 Cloud 也能完整本地运行**,这一点属实。
细节上:默认本地 runtime 是 OpenCode,以**独立 sidecar** 形式存在,首次桌面构建时下载,iPolloWork 不 fork、不改写它,OpenCode 可以独立升级。开发模式还用了隔离状态,不会污染用户原有的 OpenCode 配置。
---
## 5. 横向对比
> 说明:下表除 iPolloWork 外的项目尚未正式收录进追踪库,仅作为**参照项**列出,数据未做逐项核实。
| 维度 | iPolloWork | 执行引擎(Codex / OpenCode / DSH) | 编辑器内 Agent(Cline / Roo / Kilo) |
| --- | --- | --- | --- |
| 站位层次 | 桌面工作台层 | 执行引擎层 | 编辑器内 Agent 层 |
| 引擎立场 | 多引擎中立 | 自己就是引擎 | 通常绑定单一或少数模型 |
| 交付物纵深 | 代码→文档→PPT→网站→设计→视频,均可续改 | 以代码为主 | 以代码为主 |
| 人机协作 | 审批式,团队共看一张项目视图 | 多为单人命令行 | 单人、编辑器内 |
| 许可证 | L3 source-available | 多为 MIT / Apache | 多为 MIT / Apache |
| 成熟度 | beta(v0.50.x) | 相对成熟 | 相对成熟 |
一句话概括差异:**引擎层解决"怎么跑",编辑器层解决"在哪写",iPolloWork 想解决"跑完之后一堆人和一堆产出物怎么管"。**
---
## 6. 宣称 vs 实测
这一节是本系列最看重的部分。项目方基本没说谎,但有几处没说全。
**① 宣称:兼容 Codex、DeepSeek Harness、OpenCode 三大引擎。**
**实际**:三者接入深度不对等。OpenCode 与 DSH 走 Engine Protocol 原生 adapter;Codex 只通过 `ipollowork-ui-mcp` 的 MCP 控制面接入,README 自己否认这是原生 Codex engine adapter。
**来源**:仓库 README 的 *Architecture boundary* 一节原文。
**② 宣称:集成 DeepSeek Harness 用于子代理委派。**
**实际**:第三方插件目录标注为「integration in active development, **not yet in the stable release**」;且 DSH 本身处于 developer preview 阶段。功能方向成立,但尚未落地到稳定版。
**来源**:deepseekharnessplugins.com 插件页;README 关于 DSH developer preview 的说明。
**③ 宣称:8 小时完成 Codex Harness 适配,登顶相关主题榜第一。**
**实际**:响应速度属实,有第三方报道佐证。但"适配"指的是打通 MCP 控制面路径,不等于原生引擎级集成——**快是因为走控制面,深度也受限于控制面**。这两件事是一体两面。
**来源**:ME News 报道;README 关于 Codex 接入方式的表述。
**④ 宣称:本地优先、不连云也能完整运行。**
**实际**:基本成立。iPolloCloud 确实可选。但默认本地 runtime 是 OpenCode sidecar,首次桌面构建时需联网下载;Orchestrator sidecar 也由 iPolloWork 侧编排。
**来源**:README 架构边界与安装章节。
**⑤ 宣称:统一的插件与 Skills 生态。**
**实际**:统一的是生命周期,不是能力。各 runtime 保留自己的 agents、Skills、插件与执行模型。
**来源**:README 的 *Agent runtime compatibility* 一节。
**⑥ 宣称:仓库 topics 里标了 `claude-code`。**
**实际**:中英文 README 均未描述任何 Claude Code 接入方式。这个 topic 更像搜索曝光策略,不应视为已支持的能力。
**来源**:GitHub topics 与 README 全文对照。
**⑦ 宣称:企业级(enterprise-grade)。**
**实际**:版本号仍在 0.50.x,无公开 Roadmap,发布节奏为**一天多版**,仓库体积 **591 MB**。工程投入相当可观,但版本语义上离"企业级稳定"还有距离。
**来源**:GitHub releases;README 版本说明;仓库 size 字段。
第 ⑦ 条值得展开一下。一天发好几版可以是"迭代快",也可以是"在追 bug"。结合 591 MB 的仓库体积和 80 个 open issues 看,我倾向于认为它正处在**高速冲刺期**——这个阶段的产品,功能边界和稳定性都在剧烈变动。
---
## 7. 结论:什么人该用它
**该用:**
- 想在同一个界面里管理多引擎项目、不想被某个 runtime 绑死的个人开发者
- 需要 agent 产出的 PPT / 网页 / 设计 / 视频**之后还能继续手工修改**的人
- 少于 3 人的极小团队(在许可证免费范围内试用)
**不该用:**
- **3 人以上需要正式落地的团队**——许可证明确要求书面授权,别等用到一半才发现
- **期待 Codex 原生级集成的用户**——目前只有 MCP 控制面
- **追求稳定的生产环境**——v0.50.x、一天多版、DSH 集成未进稳定版,这三个叠在一起风险不低
- **想要完全自主可控的团队**——工作台层本身是闭源商业产品,资产沉淀在它的项目模型里,退出成本高于纯 CLI 方案
**再看看:** 等三件事落地再评估——① DSH 集成进稳定版 ② Codex 从 MCP 控制面升级到原生 adapter ③ 版本号进 1.0。
---
## 8. 追踪备注
- **数据日期**:2026-09-01(GitHub API 快照)
- **指标**:★ 5201 / fork 976 / issues 80 / watchers 640 / 591 MB / TypeScript 49.5%
- **最新版本**:v0.50.12(2026-08-31),节奏为一天多版
- **源码镜像**:`gitinbox/iPolloWork`(**私有**,因 L3 source-available 许可证)
- **结构化档案**:`data/projects/ipollowork.yaml`
**下次跟进点:**
1. DSH 子代理委派是否进入稳定版(当前第三方标注为 in active development)
2. Codex 是否从 MCP 控制面升级为原生 Engine Protocol adapter
3. 许可证条款是否调整——3 人门槛与白标限制是观察其商业化走向的窗口
---
<sub>本文为「全球智能体开源应用追踪」系列之一 · 总纲见系列索引 · 数据与脚本开源于 `gitinbox/agent-radar`</sub>
+167
View File
@@ -0,0 +1,167 @@
# iPolloWork · 项目档案
# 生成于 2026-09-01 · 由 scripts/new_project.py 从 schema 生成,人工补完
# 字段规范见 schema/project.schema.yaml
id: ipollowork
name: "iPolloWork"
repo: "Devin-AXIS/iPolloWork"
homepage: "https://www.ipollo.ai/"
added_at: "2026-09-01"
status: active
identity:
tagline: "Enterprise-grade, local-first Agent Workbench for people and agent teams —— 面向人与智能体团队的企业级、本地优先 Agent 工作台"
vendor: "Devin-AXIS(官网 ipollo.ai;具体法人主体未在仓库中披露)"
vendor_type: startup
region: "推测中国(有中文 X 账号 @iPolloCN 与官方微信群),未在仓库中明确披露"
first_seen: "2025-08-25"
business:
positioning: "站在 Agent 工具链的**工作台层**而非引擎层:不替代 Codex / DSH / OpenCode 中任何一个,而是把它们收进同一个项目空间,并把交付物从代码延伸到文档、演示稿、网站、设计与视频"
target_users:
- "企业研发团队"
- "需要人机协作的 agent 团队"
- "需要产出并持续修改 PPT / 网页 / 设计 / 视频的内容团队"
monetization: "双轨:个人自用与少于 3 人的小规模内部使用免费;3 人及以上、以及任何售卖 / 转售 / 付费服务 / SaaS / 托管 / 白标 / 市场分发 / 面向客户的使用,均需书面授权。配套 iPolloCloud 承载身份、组织、权益与商业 App"
license:
spdx: "NOASSERTION"
name: "iPolloWork Source Available License 1.0"
openness: L3
redistributable: false
key_restrictions:
- "3 名及以上用户的任何形式使用(不限个人/企业/商业/非商业)均需事先书面授权"
- "任何售卖、转售、收费服务、SaaS、托管、白标、市场分发或面向客户的使用均需事先书面授权"
- "面向用户的前端展示必须保留 iPolloWork 名称、Logo 与产品归属,除非书面授权允许换品牌"
- "第三方组件与历史上按 MIT 发布的部分保留原许可证与既有权利"
- "项目方自述:这是源码可用协议,不是 OSI 认可的开源协议"
moat: "引擎中立 + 交付物可编辑。把项目资产(插件、Skills、任务、成果)沉淀在工作台层而非某个具体 runtime 上,引擎怎么换,工作台不动"
competitive_risk: "两头受挤:下层 OpenCode / Codex 可能自建工作台;上层 IDE 厂商(Cursor、Windsurf 一类)可能向上长成工作台。中立是护城河,也是没有独占能力的另一种说法"
scenario:
primary:
- "多引擎统一项目管理"
- "从代码延伸到文档 / PPT / 网站 / 设计 / 视频的可编辑生产"
- "人机协作的任务审批与进度追踪"
typical_use_cases:
- "同一项目里同时用 OpenCode 与 DSH,任务状态与流式输出统一在工作台呈现,而不是散在多个终端会话里"
- "让 agent 产出一份 PPT / 网页 / 视频后,人工继续改文字、图片、布局、时间线与场景,而不是只拿到一个成品文件"
- "团队在同一项目视图里查看职责、任务、日程与执行健康度,并逐项批准 agent 的动作"
- "DeepSeek Harness 用户通过 npm 插件把 iPolloWork 的 Design / PPT / Video 视图装进 DSH Web 界面"
industries:
- "软件研发"
- "企业内容生产"
- "设计与演示稿制作"
interaction_mode:
- desktop-app
human_in_the_loop: approval-gated
deliverables:
- code
- doc
- ppt
- website
- design
- video
tech:
form_factor: "Electron 桌面应用(另支持 dev:ui 只起浏览器 UI)"
stack:
- TypeScript
- React
- Electron
- "Node.js 22+"
- "pnpm 11(corepack)"
- "Bun 1.3.10+(构建 Orchestrator sidecar)"
- "OpenCode sidecar"
- "Orchestrator sidecar"
engine_abstraction:
supported:
- OpenCode
- DeepSeek Harness
- Codex
mechanism: "共享 engine boundary:agent 执行、任务状态与流式输出在统一边界规范化,引擎原生行为留在各自 adapter 内。可移植的 Skills / 插件 / MCP servers / 项目能力共用一个生命周期,引擎专属增强保持可选"
abstraction_layer: "Engine Protocol(经 local API);Codex 与 MCP 客户端另经 ipollowork-ui-mcp 控制面接入"
depth_notes: "**三种引擎的接入深度并不对等**:OpenCode 是默认本地执行 runtime,走 local API → Engine Protocol 的原生 adapter,以独立 sidecar 形式存在(首次桌面构建时下载,不 fork、可独立升级);DeepSeek Harness 是可选 peer runtime 与子代理委派目标,同样走 Engine Protocol;Codex 则**仅**通过 ipollowork-ui-mcp 的 MCP 控制面接入,README 明确写明 currently uses the MCP control surface rather than claiming a native Codex engine adapter"
extension_system:
skills: "可移植 Skills 纳入全局扩展系统,一次安装 / 启用 / 更新 / 卸载;各 runtime 保留自己的 Skills 体系"
plugins: "插件、agents、commands、services、authorization 与 Skills 共用同一生命周期;external-plugins/ 目录独立发布面向外部 agent host 的插件"
mcp: "MCP servers 纳入统一生命周期;MCP 在 Codex 路径上是集成协议而非并列的第四个引擎"
model_binding: byom
local_first: true
multi_agent: "项目内多 agent 任务编排;一个任务可把边界明确的工作委派给 DSH 子代理,再把结构化进度与结果带回同一项目"
permission_sandbox: "团队审阅进度并逐项批准动作;可审阅权限与执行;authorization 纳入扩展生命周期统一管理;仓库含 SECURITY.md 与 docs/governance.md"
observability: "统一项目视图呈现职责、任务、日程、执行健康度与流式执行数据"
ecosystem:
metrics:
stars: 5201
forks: 976
open_issues: 80
subscribers: 640
created_at: 2025-08-25
pushed_at: 2026-08-31
size_kb: 604967
languages: {TypeScript: 49.5, HTML: 20.8, JavaScript: 20.2, CSS: 7.4, MDX: 0.9, Swift: 0.6}
latest_release: v0.50.12
release_cadence: 一天多版
data_date: 2026-09-01
stale: false
community: "X 主账号 @iPolloWork 约 7.9K、中文账号 @iPolloCN 约 3.4K;设官方微信群;GitHub 上 640 watchers、80 open issues。Trendshift TypeScript 日榜 #22 / 周榜 #24"
standards:
- "OPEA(Open Platform for Enterprise AI,Linux Foundation 旗下)"
- "OpenSSF Best Practices(project 14127)"
- "Cloudflare for Startups"
notable_events:
- "2025-08-25 仓库创建"
- "2026-08-21 Codex Harness 开放后约 8 小时完成引擎级适配,据称登顶 GitHub codex-plugin 主题榜第一(当时约 4400 stars)"
- "此前 DeepSeek Harness 开放后约 10 小时完成适配,据称进入相关社区插件榜前二"
- "2026-08-31 发布 v0.50.12,为最近一次 push"
risk:
license_risk: "L3 级源码可用协议。3 人以上团队无论用途都需书面授权,且明确禁止未经授权的 SaaS / 托管 / 白标 / 市场分发。这意味着:企业落地前必须先谈授权,而源码镜像也只能是私有的"
vendor_lockin: "引擎层是中立了,但工作台层本身是闭源商业产品。项目资产沉淀在 iPolloWork 的项目模型里,退出成本高于纯 CLI 方案;一旦停止授权,已建的工作流难以平移"
claim_vs_reality:
- claim: "兼容 Codex、DeepSeek Harness、OpenCode 三大引擎"
actual: "三者接入深度不对等。OpenCode 与 DSH 走 Engine Protocol 原生 adapter;Codex 只通过 ipollowork-ui-mcp 的 MCP 控制面接入,README 自己否认这是原生 Codex engine adapter"
source: "仓库 README 的 Architecture boundary 一节原文"
- claim: "集成 DeepSeek Harness 用于子代理委派"
actual: "第三方插件目录标注为 integration in active development, not yet in the stable release;且 DSH 本身处于 developer preview 阶段。功能方向成立,但尚未进入稳定版"
source: "deepseekharnessplugins.com 插件页;README 关于 DSH developer preview 的说明"
- claim: "8 小时完成 Codex Harness 适配,登顶相关主题榜第一"
actual: "响应速度属实且有第三方报道佐证,但「适配」指的是打通 MCP 控制面路径,不等于原生引擎级集成。快是因为走控制面,深度也受限于控制面"
source: "ME News 报道;README 关于 Codex 接入方式的表述"
- claim: "本地优先、不连云也能完整运行"
actual: "这一点基本成立:iPolloCloud 明确为可选,负责身份、组织、权益、托管 worker 与商业 App。但默认本地 runtime 是 OpenCode sidecar,首次桌面构建时需联网下载,且开发模式与 Orchestrator sidecar 都由 iPolloWork 侧编排"
source: "README 架构边界与安装章节"
- claim: "统一的插件与 Skills 生态"
actual: "统一的是**生命周期**(安装/启用/更新/卸载一次),不是能力本身。README 明确各 runtime 保留自己的 agents、Skills、插件与执行模型,引擎专属增强只是可选"
source: "README 的 Agent runtime compatibility 一节"
- claim: "仓库 topics 标注 claude-code"
actual: "中英文 README 均未描述任何 Claude Code 接入方式。topic 更像搜索曝光策略,不应视为已支持的能力"
source: "GitHub topics 与 README 全文对照"
- claim: "企业级(enterprise-grade)"
actual: "版本号仍在 0.50.x,无公开 Roadmap,发布节奏为一天多版,仓库体积 591 MB。工程投入可观,但版本语义上尚未到企业可用的稳定态"
source: "GitHub releases;README 版本说明;仓库 size 字段"
maturity: beta
chart:
form_depth: 9
engine_neutral: 9
local_first_score: 9
deliverable_depth: 9
verdict:
one_liner: "把多个 Agent 引擎收进同一个可编辑工作台的野心之作——方向踩在点上,但三种引擎的接入深度并不齐整,且许可证决定了它暂时只适合个人或极小团队试用"
who_should_use: "想在同一个界面管理多引擎项目、并希望 agent 产出的 PPT / 网页 / 设计 / 视频之后还能继续手工修改的个人开发者,或少于 3 人的极小团队"
who_should_not: "3 人以上需要正式落地的团队(许可证要求书面授权)、期待 Codex 原生级集成的用户(目前只有 MCP 控制面)、以及追求稳定的生产环境(v0.50.x、一天多版、DSH 集成未进稳定版)"
mirror:
gitea_repo: "gitinbox/iPolloWork"
visibility: private
upstream: "https://github.com/Devin-AXIS/iPolloWork"
last_sync: ""
sync_note: "L3 source-available,镜像强制私有;仓库 591 MB,全量裸仓镜像"
article:
emlog_id: 97
published_at: ""
url: ""
+10
View File
@@ -0,0 +1,10 @@
# 收录总表 —— 驱动 fetch_stats.py 批量抓取
# 新增项目时在此登记,id 需与 data/projects/<id>.yaml 一致。
projects:
- id: ipollowork
repo: Devin-AXIS/iPolloWork
status: active
added_at: 2026-09-01
license_level: L3 # source-available,镜像私有
mirror_visibility: private
+1
View File
@@ -0,0 +1 @@
{"stars": 5201, "forks": 976, "open_issues": 80, "subscribers": 640, "created_at": "2025-08-25", "pushed_at": "2026-08-31", "size_kb": 604967, "languages": {"TypeScript": 49.5, "HTML": 20.8, "JavaScript": 20.2, "CSS": 7.4, "MDX": 0.9, "Swift": 0.6}, "latest_release": "v0.50.12", "release_cadence": "一天多版", "data_date": "2026-09-01", "stale": false}
+81
View File
@@ -0,0 +1,81 @@
# ============================================================
# 许可证成色分级 → 镜像可见性规则
# ------------------------------------------------------------
# 这是新增项目时的**第一判断项**:先定级,再决定源码镜像能不能公开。
# 定错级的代价:L3 项目被公开镜像,等于替对方做了一次未经授权的公开分发。
#
# 判定顺序自上而下,命中即停。
# ============================================================
tiers:
- level: L1
label: 完全开源
redistributable: true
visibility: public
match_spdx:
- MIT
- Apache-2.0
- BSD-2-Clause
- BSD-3-Clause
- ISC
- MPL-2.0
- GPL-2.0
- GPL-3.0
- LGPL-3.0
- Unlicense
note: "可自由镜像与再分发,保留原许可证声明即可。"
- level: L2
label: 有限开源
redistributable: true
visibility: public
match_spdx:
- AGPL-3.0
- SSPL-1.0
- BUSL-1.1
- Elastic-2.0
note: "有附加限制(网络使用即触发开源义务、或限制商业化托管)。可公开镜像,但档案中必须记录限制条款。"
- level: L3
label: 源码可得 · 非开源
redistributable: false
visibility: private
match_spdx:
- NOASSERTION
- OTHER
- LicenseRef-scanned
note: |
SPDX 无法识别的自定义许可,典型为 source-available。
必须人工读 LICENSE 原文确认,再决定是否归 L3 或 L4。
判为 L3 时镜像**一律私有**,仅作个人代码级研究,不构成公开分发。
- level: L4
label: 不可得
redistributable: false
visibility: none
match_spdx:
- ""
- null
note: "无 LICENSE 文件或明确 proprietary。不镜像,档案里只留仓库链接与 commit 快照。"
# ---------- 人工复核触发条件 ----------
# 命中以下任一情形,脚本必须停下来要求人工确认,不得自动定级:
manual_review_triggers:
- "SPDX 为 NOASSERTION 且 LICENSE 文件超过 200 行(条款复杂,容易漏掉分发限制)"
- "同一仓库存在多个 LICENSE 文件(如 LICENSE + LICENSES/ 目录,可能混合许可)"
- "README 自称 open source 但 SPDX 不是 OSI 认证标识(名实不符,需显式记录)"
- "LICENSE 中出现 SaaS / hosting / white-label / marketplace / resale 任一关键词"
# ---------- 已定级样本(供后续同类项目参照) ----------
precedents:
- repo: Devin-AXIS/iPolloWork
level: L3
visibility: private
license_name: "iPolloWork Source Available License 1.0"
decided_at: 2026-09-01
reason: |
条款原文要求:任何售卖、转售、收费服务、SaaS、托管、白标、市场分发或
面向客户的使用,无论个人还是企业,均需事先书面授权;且面向用户的前端
必须保留 iPolloWork 名称、Logo 与产品归属。公开镜像落入「托管/市场分发」
的解释空间,故设为私有。
+148
View File
@@ -0,0 +1,148 @@
# ============================================================
# agent-radar · 项目档案 Schema
# ------------------------------------------------------------
# 本文件有双重身份:
# 1) 人类阅读的填写规范(desc / note 字段)
# 2) scripts/new_project.py 的机器可读骨架生成源
# 改结构时两处要同步,否则脚本生成的空档案会缺字段。
#
# 五维度设计意图:
# 业务(business) — 它靠什么活着、为谁活着
# 场景(scenario) — 它在什么场合被用、产出什么
# 技术(tech) — 它怎么做到的、抽象在哪一层
# 生态(ecosystem) — 它的社区动能(多数字段由脚本自动刷新)
# 风险(risk) — 它的坑在哪、宣称与实测的落差
# 其中 risk.claim_vs_reality 是全套 schema 里最不该留空的字段:
# 它逼着每篇分析去核实「项目方宣称 vs 实际状态」,防止文章退化成 README 复读。
# ============================================================
version: 1
# ---------- 枚举值 ----------
enums:
status: [active, watch, archived] # active=持续追踪 watch=观察中 archived=停更归档
openness: [L1, L2, L3, L4] # 许可证成色分级,详见 license-tiers.yaml
vendor_type: [startup, bigtech, community, solo, unknown]
interaction_mode: [cli, ide-plugin, desktop-app, web-app, api, editor-fork]
human_in_the_loop: [autonomous, approval-gated, supervised, manual]
maturity: [concept, preview, beta, stable, mature]
model_binding: [locked, preferred, byom, neutral] # locked=仅自家模型 byom=自带模型 neutral=完全中立
visibility: [public, private, none]
# ---------- 字段定义 ----------
# type 用于脚本生成占位值:str->"" int->0 bool->false list->[] dict->{}
fields:
# ===== 顶层标识 =====
id: {type: str, required: true, desc: "档案唯一标识,小写连字符,如 ipollowork"}
name: {type: str, required: true, desc: "项目显示名"}
repo: {type: str, required: true, desc: "GitHub 仓库 owner/name"}
homepage: {type: str, required: false, desc: "官网"}
added_at: {type: str, required: true, desc: "收录日期 YYYY-MM-DD"}
status: {type: str, required: true, enum: status, desc: "追踪状态"}
# ===== 身份层 =====
identity:
tagline: {type: str, required: true, desc: "一句话定位,优先用官方原文"}
vendor: {type: str, required: false, desc: "背后公司/组织"}
vendor_type: {type: str, required: false, enum: vendor_type}
region: {type: str, required: false, desc: "团队所在地,如 中国/美国/欧洲"}
first_seen: {type: str, required: false, desc: "首次公开时间"}
# ===== 业务维度 =====
business:
positioning: {type: str, required: true, desc: "它在产业链里到底站在哪一层"}
target_users: {type: list, required: true, desc: "目标用户群"}
monetization: {type: str, required: true, desc: "变现方式:捐赠/商业授权/SaaS/双轨/未定"}
license:
spdx: {type: str, required: true, desc: "SPDX 标识,如 MIT / Apache-2.0 / NOASSERTION"}
name: {type: str, required: true, desc: "许可证实际名称"}
openness: {type: str, required: true, enum: openness, desc: "成色分级 L1-L4"}
redistributable: {type: bool, required: true, desc: "可否公开再分发 —— 直接决定镜像可见性"}
key_restrictions: {type: list, required: false, desc: "关键限制条款原文要点"}
moat: {type: str, required: false, desc: "护城河/不可轻易复制的部分"}
competitive_risk: {type: str, required: false, desc: "最容易被谁替代、为什么"}
# ===== 场景维度 =====
scenario:
primary: {type: list, required: true, desc: "核心场景"}
typical_use_cases: {type: list, required: true, desc: "典型用例,写给读者看的具体例子"}
industries: {type: list, required: false, desc: "行业落点"}
interaction_mode: {type: list, required: true, enum: interaction_mode, desc: "交互形态,可多选"}
human_in_the_loop: {type: str, required: true, enum: human_in_the_loop, desc: "人机协作模式"}
deliverables: {type: list, required: true, desc: "交付物类型:code/doc/ppt/website/design/video/data/report"}
# ===== 技术维度 =====
tech:
form_factor: {type: str, required: true, desc: "产品形态:CLI/IDE插件/桌面应用/Web平台/库"}
stack: {type: list, required: true, desc: "技术栈"}
engine_abstraction:
supported: {type: list, required: false, desc: "支持的 agent 引擎/runtime"}
mechanism: {type: str, required: false, desc: "统一接入机制描述"}
abstraction_layer: {type: str, required: false, desc: "抽象层名称,如 Engine Protocol"}
depth_notes: {type: str, required: false, desc: "**关键**:各引擎接入深度是否对等,逐个说明"}
extension_system:
skills: {type: str, required: false, desc: "Skills 支持情况"}
plugins: {type: str, required: false, desc: "插件机制"}
mcp: {type: str, required: false, desc: "MCP 支持方式"}
model_binding: {type: str, required: true, enum: model_binding, desc: "模型绑定程度"}
local_first: {type: bool, required: true, desc: "是否可完全本地运行"}
multi_agent: {type: str, required: false, desc: "多 Agent 编排方式"}
permission_sandbox: {type: str, required: false, desc: "权限审批与沙箱机制"}
observability: {type: str, required: false, desc: "执行过程可观测性"}
# ===== 生态维度(metrics 由 fetch_stats.py 自动刷新,勿手填) =====
ecosystem:
metrics:
stars: {type: int, auto: true}
forks: {type: int, auto: true}
open_issues: {type: int, auto: true}
subscribers: {type: int, auto: true}
created_at: {type: str, auto: true}
pushed_at: {type: str, auto: true}
size_kb: {type: int, auto: true}
languages: {type: dict, auto: true}
latest_release: {type: str, auto: true}
release_cadence: {type: str, auto: true}
data_date: {type: str, auto: true, desc: "本组数据的抓取日期"}
stale: {type: bool, auto: true, desc: "true=本次抓取失败,沿用上次快照"}
community: {type: str, required: false, desc: "社区形态与活跃度"}
standards: {type: list, required: false, desc: "采纳/挂靠的标准与联盟,如 OPEA/OpenSSF"}
notable_events: {type: list, required: false, desc: "关键事件与时间点"}
# ===== 风险维度 =====
risk:
license_risk: {type: str, required: true, desc: "许可证带来的使用/分发风险"}
vendor_lockin: {type: str, required: false, desc: "供应商锁定点"}
# 结构化三项式:claim / actual / source。不用裸字符串,
# 因为 build_matrix.py 要把它渲染成表格,且缺 source 的断言无法复核。
claim_vs_reality: {type: list, required: true, desc: "**必填**:每项为 {claim, actual, source} 三项,写清 宣称/实际/证据来源"}
maturity: {type: str, required: true, enum: maturity}
# ===== 象限图坐标(0-10,人工评定,供 build_matrix.py 出图) =====
# 评分是主观判断,但一旦统一口径,横向比较就成立了。
# 评完请回头看一眼:如果所有项目都打 9 分,说明这套轴失去了区分度。
chart:
form_depth: {type: int, required: false, desc: "形态纵深:0=单点工具 10=统一工作台"}
engine_neutral: {type: int, required: false, desc: "引擎立场:0=绑定单引擎 10=多引擎中立"}
local_first_score: {type: int, required: false, desc: "本地优先:0=强云依赖 10=完全本地可跑"}
deliverable_depth: {type: int, required: false, desc: "交付物纵深:0=只出代码 10=多模态可编辑产出"}
# ===== 结论 =====
verdict:
one_liner: {type: str, required: true, desc: "一句话结论,进总纲索引表"}
who_should_use: {type: str, required: true}
who_should_not: {type: str, required: true}
# ===== 镜像与文章回写 =====
mirror:
gitea_repo: {type: str, required: false, desc: "gitinbox 下的镜像仓,如 gitinbox/iPolloWork"}
visibility: {type: str, required: false, enum: visibility}
upstream: {type: str, required: false}
last_sync: {type: str, required: false}
sync_note: {type: str, required: false, desc: "如体积过大改用 treeless clone 等降级说明"}
article:
emlog_id: {type: int, required: false, desc: "emlog 文章 ID,发布后回写"}
published_at: {type: str, required: false}
url: {type: str, required: false}
+215
View File
@@ -0,0 +1,215 @@
"""agent-radar 共享基础库:GitHub API 封装 + YAML 精确回写。
设计要点
--------
1. GitHub API 优先使用 GITHUB_TOKEN,缺失时匿名降级(限额 60 次/小时)。
2. YAML 回写采用**行级精确替换**而不是 yaml.dump —— dump 会冲掉档案里的
所有注释和字段顺序,而档案是靠注释承载"为什么这么填"的。
"""
from __future__ import annotations
import json
import os
import re
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "data"
PROJECTS = DATA / "projects"
SNAPSHOTS = DATA / "snapshots"
ARTICLES = ROOT / "articles"
SCHEMA = ROOT / "schema"
GH_API = "https://api.github.com"
TOKEN = os.environ.get("GITHUB_TOKEN", "").strip()
_last_call = 0.0
# ---------------------------------------------------------------- GitHub API
def gh_get(path: str, timeout: int = 25, retries: int = 2) -> dict | list | None:
"""GET GitHub API。失败返回 None(调用方负责降级)。
内置限速:匿名 1.5s 间隔、带 token 0.3s 间隔,避免触发 403 限流。
retries 是必要的:releases 这类接口响应体可达数百 KB,
偶发 IncompleteRead(连接提前关闭)。不加重试会静默丢掉版本节奏数据,
而"样本不足"和"真的没发版"在结果上无法区分。
"""
global _last_call
url = f"{GH_API}/{path.lstrip('/')}"
req = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "agent-radar",
**({"Authorization": f"Bearer {TOKEN}"} if TOKEN else {}),
},
)
last_err = None
for attempt in range(retries + 1):
wait = 0.3 if TOKEN else 1.5
gap = time.time() - _last_call
if gap < wait:
time.sleep(wait - gap)
_last_call = time.time()
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
# 4xx 是确定性失败,重试无意义
if e.code == 403:
print(f" [warn] GitHub API 限流或禁止访问 (403): {path}")
elif e.code == 404:
print(f" [warn] 资源不存在 (404): {path}")
else:
print(f" [warn] HTTP {e.code}: {path}")
return None
except Exception as e: # IncompleteRead / 超时 / 连接重置
last_err = e
if attempt < retries:
time.sleep(1.5 * (attempt + 1))
continue
print(f" [warn] 请求失败(已重试 {retries} 次): {path} -> {last_err}")
return None
# ---------------------------------------------------------------- YAML 读写
def load_yaml(path) -> dict:
"""接受 str 或 Path —— 调用方传字符串时不应该炸。"""
p = Path(path)
return yaml.safe_load(p.read_text(encoding="utf-8")) or {}
def today() -> str:
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d")
def _fmt(v) -> str:
"""把 Python 值渲染成 YAML 标量文本。"""
if isinstance(v, bool):
return "true" if v else "false"
if v is None:
return '""'
if isinstance(v, (int, float)):
return str(v)
if isinstance(v, dict):
inner = ", ".join(f"{k}: {_fmt(x)}" for k, x in v.items())
return "{" + inner + "}"
if isinstance(v, list):
inner = ", ".join(_fmt(x) for x in v)
return "[" + inner + "]"
s = str(v)
# 含冒号空格、井号、引号或为空的字符串需要加引号
if s == "" or re.search(r'[:#\n"]|^\s|\s$', s) or re.match(r"^[-?*&!|>%@`]", s):
return json.dumps(s, ensure_ascii=False)
return s
def update_block(text: str, block_key: str, values: dict, indent: int | None = None) -> str:
"""在 YAML 文本里定位 `<block_key>:` 块,替换其中的键值。
只改块内已有键的值;块内没有而 values 里有的,追加到块末尾。
块外内容(含所有注释)原样保留。
indent 传 None 时**自动探测**第一个同名块 —— 推荐用这个。
硬编码缩进是很脆的:schema 一改层级,块的实际缩进就变了,
而失败表现只是"静默没写进去",很容易被忽略。
"""
lines = text.split("\n")
# `(\s*)` 而不是 `(\s+)`:顶层块的缩进是 0,用 `+` 会匹配不到
# (article: 就是顶层块,这个坑踩过一次)
pat = re.compile(rf"^(\s*){re.escape(block_key)}:\s*$")
start = None
base = None
for i, ln in enumerate(lines):
m = pat.match(ln)
if m and (indent is None or len(m.group(1)) == indent):
start, base = i, len(m.group(1))
break
if start is None:
raise ValueError(f"未找到 `{block_key}:` 块(indent={indent})")
# 块的结束:下一个缩进 <= base 的非空、非注释行
end = len(lines)
for j in range(start + 1, len(lines)):
ln = lines[j]
if not ln.strip() or ln.lstrip().startswith("#"):
continue
if len(ln) - len(ln.lstrip()) <= base:
end = j
break
child = base + 2
seen: set[str] = set()
for k in range(start + 1, end):
m = re.match(rf"^(\s{{{child},}})([A-Za-z_][\w-]*):\s*(.*)$", lines[k])
if not m:
continue
key = m.group(2)
if key in values:
lines[k] = f"{' ' * child}{key}: {_fmt(values[key])}"
seen.add(key)
missing = [f"{' ' * child}{k}: {_fmt(v)}" for k, v in values.items() if k not in seen]
if missing:
lines[end:end] = missing
return "\n".join(lines)
# ---------------------------------------------------------------- 快照
def snapshot_path(pid: str) -> Path:
return SNAPSHOTS / f"{pid}.jsonl"
def append_snapshot(pid: str, metrics: dict) -> None:
"""追加一行快照。同一天重复运行会覆盖当天那行(保持"一天一个点")。"""
SNAPSHOTS.mkdir(parents=True, exist_ok=True)
p = snapshot_path(pid)
day = metrics.get("data_date") or today()
rows = []
if p.exists():
rows = [r for r in p.read_text(encoding="utf-8").splitlines() if r.strip()]
rows = [r for r in rows if json.loads(r).get("data_date") != day]
rows.append(json.dumps(metrics, ensure_ascii=False))
p.write_text("\n".join(rows) + "\n", encoding="utf-8")
def last_snapshot(pid: str) -> dict | None:
p = snapshot_path(pid)
if not p.exists():
return None
rows = [r for r in p.read_text(encoding="utf-8").splitlines() if r.strip()]
return json.loads(rows[-1]) if rows else None
# ---------------------------------------------------------------- 许可证分级
def classify_license(spdx: str, license_name: str = "") -> dict:
"""按 schema/license-tiers.yaml 判定成色分级与镜像可见性。
返回 {level, redistributable, visibility}。
无法识别时返回 L3(保守策略:宁可私有,不可误公开)。
"""
tiers = load_yaml(SCHEMA / "license-tiers.yaml").get("tiers", [])
key = (spdx or "").strip()
for t in tiers:
match = [str(x) for x in t.get("match_spdx", []) if x is not None]
if key in match:
return {
"level": t["level"],
"redistributable": bool(t.get("redistributable")),
"visibility": t.get("visibility", "private"),
}
# 未命中:按 L3 处理(源码可得但许可不明 → 不公开)
return {"level": "L3", "redistributable": False, "visibility": "private"}
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""从所有项目档案生成横向对比矩阵与赛道图,回填到总纲文。
用法
----
python3 scripts/build_matrix.py
python3 scripts/build_matrix.py --stdout # 只打印,不写文件
输出的两张图
------------
视图 A:形态纵深(x:单点工具 → 统一工作台)× 引擎立场(y:绑定单引擎 → 多引擎中立)
视图 B:本地优先(x:强云依赖 → 完全本地)× 交付物纵深(y:只出代码 → 多模态可编辑)
阈值:项目数 <4 时**不出象限图**,改出赛道分层图。
原因:象限图的意义来自点的相对分布,只有一两个点时它传达的信息量
还不如一张分层图,却会让读者误以为已经建立了坐标系。
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _common import ARTICLES, PROJECTS, load_yaml # noqa: E402
QUAD_THRESHOLD = 4
BEGIN, END = "<!-- MATRIX:BEGIN -->", "<!-- MATRIX:END -->"
def cell(v, empty="-") -> str:
if v is None or v == "" or v == [] or v == {}:
return empty
if isinstance(v, bool):
return "是" if v else "否"
if isinstance(v, list):
return "、".join(str(x) for x in v) if v else empty
if isinstance(v, dict):
return "、".join(f"{k} {x}" for k, x in v.items()) if v else empty
return str(v).replace("|", "\\|").replace("\n", " ")
def load_projects() -> list[dict]:
out = []
for p in sorted(PROJECTS.glob("*.yaml")):
d = load_yaml(p)
if d:
d["_id"] = p.stem
out.append(d)
return out
# ---------------------------------------------------------------- 矩阵
def build_tables(items: list[dict]) -> str:
L = []
add = L.append
add("### 业务维度\n")
add("| 项目 | 定位 | 目标用户 | 变现 | 许可证 | 成色 |")
add("| --- | --- | --- | --- | --- | --- |")
for d in items:
b = d.get("business") or {}
lic = b.get("license") or {}
add(f"| **{d.get('name','')}** | {cell(b.get('positioning'))} | "
f"{cell(b.get('target_users'))} | {cell(b.get('monetization'))} | "
f"{cell(lic.get('name'))} | {cell(lic.get('openness'))} |")
add("\n### 场景维度\n")
add("| 项目 | 交互形态 | 人机协作 | 交付物 |")
add("| --- | --- | --- | --- |")
for d in items:
s = d.get("scene") or d.get("scenario") or {}
add(f"| **{d.get('name','')}** | {cell(s.get('interaction_mode'))} | "
f"{cell(s.get('human_in_the_loop'))} | {cell(s.get('deliverables'))} |")
add("\n### 技术维度\n")
add("| 项目 | 形态 | 引擎抽象 | 接入深度 | 模型绑定 | 本地优先 |")
add("| --- | --- | --- | --- | --- | --- |")
for d in items:
t = d.get("tech") or {}
ea = t.get("engine_abstraction") or {}
add(f"| **{d.get('name','')}** | {cell(t.get('form_factor'))} | "
f"{cell(ea.get('abstraction_layer'))} | {cell(ea.get('depth_notes'))} | "
f"{cell(t.get('model_binding'))} | {cell(t.get('local_first'))} |")
add("\n### 生态与风险\n")
add("| 项目 | Star | Fork | Issues | 最新 release | 节奏 | 成熟度 | 数据日期 |")
add("| --- | --- | --- | --- | --- | --- | --- | --- |")
for d in items:
m = (d.get("ecosystem") or {}).get("metrics") or {}
stale = " ⚠" if m.get("stale") else ""
add(f"| **{d.get('name','')}** | {cell(m.get('stars'))} | {cell(m.get('forks'))} | "
f"{cell(m.get('open_issues'))} | {cell(m.get('latest_release'))} | "
f"{cell(m.get('release_cadence'))} | {cell((d.get('risk') or {}).get('maturity'))} | "
f"{cell(m.get('data_date'))}{stale} |")
# 宣称 vs 实测:全套矩阵里信息密度最高的一张表
rows = []
for d in items:
for c in (d.get("risk") or {}).get("claim_vs_reality") or []:
if isinstance(c, dict):
rows.append((d.get("name", ""), c))
if rows:
add("\n### 宣称 vs 实测\n")
add("| 项目 | 宣称 | 实际 | 证据来源 |")
add("| --- | --- | --- | --- |")
for name, c in rows:
add(f"| **{name}** | {cell(c.get('claim'))} | {cell(c.get('actual'))} | "
f"{cell(c.get('source'))} |")
add("\n### 一句话结论\n")
add("| 项目 | 结论 | 适合 | 不适合 |")
add("| --- | --- | --- | --- |")
for d in items:
v = d.get("verdict") or {}
add(f"| **{d.get('name','')}** | {cell(v.get('one_liner'))} | "
f"{cell(v.get('who_should_use'))} | {cell(v.get('who_should_not'))} |")
return "\n".join(L)
# ---------------------------------------------------------------- 图
def stack_diagram(items: list[dict]) -> str:
"""项目 <4 个时的赛道分层图:按交互形态把项目归到三层栈。"""
layers = [
("执行引擎层", "真正跑 agent loop 的地方", ["cli"]),
("编辑器内 Agent 层", "嵌在 IDE / 编辑器里的交互层", ["ide-plugin", "editor-fork"]),
("桌面工作台层", "跨引擎、跨项目、跨交付物的统一界面", ["desktop-app", "web-app"]),
]
L = ["```text"]
L.append("┌─────────────────────────────────────────────────────────┐")
for name, desc, modes in layers:
hits = []
for d in items:
modes_ = (d.get("scenario") or {}).get("interaction_mode") or []
if any(m in modes_ for m in modes):
hits.append(f"{d.get('name')}★{(d.get('ecosystem',{}).get('metrics',{}) or {}).get('stars','-')}")
body = "、".join(hits) if hits else "(暂无收录)"
L.append(f"│ {name:<12} {body}")
L.append(f"│{'':<14}└ {desc}")
if name != layers[-1][0]:
L.append("│" + " " * 56 + "│")
L.append("└─────────────────────────────────────────────────────────┘")
L.append("```")
L.append(f"\n> 当前收录 {len(items)} 个项目,不足 {QUAD_THRESHOLD} 个,此阶段只出分层图。"
f"样本够了之后会自动切换为象限图。")
return "\n".join(L)
def quadrant_svg(items: list[dict]) -> str:
"""项目 >=4 个时的双视图象限图。"""
W, H, PAD = 720, 420, 70
plots = [
("视图 A · 形态纵深 × 引擎立场", "form_depth", "engine_neutral",
"单点工具", "统一工作台", "绑定单引擎", "多引擎中立"),
("视图 B · 本地优先 × 交付物纵深", "local_first_score", "deliverable_depth",
"强云依赖", "完全本地", "只出代码", "多模态可编辑"),
]
svg = []
for i, (title, xk, yk, xlo, xhi, ylo, yhi) in enumerate(plots):
ox = i * (W + 30)
svg.append(f'<svg viewBox="0 0 {W} {H}" width="{W}" height="{H}" '
f'xmlns="http://www.w3.org/2000/svg" style="font-family:sans-serif">')
svg.append(f'<text x="{W//2}" y="26" text-anchor="middle" font-size="15" '
f'font-weight="600" fill="#1f2328">{title}</text>')
px, py, pw, ph = PAD, 55, W - PAD * 2, H - PAD - 55
svg.append(f'<rect x="{px}" y="{py}" width="{pw}" height="{ph}" fill="#fafbfc" '
f'stroke="#d8dee4"/>')
# 网格
for k in range(1, 4):
gx = px + pw * k / 4
gy = py + ph * k / 4
svg.append(f'<line x1="{gx}" y1="{py}" x2="{gx}" y2="{py+ph}" stroke="#eaeef2"/>')
svg.append(f'<line x1="{px}" y1="{gy}" x2="{px+pw}" y2="{gy}" stroke="#eaeef2"/>')
# 轴标签
svg.append(f'<text x="{px}" y="{py+ph+22}" font-size="11" fill="#6a737d">{xlo}</text>')
svg.append(f'<text x="{px+pw}" y="{py+ph+22}" text-anchor="end" font-size="11" fill="#6a737d">{xhi}</text>')
svg.append(f'<text x="{px-8}" y="{py+ph+6}" text-anchor="end" font-size="11" fill="#6a737d">{ylo}</text>')
svg.append(f'<text x="{px-8}" y="{py+12}" text-anchor="end" font-size="11" fill="#6a737d">{yhi}</text>')
# 点
for d in items:
c = d.get("chart") or {}
xv, yv = c.get(xk), c.get(yk)
if xv is None or yv is None:
continue
cx = px + pw * max(0, min(10, float(xv))) / 10
cy = py + ph * (1 - max(0, min(10, float(yv))) / 10)
name = d.get("name", "")
svg.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="7" fill="#0969da" '
f'fill-opacity="0.75" stroke="#fff" stroke-width="1.5"/>')
svg.append(f'<text x="{cx+11:.1f}" y="{cy+4:.1f}" font-size="12" fill="#1f2328">{name}</text>')
svg.append("</svg>")
return "\n".join(svg)
# ---------------------------------------------------------------- 主流程
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--stdout", action="store_true", help="只打印不写文件")
args = ap.parse_args()
items = load_projects()
if not items:
print("data/projects/ 下没有任何档案。")
return 1
print(f"==> 载入 {len(items)} 个项目:{', '.join(d.get('name','') for d in items)}")
matrix = build_tables(items)
chart = quadrant_svg(items) if len(items) >= QUAD_THRESHOLD else stack_diagram(items)
block = f"{BEGIN}\n{matrix}\n\n{chart}\n{END}"
if args.stdout:
print(block)
return 0
path = ARTICLES / f"{'00-overview'}.md"
if not path.exists():
print(f"总纲文不存在:{path}")
return 1
text = path.read_text(encoding="utf-8")
if BEGIN not in text or END not in text:
print(f"总纲文缺少 {BEGIN} / {END} 占位标记,未回填。")
return 1
text = re.sub(rf"{re.escape(BEGIN)}.*?{re.escape(END)}", lambda _: block, text, flags=re.S)
path.write_text(text, encoding="utf-8")
print(f"==> 已回填总纲文:{path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""抓取被追踪项目的 GitHub 指标,回写档案并累积时间序列快照。
用法
----
python3 scripts/fetch_stats.py # 刷新 registry.yaml 里的全部项目
python3 scripts/fetch_stats.py --id ipollowork # 只刷新一个
python3 scripts/fetch_stats.py --force # 忽略当日缓存强制重抓
行为
----
· 优先读环境变量 GITHUB_TOKEN;没有则匿名(限额 60 次/小时),限速间隔自动放宽。
· 同一项目一天只抓一次(快照文件里已有当天记录就跳过),--force 可覆盖。
· 抓取失败时沿用上次快照数值,并把 metrics.stale 置为 true,
这样文章里的数字始终有出处,且能被识别出"这是旧数据"。
"""
from __future__ import annotations
import argparse
import re
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _common import ( # noqa: E402
DATA,
PROJECTS,
append_snapshot,
classify_license,
gh_get,
last_snapshot,
load_yaml,
today,
update_block,
)
def human_size(kb: int) -> str:
mb = kb / 1024
return f"{mb:.0f} MB" if mb >= 1 else f"{kb} KB"
def calc_cadence(releases: list) -> str:
"""从最近若干 release 的发布时间间隔估算版本节奏。"""
dates = []
for r in releases:
d = (r.get("published_at") or "")[:10]
if re.match(r"^\d{4}-\d{2}-\d{2}$", d):
dates.append(datetime.strptime(d, "%Y-%m-%d"))
if len(dates) < 2:
return "样本不足"
dates.sort(reverse=True)
spans = [(dates[i] - dates[i + 1]).days for i in range(len(dates) - 1)]
spans = [s for s in spans if s >= 0]
if not spans:
return "样本不足"
avg = sum(spans) / len(spans)
if avg < 1:
return "一天多版"
if avg < 2:
return "约 1 天/版本"
if avg < 8:
return f"约 {avg:.0f} 天/版本"
if avg < 40:
return f"约 {avg / 7:.1f} 周/版本"
return f"约 {avg / 30:.1f} 月/版本"
def fetch_one(pid: str, repo: str, force: bool) -> bool:
print(f"\n[{pid}] {repo}")
# ---- 当日缓存 ----
if not force:
prev = last_snapshot(pid)
if prev and prev.get("data_date") == today():
print(f" 今日已抓取过({today()}),跳过。用 --force 强制刷新。")
return True
# ---- 主仓库信息 ----
info = gh_get(f"repos/{repo}")
if not info:
print(" 主信息抓取失败,尝试沿用上次快照 …")
return fallback(pid)
lic = info.get("license") or {}
spdx = lic.get("spdx_id") or "NOASSERTION"
if spdx == "NOASSERTION" and not lic.get("key"):
spdx = ""
langs_raw = gh_get(f"repos/{repo}/languages") or {}
total = sum(langs_raw.values()) or 1
langs = {k: round(v / total * 100, 1) for k, v in sorted(langs_raw.items(), key=lambda x: -x[1])[:6]}
rels = gh_get(f"repos/{repo}/releases?per_page=8") or []
latest = ""
if rels:
latest = rels[0].get("tag_name") or ""
metrics = {
"stars": info.get("stargazers_count", 0),
"forks": info.get("forks_count", 0),
"open_issues": info.get("open_issues_count", 0),
"subscribers": info.get("subscribers_count", 0),
"created_at": (info.get("created_at") or "")[:10],
"pushed_at": (info.get("pushed_at") or "")[:10],
"size_kb": info.get("size", 0),
"languages": langs,
"latest_release": latest,
"release_cadence": calc_cadence(rels),
"data_date": today(),
"stale": False,
}
print(f" ★ {metrics['stars']} fork {metrics['forks']} issues {metrics['open_issues']}")
print(f" 最新 {latest} 节奏 {metrics['release_cadence']} 体积 {human_size(metrics['size_kb'])}")
print(f" 语言 {langs}")
print(f" 许可证 spdx={spdx or '(无)'}")
# ---- 快照 + 回写 ----
append_snapshot(pid, metrics)
ypath = PROJECTS / f"{pid}.yaml"
if ypath.exists():
text = ypath.read_text(encoding="utf-8")
try:
text = update_block(text, "metrics", metrics)
except ValueError as e:
print(f" [warn] 回写 metrics 失败:{e}")
# 许可证若尚未定级,顺手补上
if "openness: ~" in text or "openness: null" in text or 'openness: ""' in text:
cls = classify_license(spdx, lic.get("name", ""))
text = update_block(text, "license", {
"spdx": spdx or "NOASSERTION",
"name": lic.get("name") or "自定义许可",
"openness": cls["level"],
"redistributable": cls["redistributable"],
})
print(f" 自动定级:{cls['level']} → 镜像可见性 {cls['visibility']}")
ypath.write_text(text, encoding="utf-8")
else:
print(f" [warn] 档案不存在:{ypath},跳过回写")
return True
def fallback(pid: str) -> bool:
"""抓取失败:沿用上次快照并把 stale 标为 true。"""
prev = last_snapshot(pid)
ypath = PROJECTS / f"{pid}.yaml"
if not prev or not ypath.exists():
print(" 无历史快照可用,本次放弃。")
return False
prev = dict(prev)
prev["stale"] = True
text = ypath.read_text(encoding="utf-8")
try:
text = update_block(text, "metrics", prev)
ypath.write_text(text, encoding="utf-8")
print(f" 已沿用 {prev.get('data_date')} 的快照并标记 stale=true")
except ValueError as e:
print(f" [warn] 回写失败:{e}")
return False
return True
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--id", help="只刷新指定项目 id")
ap.add_argument("--force", action="store_true", help="忽略当日缓存")
args = ap.parse_args()
reg = load_yaml(DATA / "registry.yaml")
items = reg.get("projects", [])
if not items:
print("registry.yaml 里没有登记任何项目。")
return 1
targets = [p for p in items if not args.id or p.get("id") == args.id]
if not targets:
print(f"未找到 id={args.id}")
return 1
ok = sum(fetch_one(p["id"], p["repo"], args.force) for p in targets)
print(f"\n完成 {ok}/{len(targets)}")
return 0 if ok == len(targets) else 1
if __name__ == "__main__":
raise SystemExit(main())
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# 把被追踪项目的源码镜像到 gitinbox 组织,供代码级追踪。
#
# 用法:
# bash scripts/mirror_sync.sh --id ipollowork
# bash scripts/mirror_sync.sh --id ipollowork --depth 200 # 超大仓只取近期历史
#
# 可见性由档案的 business.license.redistributable 决定(L1/L2 → public,L3 → private)。
# 这条规则不能手工绕过:把 source-available 项目公开镜像,等于替对方做了一次
# 未经授权的公开分发。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GITEA_URL="${GITEA_URL:-https://gitea.apescale.com}"
DISPLAY_URL="${GITEA_DISPLAY_URL:-https://gitea.apescale.com}"
ORG="${GITEA_ORG:-gitinbox}"
ID=""
DEPTH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) ID="$2"; shift 2;;
--org) ORG="$2"; shift 2;;
--url) GITEA_URL="$2"; shift 2;;
--depth) DEPTH="$2"; shift 2;;
*) echo "未知参数: $1" >&2; exit 1;;
esac
done
[[ -z "$ID" ]] && { echo "缺少 --id" >&2; exit 1; }
YAML="$ROOT/data/projects/$ID.yaml"
[[ -f "$YAML" ]] || { echo "档案不存在: $YAML" >&2; exit 1; }
# ---- 从档案读取镜像配置(bash 不适合解析 YAML,借用 python 输出 shell 赋值) ----
# 用"写临时文件再 source"而不是 eval "$(...)":
# eval 会二次解析内容,YAML 里的括号/引号一旦漏转义就会变成难定位的
# syntax error,而且报错行号会偏移到完全无关的位置。
CONF_FILE="$(mktemp)"
trap 'rm -f "$CONF_FILE"' EXIT
/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python - "$YAML" > "$CONF_FILE" <<'PY'
import sys, shlex
from pathlib import Path
sys.path.insert(0, str(Path(sys.argv[1]).resolve().parent.parent.parent / "scripts"))
from _common import load_yaml
d = load_yaml(Path(sys.argv[1]))
m = d.get("mirror") or {}
lic = (d.get("business") or {}).get("license") or {}
print(f"UPSTREAM={shlex.quote(str(m.get('upstream') or ''))}")
print(f"REPO_NAME={shlex.quote(str(m.get('gitea_repo') or '').split('/')[-1] or d.get('id',''))}")
# 可见性以许可证判定为准,不信任档案里手写的 visibility
vis = "public" if lic.get("redistributable") else "private"
print(f"VISIBILITY={shlex.quote(vis)}")
print(f"LIC_LEVEL={shlex.quote(str(lic.get('openness') or '?'))}")
print(f"LIC_NAME={shlex.quote(str(lic.get('name') or ''))}")
PY
# shellcheck disable=SC1090
source "$CONF_FILE"
[[ -z "$UPSTREAM" ]] && { echo "档案缺少 mirror.upstream" >&2; exit 1; }
echo "==> 镜像 $ID"
echo " 上游 : $UPSTREAM"
echo " 目标 : $ORG/$REPO_NAME"
echo " 许可证 : $LIC_LEVEL ($LIC_NAME)"
echo " 可见性 : $VISIBILITY"
if [[ "$VISIBILITY" == "private" ]]; then
echo " ⚠ L3 级 source-available,已强制私有。公开镜像可能构成未经授权的分发。"
fi
# ---- 凭据:优先环境变量,回退到 macOS 钥匙串 ----
if [[ -z "${GITEA_TOKEN:-}" ]]; then
CRED="$(printf "host=%s\nprotocol=https\n" "${DISPLAY_URL#*://}" | git credential-osxkeychain get 2>/dev/null || true)"
GITEA_USER="${GITEA_USER:-$(printf '%s' "$CRED" | sed -n 's/^username=//p')}"
GITEA_TOKEN="$(printf '%s' "$CRED" | sed -n 's/^password=//p')"
fi
[[ -z "${GITEA_TOKEN:-}" || -z "${GITEA_USER:-}" ]] && {
echo "未拿到 Gitea 凭据(设 GITEA_TOKEN/GITEA_USER,或存入钥匙串)" >&2; exit 1; }
# ---- 确保目标仓库存在(组织端点,不是 /user/repos) ----
API="$GITEA_URL/api/v1"
HTTP="$(curl -sS -o /dev/null -w '%{http_code}' -u "$GITEA_USER:$GITEA_TOKEN" "$API/repos/$ORG/$REPO_NAME" || echo 000)"
if [[ "$HTTP" == "200" ]]; then
echo " 仓库已存在"
elif [[ "$HTTP" == "404" ]]; then
echo " 创建仓库 $ORG/$REPO_NAME (private=$([[ $VISIBILITY == private ]] && echo true || echo false)) ..."
CREATE="$(curl -sS -o /tmp/gitea_mirror_create.json -w '%{http_code}' -u "$GITEA_USER:$GITEA_TOKEN" \
-H 'Content-Type: application/json' -X POST "$API/org/$ORG/repos" \
-d "{\"name\":\"$REPO_NAME\",\"private\":$([[ $VISIBILITY == private ]] && echo true || echo false),\"auto_init\":false,\"description\":\"Mirror of $UPSTREAM (license: $LIC_NAME)\"}")"
[[ "$CREATE" =~ ^(200|201)$ ]] || {
echo "创建失败 (HTTP $CREATE):" >&2; cat /tmp/gitea_mirror_create.json >&2; exit 1; }
echo " 已创建"
else
echo "查询仓库失败 (HTTP $HTTP)" >&2; exit 1
fi
# 已存在的仓库若应为私有,显式修正(创建时的 private 对已存在仓库不生效)
if [[ "$VISIBILITY" == "private" ]]; then
curl -sS -o /dev/null -u "$GITEA_USER:$GITEA_TOKEN" -X PATCH \
-H 'Content-Type: application/json' -d '{"private":true}' "$API/repos/$ORG/$REPO_NAME" || true
fi
# ---- 同步 ----
MIRROR_DIR="$ROOT/.mirrors/$REPO_NAME.git"
mkdir -p "$ROOT/.mirrors"
if [[ -d "$MIRROR_DIR" ]]; then
echo " 更新已有裸仓 ..."
git -C "$MIRROR_DIR" remote update --prune
else
echo " 首次全量镜像(大仓请耐心)..."
if [[ -n "$DEPTH" ]]; then
git clone --mirror --depth "$DEPTH" "$UPSTREAM" "$MIRROR_DIR"
else
git clone --mirror "$UPSTREAM" "$MIRROR_DIR"
fi
fi
PROTO="${GITEA_URL%%://*}"
HOST="${GITEA_URL#*//}"
U_ENC="$(/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python -c \
'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$GITEA_USER")"
P_ENC="$(/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python -c \
'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$GITEA_TOKEN")"
echo " 推送到 $ORG/$REPO_NAME ..."
git -C "$MIRROR_DIR" push --mirror "$PROTO://$U_ENC:$P_ENC@$HOST/$ORG/$REPO_NAME.git"
# ---- 回写档案 ----
/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python - "$YAML" "$VISIBILITY" "$REPO_NAME" <<'PY'
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(sys.argv[1]).resolve().parent.parent.parent / "scripts"))
from _common import today, update_block
p = Path(sys.argv[1])
text = p.read_text(encoding="utf-8")
text = update_block(text, "mirror", {
"visibility": sys.argv[2],
"gitea_repo": f"{'gitinbox'}/{sys.argv[3]}",
"last_sync": today(),
})
p.write_text(text, encoding="utf-8")
PY
SIZE="$(du -sh "$MIRROR_DIR" 2>/dev/null | cut -f1 || echo '?')"
echo "==> 完成。$DISPLAY_URL/$ORG/$REPO_NAME (本地裸仓 $SIZE)"
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""从 schema 生成一个项目的空白档案骨架,并自动预填可机器获取的字段。
用法
----
python3 scripts/new_project.py --repo Devin-AXIS/iPolloWork
python3 scripts/new_project.py --repo owner/name --id custom-id --status watch
生成后需要人工补完的内容(脚本不猜这些):
identity / business / scenario / tech / risk / verdict
其中 **risk.claim_vs_reality 是必填**,它承载"宣称 vs 实测"的落差分析,
是整套档案里最不能空着的字段。
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _common import ( # noqa: E402
DATA,
PROJECTS,
SCHEMA,
classify_license,
gh_get,
load_yaml,
today,
)
LEAF_KEYS = {"type", "required", "desc", "enum", "auto", "note"}
def render(node, indent=0, out=None, prefix="", idx=None):
"""把 schema 的 fields 树渲染成带注释的 YAML 文本。
同时返回 {字段路径: 行号} 索引。回填值必须走这个索引按**路径**定位,
不能靠硬编码缩进 —— schema 一旦加减层级,缩进常量就会悄悄错位,
而且像 `name` 这种同名不同层的字段(项目名 vs 许可证名)会互相覆盖。
"""
out = out if out is not None else []
idx = idx if idx is not None else {}
pad = " " * indent
for key, spec in node.items():
if not isinstance(spec, dict):
continue
path = f"{prefix}{key}"
if "type" in spec: # 叶子字段
default = {
"str": '""', "int": "0", "bool": "false",
"list": "[]", "dict": "{}",
}.get(spec["type"], '""')
comment = ""
if spec.get("enum"):
# fields 里的 enum 是 enums 段的键名引用(字符串),不是字面量数组;
# 直接 join 会把 "vendor_type" 拆成 v|e|n|d|o|r|_|t|y|p|e
e = spec["enum"]
comment = f" # {'|'.join(e) if isinstance(e, list) else e}"
elif spec.get("desc"):
d = re.sub(r"\s+", " ", spec["desc"].replace("\n", " "))
comment = f" # {d[:60]}"
idx[path] = len(out)
out.append(f"{pad}{key}: {default}{comment}")
elif spec and not (set(spec) & LEAF_KEYS): # 嵌套 section
out.append(f"{pad}{key}:")
render(spec, indent + 2, out, path + ".", idx)
return out, idx
def slug(repo: str) -> str:
name = repo.rstrip("/").split("/")[-1]
name = re.sub(r"\.git$", "", name)
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--repo", required=True, help="GitHub 仓库 owner/name")
ap.add_argument("--id", help="档案 id,默认由仓库名生成")
ap.add_argument("--status", default="active", choices=["active", "watch", "archived"])
args = ap.parse_args()
pid = args.id or slug(args.repo)
ypath = PROJECTS / f"{pid}.yaml"
if ypath.exists():
print(f"档案已存在:{ypath}")
return 1
print(f"==> 生成档案 {pid} <- {args.repo}")
# ---- 抓可自动获取的部分 ----
info = gh_get(f"repos/{args.repo}") or {}
lic = info.get("license") or {}
spdx = lic.get("spdx_id") or ""
if spdx == "NOASSERTION":
spdx = "NOASSERTION"
cls = classify_license(spdx, lic.get("name", ""))
print(f" 许可证 spdx={spdx or '(无)'} → {cls['level']} / 可见性 {cls['visibility']}")
schema = load_yaml(SCHEMA / "project.schema.yaml")
lines, idx = render(schema.get("fields", {}))
def setval(path: str, val) -> None:
"""按字段路径回填值,保留该行原有的缩进与键名。"""
i = idx.get(path)
if i is None:
print(f" [warn] schema 中无字段 {path},跳过回填")
return
line = lines[i]
indent = " " * (len(line) - len(line.lstrip()))
key = path.split(".")[-1]
lines[i] = f"{indent}{key}: {val}"
setval("id", pid)
setval("name", f'"{info.get("name", "")}"')
setval("repo", f'"{args.repo}"')
setval("homepage", f'"{info.get("homepage") or ""}"')
setval("added_at", f'"{today()}"')
setval("status", args.status)
if info:
setval("identity.tagline", f'"{info.get("description") or ""}"')
setval("identity.first_seen", f'"{(info.get("created_at") or "")[:10]}"')
setval("business.license.spdx", f'"{spdx or "NOASSERTION"}"')
setval("business.license.name", f'"{lic.get("name") or "自定义许可"}"')
setval("business.license.openness", cls["level"])
setval("business.license.redistributable", "true" if cls["redistributable"] else "false")
setval("mirror.gitea_repo", f'"gitinbox/{info.get("name") or pid}"')
setval("mirror.visibility", cls["visibility"])
setval("mirror.upstream", f'"https://github.com/{args.repo}"')
text = "\n".join(lines)
header = (
f"# {info.get('name') or pid} · 项目档案\n"
f"# 生成于 {today()} · 由 scripts/new_project.py 从 schema 生成\n"
"# 人工补完重点:business / scenario / tech / risk.claim_vs_reality / verdict\n"
"# 字段规范见 schema/project.schema.yaml\n"
"\n"
)
PROJECTS.mkdir(parents=True, exist_ok=True)
ypath.write_text(header + text + "\n", encoding="utf-8")
print(f" 已写入 {ypath.relative_to(PROJECTS.parent.parent)}")
# ---- 登记到 registry ----
reg_path = DATA / "registry.yaml"
reg_text = reg_path.read_text(encoding="utf-8")
if f"id: {pid}" not in reg_text:
entry = (
f" - id: {pid}\n"
f" repo: {args.repo}\n"
f" status: {args.status}\n"
f" added_at: {today()}\n"
f" license_level: {cls['level']}"
+ (" # source-available,镜像私有\n" if cls["level"] == "L3" else "\n")
+ f" mirror_visibility: {cls['visibility']}\n"
)
reg_text = reg_text.rstrip("\n") + "\n" + entry
reg_path.write_text(reg_text, encoding="utf-8")
print(f" 已登记到 data/registry.yaml")
print(f"\n下一步:\n 1. 补完档案(重点 risk.claim_vs_reality)\n"
f" 2. python3 scripts/fetch_stats.py --id {pid}\n"
f" 3. bash scripts/mirror_sync.sh --id {pid}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""把 articles/ 下的 Markdown 源文发布(或更新)到 emlog 可巴蒂栏目。
用法
----
python3 scripts/publish_emlog.py --article ipollowork
python3 scripts/publish_emlog.py --article 00-overview
python3 scripts/publish_emlog.py --article ipollowork --draft y # 存草稿
两个关键设计
------------
1. **update 前必须先 detail 取回全文**。
emlog 的 article_update 接口在 content 为空时会把正文清空 —— 这是接口行为,
不是 bug。总纲文每次回填都要更新,所以这条保护是刚需。
2. **文章 ID 回写**。
项目文回写到 data/projects/<id>.yaml 的 article 块;
总纲文回写到 articles/00-overview.meta.yaml。
有了 ID 才能做"更新"而不是每次都发一篇新的。
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, "/Users/figmarlite/.workbuddy/skills/emlog-blog-publisher/scripts")
import yaml # noqa: E402
from _common import ARTICLES, PROJECTS, today, update_block # noqa: E402
import publish_article as emlog # noqa: E402
DEFAULT_SORT_ID = 17 # 可巴蒂
OVERVIEW_ID = "00-overview"
META_PATH = ARTICLES / f"{OVERVIEW_ID}.meta.yaml"
# ---------------------------------------------------------------- 转换
def md_to_html(md: str) -> str:
"""Markdown → HTML。emlog 只吃 HTML。"""
try:
import markdown
return markdown.markdown(
md, extensions=["tables", "fenced_code", "toc", "attr_list", "sane_lists"]
)
except ImportError:
sys.exit("缺少 markdown 库:pip install markdown")
def split_title(md: str) -> tuple[str, str]:
"""取第一个一级标题作为文章标题,其余作为正文。"""
lines = md.lstrip().split("\n")
if lines and lines[0].startswith("# "):
return lines[0][2:].strip(), "\n".join(lines[1:]).lstrip("\n")
return "未命名", md
def to_excerpt(md: str, limit: int = 110) -> str:
"""摘一段纯文本作为摘要:跳过标题与表格,取第一段实质内容。"""
for line in md.split("\n"):
s = line.strip()
if not s or s.startswith(("#", "|", ">", "-", "*", "!")) or s.startswith("<"):
continue
s = re.sub(r"[*`_]", "", s)
return s[:limit]
return ""
# ---------------------------------------------------------------- ID 读写
def read_emlog_id(aid: str) -> int | None:
if aid == OVERVIEW_ID:
if not META_PATH.exists():
return None
return (yaml.safe_load(META_PATH.read_text(encoding="utf-8")) or {}).get("emlog_id")
p = PROJECTS / f"{aid}.yaml"
if not p.exists():
return None
d = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
v = (d.get("article") or {}).get("emlog_id")
return int(v) if v else None
def write_emlog_id(aid: str, emlog_id: int) -> None:
url = f"{emlog.BLOG_URL}/?post={emlog_id}"
if aid == OVERVIEW_ID:
META_PATH.write_text(
f"# 总纲文发布元数据(由 scripts/publish_emlog.py 维护)\n"
f"emlog_id: {emlog_id}\nurl: \"{url}\"\nlast_published: \"{today()}\"\n",
encoding="utf-8",
)
print(f" 已写入 {META_PATH.name}")
return
p = PROJECTS / f"{aid}.yaml"
if not p.exists():
print(f" [warn] 档案不存在,未回写 ID: {p}")
return
text = p.read_text(encoding="utf-8")
try:
text = update_block(text, "article", {
"emlog_id": emlog_id, "published_at": today(), "url": url,
})
p.write_text(text, encoding="utf-8")
print(f" 已回写 emlog_id 到档案")
except ValueError as e:
print(f" [warn] 回写失败:{e}")
# ---------------------------------------------------------------- 发布
def do_publish(aid: str, title: str, html: str, sort_id: int, tags: str, draft: str, excerpt: str) -> None:
res = emlog.publish_article(
title=title, content=html, sort_id=sort_id,
excerpt=excerpt, tags=tags, draft=draft,
)
if res.get("code") != 0:
print(f" 发布失败:{res.get('msg')}")
sys.exit(1)
new_id = int(res.get("data", {}).get("article_id") or 0)
print(f" 已发布 id={new_id} {emlog.BLOG_URL}/?post={new_id}")
write_emlog_id(aid, new_id)
def do_update(aid: str, emlog_id: int, title: str, html: str, sort_id: int, tags: str, draft: str, excerpt: str) -> None:
"""更新既有文章。
关于"emlog update 不传 content 会清空正文"这个坑:
保护点应该是**本地正文非空**,而不是"先 detail 取回线上全文"。
两个理由——
1. 本脚本的 content 永远来自本地 Markdown 源文,不存在"忘了传"的情况;
2. detail 接口查不到**草稿**(实测:草稿 id 返回 400 Article not found,
已发布文章正常)。若把保护建立在 detail 上,草稿就永远无法转正。
所以 detail 降级为可选的健全性检查:取得到就对比一下长度,取不到也不阻断。
"""
print(f" 发现已有文章 id={emlog_id},走更新流程")
if not html.strip():
print(" 本地正文为空,拒绝更新(那会清空线上内容)")
sys.exit(1)
det = emlog.get_article_detail(emlog_id)
if det.get("code") == 0:
# 注意返回结构是 data.article.content,不是 data.content
art = (det.get("data") or {}).get("article") or {}
existing = art.get("content") or ""
print(f" 线上现有正文 {len(existing)} 字符 → 将覆盖为 {len(html)} 字符")
else:
print(f" 未能取回线上正文({det.get('msg', '未知')});"
f"本地正文非空,继续更新")
res = emlog.update_article(
article_id=emlog_id, title=title, content=html,
sort_id=sort_id, excerpt=excerpt, tags=tags, draft=draft,
)
if res.get("code") != 0:
print(f" 更新失败:{res.get('msg')}")
sys.exit(1)
print(f" 已更新 {emlog.BLOG_URL}/?post={emlog_id}")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--article", required=True, help="articles/ 下的文件名(不含 .md)")
ap.add_argument("--sort-id", type=int, default=DEFAULT_SORT_ID)
ap.add_argument("--tags", default="")
ap.add_argument("--draft", default="n", choices=["y", "n"])
args = ap.parse_args()
md_path = ARTICLES / f"{args.article}.md"
if not md_path.exists():
print(f"文章不存在:{md_path}")
return 1
md = md_path.read_text(encoding="utf-8")
title, body = split_title(md)
html = md_to_html(body)
print(f"==> {args.article}")
print(f" 标题:{title}")
print(f" HTML {len(html)} 字符")
# 摘要要从正文里摘,不是从标题 —— 传标题进去等于没写摘要
excerpt = to_excerpt(body)
emlog_id = read_emlog_id(args.article)
if emlog_id:
do_update(args.article, emlog_id, title, html, args.sort_id, args.tags, args.draft, excerpt)
else:
do_publish(args.article, title, html, args.sort_id, args.tags, args.draft, excerpt)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+130
View File
@@ -0,0 +1,130 @@
# 文章模板 · 项目深度分析
> 使用方式:复制本文件为 `articles/<id>.md` 后逐节填写。
> 带 `【必填】` 的章节不可省略;带 `【若适用】` 的可按项目情况取舍。
> **写作铁律:不做 README 复读机。** 每一节都要回答「README 没写、但你核实出来了什么」。
---
# <项目名>:<副标题,点出它真正有意思的那个点>
> **速览**
> | 项 | 值 |
> | --- | --- |
> | 仓库 | [owner/name](url) |
> | 一句话 | <tagline> |
> | Star / Fork / Issues | <n> / <n> / <n>(截至 <日期>) |
> | 主语言 / 体积 | <lang> / <size> |
> | 许可证 | <name>(L<n> · <成色>) |
> | 成熟度 | <preview/beta/stable> |
>
> 一句话结论:<verdict.one_liner>
---
## 0. 先给结论 【必填】
用 3-5 句话说清:它是什么、不是什么、最值得关注的一点。
**不要在这里复述特性列表**——读者要的是判断,不是目录。
---
## 1. 赛道定位:它站在哪一层 【必填】
把它放进坐标系。建议按 Agent 工具的三层栈定位:
- **执行引擎层**(Codex / DeepSeek Harness / OpenCode …)—— 真正跑 agent loop 的地方
- **编辑器内 Agent 层**(Cline / Roo Code / Kilo Code …)—— 嵌在 IDE 里的交互层
- **桌面工作台层**(iPolloWork 等)—— 跨引擎、跨项目、跨交付物的统一界面
写明它跨了几层、以哪一层为主。**配一张分层图。**
---
## 2. 业务维度:它靠什么活着 【必填】
- 目标用户到底是谁(警惕「人人可用」这种说法)
- 变现路径:捐赠 / 商业授权 / SaaS / 双轨 / 尚未明确
- 许可证成色分析:**这是本节重点**。不只是写许可证名,要说清
- 它是 OSI 认证的吗?不是的话,差在哪?
- 关键限制条款是什么(用户/规模/再分发/品牌保留)
- 这个限制的商业意图是什么
- 护城河:什么是别人抄不走的
---
## 3. 场景维度:它在什么场合被用 【必填】
- 核心场景与典型用例(给具体例子,别写「提升效率」这类空话)
- 人机协作模式:全自动 / 审批式 / 全程监督
- **交付物**:它最终产出什么?这些产出生成后还能改吗?
(这是区分「生成工具」和「工作台」的关键分水岭)
---
## 4. 技术维度:它怎么做到的 【必填】
- 产品形态与技术栈
- **架构图**(必须有,用 ASCII 或 SVG)
- monorepo 布局 / 模块职责拆解
- **引擎抽象层**:如果它支持多引擎,逐个说明接入方式,
并明确指出**各引擎的接入深度是否对等**
(常见落差:A 是原生 adapter,B 只是 MCP 控制面)
- 扩展机制:Skills / 插件 / MCP 是否统一生命周期
- 模型绑定程度:锁定自家模型 / 自带模型 / 完全中立
- 本地优先的实现方式(BYOM?数据不出本地?云端是否可选?)
- 权限审批与沙箱
---
## 5. 横向对比 【必填,项目数 ≥2 时】
与已收录项目的差异对照表。**不要只比功能清单**,要比设计取向:
| 维度 | 本项目 | 对照 A | 对照 B |
| --- | --- | --- | --- |
| 站位层次 | | | |
| 引擎立场 | | | |
| 交付物纵深 | | | |
| 许可证 | | | |
---
## 6. 宣称 vs 实测 【必填 · 本节是全篇价值核心】
逐条列出。每项必须写清:**宣称 / 实际 / 证据来源**。
撰写要点:
- 优先抓 README 里的限定词(`currently` / `today` / `in development` / `optional`)
—— 这些词往往是落差的藏身处
- 对比第三方评测与官方自述的出入
- 用指标佐证叙事(版本号跳跃速度、仓库体积、issue 积压、最后提交时间)
- topics / 徽章里提到了但文档没写的,单独拎出来问
示例格式:
> **宣称**:兼容 Codex。
> **实际**:Codex 通过 `ipollowork-ui-mcp` 的 MCP 控制面接入,README 自述
> 「currently uses the MCP control surface rather than claiming a native Codex engine adapter」,
> 与 OpenCode 的原生 Engine Protocol adapter 深度不对等。
> **来源**:仓库 README *Architecture boundary* 一节。
---
## 7. 结论:什么人该用它 【必填】
- **该用**:<画像 + 场景>
- **不该用**:<画像 + 原因,比"该用"更重要>
- **再看看**:<哪些承诺落地后再评估>
---
## 8. 追踪备注 【必填】
- 数据日期:<日期>
- 指标来源:GitHub API(自动化快照)
- 下次跟进点:<列出需要持续观察的 2-3 件事,如「DSH 集成是否进入稳定版」>
- 相关档案:`data/projects/<id>.yaml` · 源码镜像:<gitea_repo>(<visibility>)
---
<sub>本文为「全球智能体开源应用追踪」系列之一 · 总纲见 [系列索引](总纲链接) · 数据与脚本开源于 `gitinbox/agent-radar`</sub>