Publish agent-radar via gitea-publish skill
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
||||
# 源码镜像是全量裸仓,单个项目可达数百 MB,绝不能进版本库
|
||||
.mirrors/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# 系统
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
# 临时
|
||||
/tmp/
|
||||
*.tmp
|
||||
@@ -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 个时象限图没有统计意义,此阶段只出赛道分层图。
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
# 总纲文发布元数据(由 scripts/publish_emlog.py 维护)
|
||||
emlog_id: 98
|
||||
url: "https://blog.apescale.com/?post=98"
|
||||
last_published: "2026-09-01"
|
||||
@@ -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>
|
||||
@@ -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: ""
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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 与产品归属。公开镜像落入「托管/市场分发」
|
||||
的解释空间,故设为私有。
|
||||
@@ -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}
|
||||
Executable
+215
@@ -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"}
|
||||
Executable
+234
@@ -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())
|
||||
Executable
+191
@@ -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())
|
||||
Executable
+148
@@ -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)"
|
||||
Executable
+174
@@ -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())
|
||||
Executable
+194
@@ -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())
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user