From 78b87e8b2ffd78da9d6fe1d16593f3983aefe127 Mon Sep 17 00:00:00 2001 From: gitea-publish Date: Tue, 1 Sep 2026 15:14:54 +0800 Subject: [PATCH] Publish agent-radar via gitea-publish skill --- .gitignore | 16 ++ README.md | 137 ++++++++++++++++ articles/00-overview.md | 161 +++++++++++++++++++ articles/00-overview.meta.yaml | 4 + articles/ipollowork.md | 268 ++++++++++++++++++++++++++++++++ data/projects/ipollowork.yaml | 167 ++++++++++++++++++++ data/registry.yaml | 10 ++ data/snapshots/ipollowork.jsonl | 1 + schema/license-tiers.yaml | 81 ++++++++++ schema/project.schema.yaml | 148 ++++++++++++++++++ scripts/_common.py | 215 +++++++++++++++++++++++++ scripts/build_matrix.py | 234 ++++++++++++++++++++++++++++ scripts/fetch_stats.py | 191 +++++++++++++++++++++++ scripts/mirror_sync.sh | 148 ++++++++++++++++++ scripts/new_project.py | 174 +++++++++++++++++++++ scripts/publish_emlog.py | 194 +++++++++++++++++++++++ templates/project-article.md | 130 ++++++++++++++++ 17 files changed, 2279 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 articles/00-overview.md create mode 100644 articles/00-overview.meta.yaml create mode 100644 articles/ipollowork.md create mode 100644 data/projects/ipollowork.yaml create mode 100644 data/registry.yaml create mode 100644 data/snapshots/ipollowork.jsonl create mode 100644 schema/license-tiers.yaml create mode 100644 schema/project.schema.yaml create mode 100755 scripts/_common.py create mode 100755 scripts/build_matrix.py create mode 100755 scripts/fetch_stats.py create mode 100755 scripts/mirror_sync.sh create mode 100755 scripts/new_project.py create mode 100755 scripts/publish_emlog.py create mode 100644 templates/project-article.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f5c59b --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# 源码镜像是全量裸仓,单个项目可达数百 MB,绝不能进版本库 +.mirrors/ + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ + +# 系统 +.DS_Store +*.swp + +# 临时 +/tmp/ +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..dccf97f --- /dev/null +++ b/README.md @@ -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/.yaml # 每项目一份五维档案 +│ └── snapshots/.jsonl # 指标时间序列(追加式,一行一天) +├── articles/ +│ ├── 00-overview.md # 总纲文源文(含矩阵占位符,由脚本回填) +│ └── .md # 项目深度文源文 +├── templates/project-article.md # 文章固定章节模板 +├── scripts/ # 抓取 / 生成 / 镜像 / 发布脚本 +└── site/index.html # 静态总览页 +``` + +--- + +## 五维档案 + +每个项目一份 `data/projects/.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/.yaml +# 并把它加进 data/registry.yaml + +# 3. 刷新指标 +python3 scripts/fetch_stats.py --id + +# 4. 镜像源码(按许可证等级自动决定 private / public) +bash scripts/mirror_sync.sh --id + +# 5. 成文(套 templates/project-article.md) +$EDITOR articles/.md + +# 6. 发布到 emlog(可巴蒂 sid=17),emlog_id 会自动回写档案 +python3 scripts/publish_emlog.py --article + +# 7. 回填总纲并更新总纲文 +python3 scripts/build_matrix.py +python3 scripts/publish_emlog.py --article 00-overview + +# 8. 入库 +git add -A && git commit -m "track: " && 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 个时象限图没有统计意义,此阶段只出赛道分层图。 diff --git a/articles/00-overview.md b/articles/00-overview.md new file mode 100644 index 0000000..d7aa727 --- /dev/null +++ b/articles/00-overview.md @@ -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 │ +└─────────────────────────────────────────────────────────────┘ +``` + +越往上,离"模型"越远、离"交付物"越近,也越容易形成用户黏性—— +但也越容易被下一层向上长出来的能力挤压。 + +--- + + +### 业务维度 + +| 项目 | 定位 | 目标用户 | 变现 | 许可证 | 成色 | +| --- | --- | --- | --- | --- | --- | +| **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 个,此阶段只出分层图。样本够了之后会自动切换为象限图。 + + +--- + +## 深度文索引 + +| 项目 | 一句话结论 | 文章 | +| --- | --- | --- | +| 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 / 托管 / 市场分发均需事先书面授权」, +公开镜像会落进这个解释空间里。替别人的源码做一次未经授权的公开分发,不值得。 + +--- + +本页由 `gitinbox/agent-radar` 的脚本自动生成与维护 · 数据快照为追加式时间序列,可回溯任意时点的指标 diff --git a/articles/00-overview.meta.yaml b/articles/00-overview.meta.yaml new file mode 100644 index 0000000..e84cc35 --- /dev/null +++ b/articles/00-overview.meta.yaml @@ -0,0 +1,4 @@ +# 总纲文发布元数据(由 scripts/publish_emlog.py 维护) +emlog_id: 98 +url: "https://blog.apescale.com/?post=98" +last_published: "2026-09-01" diff --git a/articles/ipollowork.md b/articles/ipollowork.md new file mode 100644 index 0000000..11bfe9e --- /dev/null +++ b/articles/ipollowork.md @@ -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 人门槛与白标限制是观察其商业化走向的窗口 + +--- + +本文为「全球智能体开源应用追踪」系列之一 · 总纲见系列索引 · 数据与脚本开源于 `gitinbox/agent-radar` diff --git a/data/projects/ipollowork.yaml b/data/projects/ipollowork.yaml new file mode 100644 index 0000000..aabfff0 --- /dev/null +++ b/data/projects/ipollowork.yaml @@ -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: "" diff --git a/data/registry.yaml b/data/registry.yaml new file mode 100644 index 0000000..0116a6f --- /dev/null +++ b/data/registry.yaml @@ -0,0 +1,10 @@ +# 收录总表 —— 驱动 fetch_stats.py 批量抓取 +# 新增项目时在此登记,id 需与 data/projects/.yaml 一致。 + +projects: + - id: ipollowork + repo: Devin-AXIS/iPolloWork + status: active + added_at: 2026-09-01 + license_level: L3 # source-available,镜像私有 + mirror_visibility: private diff --git a/data/snapshots/ipollowork.jsonl b/data/snapshots/ipollowork.jsonl new file mode 100644 index 0000000..5ce3eda --- /dev/null +++ b/data/snapshots/ipollowork.jsonl @@ -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} diff --git a/schema/license-tiers.yaml b/schema/license-tiers.yaml new file mode 100644 index 0000000..8a1c624 --- /dev/null +++ b/schema/license-tiers.yaml @@ -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 与产品归属。公开镜像落入「托管/市场分发」 + 的解释空间,故设为私有。 diff --git a/schema/project.schema.yaml b/schema/project.schema.yaml new file mode 100644 index 0000000..8aefd8f --- /dev/null +++ b/schema/project.schema.yaml @@ -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} diff --git a/scripts/_common.py b/scripts/_common.py new file mode 100755 index 0000000..4d00e8b --- /dev/null +++ b/scripts/_common.py @@ -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 文本里定位 `:` 块,替换其中的键值。 + + 只改块内已有键的值;块内没有而 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"} diff --git a/scripts/build_matrix.py b/scripts/build_matrix.py new file mode 100755 index 0000000..3d87872 --- /dev/null +++ b/scripts/build_matrix.py @@ -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 = "", "" + + +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.append(f'{title}') + px, py, pw, ph = PAD, 55, W - PAD * 2, H - PAD - 55 + svg.append(f'') + # 网格 + for k in range(1, 4): + gx = px + pw * k / 4 + gy = py + ph * k / 4 + svg.append(f'') + svg.append(f'') + # 轴标签 + svg.append(f'{xlo}') + svg.append(f'{xhi}') + svg.append(f'{ylo}') + svg.append(f'{yhi}') + # 点 + 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'') + svg.append(f'{name}') + svg.append("") + 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()) diff --git a/scripts/fetch_stats.py b/scripts/fetch_stats.py new file mode 100755 index 0000000..0271eb4 --- /dev/null +++ b/scripts/fetch_stats.py @@ -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()) diff --git a/scripts/mirror_sync.sh b/scripts/mirror_sync.sh new file mode 100755 index 0000000..8684096 --- /dev/null +++ b/scripts/mirror_sync.sh @@ -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)" diff --git a/scripts/new_project.py b/scripts/new_project.py new file mode 100755 index 0000000..70d4006 --- /dev/null +++ b/scripts/new_project.py @@ -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()) diff --git a/scripts/publish_emlog.py b/scripts/publish_emlog.py new file mode 100755 index 0000000..d2f26e4 --- /dev/null +++ b/scripts/publish_emlog.py @@ -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/.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()) diff --git a/templates/project-article.md b/templates/project-article.md new file mode 100644 index 0000000..dd3a429 --- /dev/null +++ b/templates/project-article.md @@ -0,0 +1,130 @@ +# 文章模板 · 项目深度分析 + +> 使用方式:复制本文件为 `articles/.md` 后逐节填写。 +> 带 `【必填】` 的章节不可省略;带 `【若适用】` 的可按项目情况取舍。 +> **写作铁律:不做 README 复读机。** 每一节都要回答「README 没写、但你核实出来了什么」。 + +--- + +# <项目名>:<副标题,点出它真正有意思的那个点> + +> **速览** +> | 项 | 值 | +> | --- | --- | +> | 仓库 | [owner/name](url) | +> | 一句话 | | +> | Star / Fork / Issues | / / (截至 <日期>) | +> | 主语言 / 体积 | / | +> | 许可证 | (L · <成色>) | +> | 成熟度 | | +> +> 一句话结论: + +--- + +## 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/.yaml` · 源码镜像:() + +--- + +本文为「全球智能体开源应用追踪」系列之一 · 总纲见 [系列索引](总纲链接) · 数据与脚本开源于 `gitinbox/agent-radar`