Compare commits
204 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8a5551215 | |||
| 45edbeae1f | |||
| a9af14ac63 | |||
| c1f4366379 | |||
| c0d6b4a8e1 | |||
| db70ef32da | |||
| 55b8fea7a8 | |||
| 701ef632ff | |||
| b6e44625b6 | |||
| c22739d4f8 | |||
| 46bff91af9 | |||
| fe6b3b5951 | |||
| e0806362f0 | |||
| bef80fba4d | |||
| 9c32f77722 | |||
| d631be2617 | |||
| 51d6364ddd | |||
| 17e9306c97 | |||
| 6e3a505d24 | |||
| cd3e0c695c | |||
| 5514875213 | |||
| ec50b3302e | |||
| 68780f1f2b | |||
| 34f7cae0fe | |||
| 76aa80fedc | |||
| 7901ab940e | |||
| d52dac64e5 | |||
| b3fcb9ad3a | |||
| 91d6c22e6b | |||
| e04ab96187 | |||
| eb7e758570 | |||
| 5ba790eec2 | |||
| ac9ddd9c27 | |||
| ea31287924 | |||
| 5b31fa5b77 | |||
| c833aa56c1 | |||
| 0a640d75d4 | |||
| 0bd2ce6b02 | |||
| cce2fd8581 | |||
| ae84304e16 | |||
| c6e5a7b05f | |||
| 9bfd6891c3 | |||
| 3557ee7e87 | |||
| cc6993de2a | |||
| 462fe01df0 | |||
| 838c9f1bad | |||
| 0d7c24c529 | |||
| 74cd21c6fa | |||
| 3c14c6fdca | |||
| a1587e86bb | |||
| 17d6589db1 | |||
| 708f136163 | |||
| f74b268da4 | |||
| 2f525f97e3 | |||
| 92f7bcffbd | |||
| 1ca8ab72e8 | |||
| 2c1f35f000 | |||
| 5cbaa013a7 | |||
| f72043a4ed | |||
| 21fe8ded32 | |||
| 3c631a43a6 | |||
| 53df8a4a2b | |||
| 5b86f08959 | |||
| 4330817138 | |||
| 72f0928efa | |||
| e113b74c41 | |||
| a1184b31b8 | |||
| 14e0c8ac76 | |||
| ea3433bd60 | |||
| dc17a4d802 | |||
| f49b5b96e7 | |||
| f3d83901a1 | |||
| cb803662c3 | |||
| 7fc6742129 | |||
| 91d4734763 | |||
| 4269d05b2e | |||
| 4be984fa0a | |||
| 643c0df7aa | |||
| 8eb3b888d1 | |||
| e9f48d6d9c | |||
| a67a3b3b81 | |||
| d7251e892b | |||
| c0f9ee50c1 | |||
| ad682df1cc | |||
| cf47c1af31 | |||
| 06a6883be3 | |||
| 9c08d9dc0f | |||
| c0924df7c4 | |||
| 0e1e127d4b | |||
| a9e5ac6a87 | |||
| b81e1ad76e | |||
| 1f87628076 | |||
| a6a1860ef1 | |||
| 94dcac4e95 | |||
| ec0d9abb34 | |||
| d2e83cd33b | |||
| 69c3cc8e64 | |||
| cdb84376cb | |||
| fabfce1f4c | |||
| 142fa62d10 | |||
| 868594c79a | |||
| 83f91f1b1e | |||
| f99a354db2 | |||
| 6410535ed9 | |||
| db3b21bf54 | |||
| 7140ea0469 | |||
| 241c82aa88 | |||
| 5d62dee4e9 | |||
| 543d6ed527 | |||
| 445bfb37d0 | |||
| baaea4fc61 | |||
| ac04839571 | |||
| 7cecb9f753 | |||
| a2a5958fc4 | |||
| 7cc2397103 | |||
| feb4de220d | |||
| 652b0fd539 | |||
| 5ee04f26ae | |||
| 812749823b | |||
| 8a4df5c7ba | |||
| 8d886ad3cc | |||
| 790dda09de | |||
| 5182eca961 | |||
| 5cd8f831be | |||
| a520694f9e | |||
| dc373bad09 | |||
| dc2c3a22f3 | |||
| fefa7c5ebd | |||
| 5c2d9fd06c | |||
| 85a0c534aa | |||
| d4736f7477 | |||
| f41d9e0d53 | |||
| 1736912ecb | |||
| 45f1d1dba3 | |||
| 8d7b10df19 | |||
| d9d675ce56 | |||
| b8a82359e3 | |||
| 4682e3513e | |||
| c0ff99e640 | |||
| b5e4f817fb | |||
| df227df18c | |||
| 1e81887682 | |||
| c25fb79eee | |||
| a8b054381f | |||
| 8e5df06fdd | |||
| 4942b86a4a | |||
| 7e4f4fe3e6 | |||
| 1e716c36b2 | |||
| ba042ed5ae | |||
| 0cc3815bd8 | |||
| f49aac2778 | |||
| d588f2e1fb | |||
| ad1f351a6b | |||
| f73d2631fb | |||
| 6dca32dc66 | |||
| 992275560f | |||
| 74c74ddda9 | |||
| 61201c1bb0 | |||
| 4eb08c0857 | |||
| 3e7792191e | |||
| fa09a01421 | |||
| 87987f6cc6 | |||
| c9497ccc6b | |||
| 05b500e911 | |||
| ea09b414a2 | |||
| 945e7eadb2 | |||
| f86a4a75a0 | |||
| ccba2c546a | |||
| 56b0ee60c0 | |||
| f7cdf16933 | |||
| deadd24dce | |||
| 4f1d3caafd | |||
| 28ced713dc | |||
| c8c8bad563 | |||
| 13f2b31e8a | |||
| 9ee2bd219a | |||
| 20e5097783 | |||
| ae4bd203a1 | |||
| 5b6d3d9bfd | |||
| 6b4fa711c1 | |||
| 76e32490e9 | |||
| fa0bcb15fb | |||
| 12123c278f | |||
| 4273dd77e0 | |||
| 3ab2f8e122 | |||
| 15024c3908 | |||
| 770be83692 | |||
| 083f532609 | |||
| 2b3706c90d | |||
| 6bfeea4e7e | |||
| f156406265 | |||
| e65d90c78b | |||
| 96f1417cda | |||
| 347b9587c6 | |||
| 677cd73945 | |||
| e075eee8a5 | |||
| d971362a97 | |||
| 0e5ccafa30 | |||
| 0c2a909521 | |||
| a58a409c38 | |||
| c4ab5caac0 | |||
| 4cbb88181a | |||
| ce87abd1cb | |||
| dc7ec078e1 |
@@ -3,7 +3,8 @@
|
||||
- 本仓库专门维护 Endor 文档与制图脚本。支持本地开发;CI/CD 仅在 main 使用现有 ces-control Runner,验证通过后自动部署;工作分支与 PR 不运行 CI,确认 Runner 不构成扩大触发范围的授权。不得修改其他项目、共享 Runner 配置或清理共享 Docker 缓存。
|
||||
- Web 技术栈为 TypeScript、React、Tailwind CSS、Next.js Pages Router;不要切换到 App Router。
|
||||
- 使用 pnpm 管理 JavaScript workspace,使用 uv 管理 `tools/figures` 的 Python 项目,保留两套锁文件。
|
||||
- 文档正文、目录元数据与修订记录以站点现有 SQLite 数据库为唯一权威源,使用 docs 管理工具直接更新;不得把新正文、目录 JSON、数据库文件或日常内容快照提交到仓库。历史 Git 文档只用于显式的一次性迁移,不作为运行时种子。Python 图形源码放在 `tools/figures/src/endor_figures`,可复现的 SVG 输出放在 `apps/docs/public/figures`。
|
||||
- 文档正文、目录元数据与修订记录以站点现有 SQLite 数据库为唯一权威源,使用文档 API 或 docs 管理工具直接更新;不得把新正文、目录 JSON、数据库文件或日常内容快照提交到仓库。历史 Git 文档只用于显式的一次性迁移,不作为运行时种子。Python 图形源码放在 `tools/figures/src/endor_figures`,可复现的 SVG 输出放在 `apps/docs/public/figures`。
|
||||
- 编辑已有文章时优先使用章节 API:先读取章节目录与内部摘要,据此选择需要读取正文的章节,再提交单章或多章操作。摘要缺失时先读对应正文并补充;修改观点前必须核对该章原文,检查读取版本与提交 revision 一致。摘要只写入内部章节数据,不放入网页正文、目录摘要或 Git;改写正文时同步更新摘要。完整操作与 Python 客户端用法见 README 的「先读摘要,再按章节编辑」。
|
||||
- 未确认的架构提议明确标注为草案,不能表述为现有实现。
|
||||
- 本地开发与预览只监听 `127.0.0.1`。部署容器监听 `0.0.0.0:3000`,服务器入口默认 18135,可通过独立 production.env 配置。
|
||||
- 对 gitea.entroia.com 仅使用 codex-bot,提交身份为 Codex Bot <codex-bot@gitea.entroia.local>。SSH 使用隔离配置:`ssh -F /dev/null -o IdentitiesOnly=yes -i /Users/zzyang/.codex/credentials/gitea-entroia/codex_entroia_ed25519 -p 22222`。
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
# Endor · Tabularium
|
||||
|
||||
Endor 文档站、研究工作台与制图程序。应用代码在 Git 中评审;全部文档正文、目录元数据和内容修订以站点 SQLite 数据库为唯一权威源。日常写作不需要 commit、构建或部署。
|
||||
Endor 的文档站与 AI 公司研究工作台,包含文档阅读、全文搜索、研究数据展示和制图工具。使用 Next.js Pages Router、React、TypeScript 与 Tailwind CSS;JavaScript workspace 使用 pnpm,制图 Python 项目使用 uv。
|
||||
|
||||
## 存储与读取
|
||||
## 代码与内容放在哪里
|
||||
|
||||
沿用 `AUTH_CONFIG_FILE` 同目录的 `login-state.sqlite` 和 `/auth` 持久挂载。`documents` 存正文及分类、合集、分组、摘要、草案与推荐状态;`document_aliases` 保留旧地址;`document_revisions` 保存每次修改;`document_imports` 标记一次性迁移。没有第二套文档数据库。
|
||||
文档正文、目录元数据和修订记录由站点 SQLite 数据库管理,日常写作直接更新数据库,无需提交代码、构建或部署。研究业务数据仍沿用版本化 JSON 导入机制,后续单独迁移。
|
||||
|
||||
首页、阅读页、目录和全文搜索按请求查询数据库,使用 `private, no-store`。旧文件式地址与改名前的语义地址通过数据库别名跳转,保留查询参数;不再在构建阶段枚举文档。Markdown 原文通过受认证保护的 `/api/documents/source?slug=...` 获取。
|
||||
| 内容 | 权威来源与维护方式 |
|
||||
| --- | --- |
|
||||
| 文档正文、分类、合集、分组、摘要、草案和推荐状态 | 数据库;通过文档 API 或服务器上的 `docs` 管理工具读写 |
|
||||
| 文档旧地址与修订历史 | 数据库;改名保留别名,修改追加修订 |
|
||||
| AI 公司、观测、证据、候选与核查记录 | `content/research` 中的版本化快照;服务端显式按版本导入同库后查询 |
|
||||
| 页面、组件、管理工具和部署逻辑 | 本仓库,通过工作分支与 PR 评审 |
|
||||
| 图形源码与静态资源 | `tools/figures` 与 `apps/docs/public`;生成步骤在远程 Runner 执行 |
|
||||
|
||||
仓库不保存日常正文、目录 JSON、数据库文件或内容快照;历史 Git 记录仅用于迁移。页面不再读取 `content/docs` 或 `content/catalog.json`。Markdown 与 GFM 表格仍可用,不解析原始 HTML。图片在写入时检查压缩格式;图形程序与版本化静态资源仍在代码仓库,本轮不迁移媒体存储。研究业务数据留待下一步。
|
||||
以上数据库数据共用 `AUTH_CONFIG_FILE` 同目录下的 `login-state.sqlite`,该文件名沿用历史命名。生产环境挂载到容器 `/auth`,不随镜像替换。文档使用 `documents`、`document_aliases`、`document_revisions`、`document_imports` 表,研究数据使用 `research_*` 表,登录状态使用 `login_state` 表。
|
||||
|
||||
## 在服务器上管理文档
|
||||
仓库不接收新的文档正文 Markdown、目录 JSON、数据库文件或日常正文快照。历史 Git 正文仅用于一次性迁移;运行时不读取 `content/docs` 或 `content/catalog.json`。README 维护程序使用与运维约定,研究个案、统计数量与内容更新日志在工作台和数据库文档中查看。
|
||||
|
||||
部署安装 `/opt/endorphin-tabularium-docs/docs`,通过当前容器执行管理 CLI。需要服务器的运维权限;不增加可公开写入的 API,不改变现有动态码登录。
|
||||
## 阅读与访问
|
||||
|
||||
首页、阅读页、目录及全文搜索按请求读取数据库,内容更新后刷新即可看到。旧文件式地址和改名前的语义地址继续跳转到当前地址,并保留查询参数。
|
||||
|
||||
- 文档原文:`GET /api/documents/source?slug=<文档地址>`。
|
||||
- 全文搜索:`GET /api/search`。
|
||||
- 研究工作台:`/docs/other-research/ai-company-research/overview`。
|
||||
- 收入样本:`/docs/other-research/ai-company-research/income-snapshot`。
|
||||
- 覆盖与缺项:`/docs/other-research/ai-company-research/coverage`。
|
||||
|
||||
正文、搜索、研究 API 与静态资料均经过站点登录认证,私有响应使用 `private, no-store`。Markdown 支持 GFM 表格,不解析原始 HTML;正文图片写入时检查压缩格式。草案标记用于展示状态,不构成单独的访问权限。
|
||||
|
||||
## 管理文档
|
||||
|
||||
部署后,管理员可在服务器上使用 `/opt/endorphin-tabularium-docs/docs`。该工具在当前容器中操作数据库,需要服务器运维权限。自动化上传使用下文的文档 API,日常读写无需登录服务器。
|
||||
|
||||
```sh
|
||||
sudo /opt/endorphin-tabularium-docs/docs list
|
||||
@@ -20,176 +40,239 @@ sudo /opt/endorphin-tabularium-docs/docs get endor/endor-purpose-draft
|
||||
sudo /opt/endorphin-tabularium-docs/docs history 04-endor-purpose-draft
|
||||
```
|
||||
|
||||
新建或修改使用标准输入传入完整 JSON。必填字段:`slug`、`sourceSlug`、`title`、`category`、`collection`、`group`、`description`、`draft`、`featured`、`content` 和 `revision`。新建时 `revision: 0`;修改时先 get 并使用读到的 revision,避免覆盖别人刚写入的版本。`sourceSlug` 是稳定标识,修改公共 slug 时保持它不变,旧地址继续可用。调整目录直接修改 category、collection、group 等字段。
|
||||
`save` 从标准输入接收完整 JSON。必填字段如下:
|
||||
|
||||
| 字段 | 用途 |
|
||||
| --- | --- |
|
||||
| `slug` | 语义化公开地址,不含数字章节前缀 |
|
||||
| `sourceSlug` | 稳定标识;修改公开地址时保持不变 |
|
||||
| `title`、`content` | 标题与 Markdown 正文 |
|
||||
| `category`、`collection`、`group` | 分类、合集与目录分组 |
|
||||
| `description`、`draft`、`featured` | 摘要、草案状态与推荐状态 |
|
||||
| `revision` | 新建传 `0`;修改传最近一次读取到的版本号 |
|
||||
|
||||
修改现有文档的流程:
|
||||
|
||||
```sh
|
||||
sudo /opt/endorphin-tabularium-docs/docs get endor/endor-purpose-draft > /private/work/purpose.json
|
||||
# 在私有文件中修改正文或目录字段,保留读到的 revision。
|
||||
sudo /opt/endorphin-tabularium-docs/docs save < /private/work/purpose.json
|
||||
umask 077
|
||||
doc_work_dir=$(mktemp -d)
|
||||
sudo /opt/endorphin-tabularium-docs/docs get endor/endor-purpose-draft > "$doc_work_dir/document.json"
|
||||
# 编辑该私有 JSON 文件,保留读取到的 revision 和 sourceSlug。
|
||||
sudo /opt/endorphin-tabularium-docs/docs save < "$doc_work_dir/document.json"
|
||||
```
|
||||
|
||||
路径仅为操作示例,应选择实际存在且受保护的目录。运行成功后刷新网页即可看到正文、导航和搜索更新。版本冲突时重新读取并合并修改;不要把旧 revision 改成新数字强行覆盖。草案是现有展示标记,不是按分支或用户隔离的访问控制。
|
||||
保存成功后,正文、目录和搜索通过后续请求读取新内容。发生版本冲突时重新读取并合并修改,不能仅改大 `revision` 覆盖并发编辑。调整分类或分组也走同一保存流程;`history` 使用稳定的 `sourceSlug` 查询修订。
|
||||
|
||||
新增正文不提交回 Git。奇思妙想这类内容同样通过数据库写入;本次用户明确要求汇总四个 PR,因此该草案已纳入首次迁移清单,保留草案状态。代码分支与数据发布是不同动作,其他分支的内容不会自动发布。
|
||||
## 文档上传 API
|
||||
|
||||
## 首次迁移与备份
|
||||
文档 API 直接读写同一个数据库,供自动化整理和大量文档上传使用。它只接受独立的文档 API Key,不接受网页 Cookie 或通用网页会话令牌;Key 不能用于研究、登录或其他站点 API。
|
||||
|
||||
本次汇总先合入 PR #14、#15、#16,再应用 PR #17 的数据库迁移。最新研究正文及奇思妙想草案合计 51 篇,连同目录可从固定历史提交 `cffca6942b1bc0005321e1685f38593fb0b853d5` 完整导出;其中 AI 公司研究三篇采用汇总期间追加的 `74af205b2959e1bb6a1fe9f567bc87069ac6d12c` 历史版本,保留 Wabi 更新:
|
||||
| 方法与地址 | 用途 |
|
||||
| --- | --- |
|
||||
| `GET /api/documents?limit=50&offset=0` | 分页读取目录元数据;不返回全部正文 |
|
||||
| `GET /api/documents/item/<slug>` | 读取单篇完整正文、目录字段和当前 revision;支持旧地址 |
|
||||
| `GET /api/documents/item/<slug>?history=1&limit=50&offset=0` | 分页读取修订元数据与时间,按新到旧排列 |
|
||||
| `GET /api/documents/item/<slug>?revision=1` | 读取指定修订的完整正文 |
|
||||
| `POST /api/documents` | 保存一个完整文档 JSON;字段与 `docs save` 相同 |
|
||||
| `POST /api/documents/batch` | 保存 `{ "documents": [...] }`,每个请求 1—100 篇 |
|
||||
| `GET /api/documents/sections/<slug>` | 读取章节 ID、标题和内部摘要,不返回正文 |
|
||||
| `GET /api/documents/sections/<slug>?section=<id>` | 读取选中章节正文;重复 `section` 可选多章 |
|
||||
| `GET /api/documents/sections/<slug>?revision=2` | 读取对应修订的章节目录;也可搭配 `section` |
|
||||
| `POST /api/documents/sections/<slug>` | 原子执行章节新增、更新、删除和移动 |
|
||||
|
||||
写入使用 `Authorization: Bearer <文档 API Key>`。新建传 `revision: 0`;更新先读取当前全文,再使用读到的 revision。每篇正文最多 2,000,000 个 JavaScript 字符单位;每次请求最多 8 MiB。目录和修订分页的 `limit` 范围为 1—100,响应包含 `total` 和 `nextOffset`。
|
||||
|
||||
单个批量请求在同一事务提交,任意一篇格式错误、版本冲突或地址冲突都会整体回滚。成功返回文档地址、稳定标识和新修订号;版本或幂等键冲突返回 `409`,格式错误返回 `400`,超出请求大小返回 `413`。缺少、过期或撤销的 Key 返回 `401`,只读 Key 写入返回 `403`。
|
||||
|
||||
建议写入时提供 `Idempotency-Key`,由 1—128 个字母、数字或 `._:-` 组成。同一个 Key、相同请求 ID 和相同输入重试时,返回原回执并标记 `replayed: true`,不追加修订;相同 ID 改换输入会冲突。回执描述原请求,不代表之后没有其他修改;需要当前内容时重新读取文档。改过正文或 revision 后,应使用新的请求 ID。
|
||||
|
||||
### 先读摘要,再按章节编辑
|
||||
|
||||
章节接口使用相同的文档 Key 和读写权限。编辑流程为:先读章节目录及摘要,选择需要深入的章节,再读取这些章节的正文,最后提交一组操作。不要只凭摘要修改未读正文;目录与正文响应的 `revision` 应一致,否则重新读取。接口不调用模型生成摘要:由编辑 Agent 阅读正文、撰写摘要,并在编辑请求中保存。
|
||||
|
||||
章节按行首 Markdown ATX 二级标题(`##`)划分,三级至六级标题随所属章节一起编辑;代码围栏内的标题不会切章。二级标题之前的文档标题及导言属于固定 ID `preamble`,可以修改但不能删除或移动。Setext 标题、带缩进或引用、列表标记的标题不作为章节边界。正文无行首二级标题时,全文作为导言处理。拆分及组合保持换行和未编辑内容原样,不自动格式化 Markdown。
|
||||
|
||||
目录中的每章包含 `id`、`title`、`position`、`summary`、`summaryStatus` 和 `contentHash`。摘要为编辑内部数据,单独保存在 SQLite 的 `document_sections` 和 `document_section_revisions` 中,不加入普通文档响应、网页 props、搜索索引或页面正文。完整数据库备份包含这些表;`docs export` 仍只导出当前文档,不包含内部摘要和修订。
|
||||
|
||||
旧文章无需重写,首次读取即可按原文返回目录,初始摘要为空,`summaryStatus` 为 `missing`。Agent 应按需读取这些章节,生成摘要并通过 `update` 保存;保存后状态为 `current`。这里的 `current` 仅表示摘要与该版正文一起保存,不保证模型的总结正确。全文接口仍兼容;未变化章节保留摘要与 ID,正文变化的章节清空摘要,等待更新。全文改名且改写章节时可能产生新 ID,因此全文保存后应重新读取目录。
|
||||
|
||||
章节编辑请求示例(示例 ID 需要替换为目录实际返回的值):
|
||||
|
||||
```json
|
||||
{
|
||||
"revision": 3,
|
||||
"operations": [
|
||||
{
|
||||
"op": "update",
|
||||
"id": "s_existing",
|
||||
"content": "## 原有章节\n\n修改后的正文。\n\n",
|
||||
"summary": "本章说明修改后的核心观点与边界。"
|
||||
},
|
||||
{
|
||||
"op": "insert",
|
||||
"after": "s_existing",
|
||||
"content": "## 新章节\n\n新增论证。\n\n",
|
||||
"summary": "补充上一章尚未覆盖的论证。"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`update` 可只改 `summary`,也可改正文;正文改变时若不提供新摘要,旧摘要会清空。`insert` 必须提供正文和非空摘要;摘要最多 8000 个 JavaScript 字符单位。`delete` 使用 `{ "op": "delete", "id": "s_existing" }`;`move` 使用 `{ "op": "move", "id": "s_existing", "after": "preamble" }`。按数组顺序执行,最多 100 个操作;新章 ID 由服务器生成,在回执目录中返回。章正文应从自己的 `##` 标题开始,只包含一个二级标题,并保留与下一章之间的换行;跨章代码围栏或章节边界改变会被拒绝,拆章请使用多个操作。
|
||||
|
||||
章节变更、内部摘要、组合后的全文和历史快照在同一事务内提交。每批操作只增加一次文章 `revision`;摘要单独更新也增加版本号。后续操作失败或全文版本过期时全部回滚。写入沿用幂等规则,回执包含新版本、章节目录和 `replayed`;章节接口与全文接口的幂等记录分开。修改章节不会改变其 ID,移动亦保留 ID;删除后的正文和摘要仍可从对应的旧修订读取。启用前的历史修订只有原文,摘要显示缺失。
|
||||
|
||||
Python 客户端对应命令:
|
||||
|
||||
```sh
|
||||
python3 scripts/export-legacy-documents.py --output .artifacts/documents-migration.json
|
||||
python3 scripts/documents-client.py outline endor/endor-purpose-draft
|
||||
python3 scripts/documents-client.py sections endor/endor-purpose-draft preamble s_existing
|
||||
python3 scripts/documents-client.py edit-sections endor/endor-purpose-draft /private/path/edit.json --request-id chapter-edit-1
|
||||
```
|
||||
|
||||
导出文件位于 Git 忽略目录、权限 0600,不进入镜像。脚本也支持显式 `--ref`,但不能用未评审分支替换生产迁移基线。它不会在站点读取时执行。CI 使用历史导出验证全部正文与目录的逐项一致性,构建镜像只含程序,不含文档快照或数据库。
|
||||
`outline` 和 `sections` 支持 `--revision`;请求文件只包含 `revision` 和 `operations`,不要把内部摘要文件提交到 Git。
|
||||
|
||||
首次部署先备份整个共享数据库,再显式事务导入到空文档表。普通部署使用 `import --if-empty`,已有内容时跳过,不以历史数据覆盖数据库编辑;重复同一导入也不会重置内容。导入失败则事务整体回滚,既有登录与研究表保留,部署中止。
|
||||
### 创建、查看与撤销 Key
|
||||
|
||||
管理员只需通过已验证的服务器连接进行 Key 管理:
|
||||
|
||||
```sh
|
||||
sudo /opt/endorphin-tabularium-docs/docs key-create codex-upload /auth/codex-docs-key.json read-write 90
|
||||
sudo /opt/endorphin-tabularium-docs/docs key-list
|
||||
sudo /opt/endorphin-tabularium-docs/docs key-revoke <key-id>
|
||||
```
|
||||
|
||||
创建命令将完整 Key 写入认证私有目录中的指定新文件,权限 `0600`,终端仅输出 ID、权限和到期信息。文件已存在时拒绝覆盖。权限可选 `read` 或 `read-write`;有效期默认 90 天,可设 1—365 天。数据库仅保存 Key 摘要,列表不返回原文;撤销后不能继续认证。独立 Key 文件和认证 config.json 都不包含在 SQLite 整库备份中。
|
||||
|
||||
### 使用上传客户端
|
||||
|
||||
`scripts/documents-client.py` 仅使用 Python 标准库。通过 `TABULARIUM_DOCS_URL` 指定站点 origin,通过 `TABULARIUM_DOCS_KEY_FILE` 指定本机私有 Key 文件(支持创建命令输出的 JSON),不把 Key 放入命令行参数。
|
||||
|
||||
客户端要求 HTTPS;尚未配置 HTTPS 时,可通过已验证的 SSH 连接建立本机端口转发,连接 `http://127.0.0.1:18136`。以下 `$DOCS_SSH_TARGET` 应事先设置为受信任的 SSH 别名或 `user@host`:
|
||||
|
||||
```sh
|
||||
ssh -N -L 18136:127.0.0.1:18135 "$DOCS_SSH_TARGET"
|
||||
```
|
||||
|
||||
将服务器私有目录中的 Key 文件复制到受保护的本机位置后,在另一个终端配置客户端。`$TABULARIUM_DOCS_KEY_FILE` 文件须仅本人可读写,不能提交到仓库:
|
||||
|
||||
```sh
|
||||
export TABULARIUM_DOCS_URL=http://127.0.0.1:18136
|
||||
export TABULARIUM_DOCS_KEY_FILE="$HOME/.config/tabularium/codex-docs-key.json"
|
||||
python3 scripts/documents-client.py list --limit 50
|
||||
python3 scripts/documents-client.py get endor/endor-purpose-draft
|
||||
python3 scripts/documents-client.py get endor/endor-purpose-draft --history
|
||||
python3 scripts/documents-client.py get endor/endor-purpose-draft --revision 1
|
||||
```
|
||||
|
||||
修改流程与 CLI 相同:
|
||||
|
||||
```sh
|
||||
umask 077
|
||||
api_work_dir=$(mktemp -d)
|
||||
python3 scripts/documents-client.py get endor/endor-purpose-draft > "$api_work_dir/document.json"
|
||||
# 编辑正文或目录字段,保留 sourceSlug 和读取到的 revision。
|
||||
python3 scripts/documents-client.py save "$api_work_dir/document.json"
|
||||
```
|
||||
|
||||
大量文档可使用仅含 `documents` 数组的私有 JSON 文件。客户端按 100 篇和 8 MiB 请求上限自动分批,并为各批派生固定的幂等请求 ID。每批独立提交;后续批次失败时,前面的成功批次保留。客户端会打印根请求 ID 和各批进度,使用相同 ID 与未修改的输入重试可跳过已完成的批次;可通过 `--request-id` 显式指定根 ID(客户端最多 100 个字符)。若后续批次因业务冲突失败,按进度中已提交的文档数量提取尚未成功的部分,修正后使用新的根 ID 上传。
|
||||
|
||||
客户端只自动重试一次临时网络或 `502/503/504` 错误,保持正文和请求 ID 不变;`409` 等业务错误不会自动覆盖。它不跟随 HTTP 重定向,避免将 Bearer 凭据转发到其他地址。正文和 Key 继续保存在数据库或私有文件中,不因使用 API 而进入 Git。
|
||||
|
||||
### 上传正文图片
|
||||
|
||||
使用前述 HTTPS 或 SSH 隧道配置以及 `read-write` 文档 API Key:
|
||||
|
||||
```sh
|
||||
python3 scripts/documents-client.py upload-image ./diagram.png
|
||||
```
|
||||
|
||||
客户端向 `POST /api/documents/images` 发送原始图片字节,`Content-Type` 为 `image/png`、`image/jpeg` 或 `image/webp`。服务端接受最大 8 MiB、2500 万像素的静态图,转换为最大 2000 × 4000 像素、2 MiB 的 WebP,并按处理后内容去重。
|
||||
|
||||
把响应中的 `url` 放入 Markdown,例如 ``,然后通过章节 API 保存正文。登录读者和文档 API Key 均可读取图片。图片存入现有持久化 SQLite,同步纳入整库备份。仅导出正文的 `docs export` 保留图片引用,迁移时需使用包含图片的整库备份。
|
||||
|
||||
## 研究数据与检查
|
||||
|
||||
研究工作台提供公司筛选、经营指标、原始证据、收费与交付模式、技术材料及核查记录。它区分公司披露、支付平台指标、第三方估算、融资和客户收益;未知收入不填零,材料可访问也不代表性能或商业效果已经验证。研究范围、样本限制和个案结论以工作台记录为准。
|
||||
|
||||
| 仓库资料 | 用途 |
|
||||
| --- | --- |
|
||||
| `content/research/ai-companies.seed.json` | 研究数据导入快照;更新必须更换 `version` |
|
||||
| `content/research/ai-companies.sources.json` | 来源范围、用途、覆盖边界与后续缺项 |
|
||||
| `content/research/ai-companies.coverage.json` | 候选池逐条筛选台账 |
|
||||
| `content/research/ai-income-analysis.json` | 独立复算的统计参考快照 |
|
||||
|
||||
服务端以事务导入未载入的版本,保留旧研究版本与登录、文档数据;同一版本标识下内容变化会拒绝导入。页面和 `GET /api/research/ai-companies` 从数据库读取对应研究版本。回退程序可能选择旧研究版本,不能等同于撤销数据库中的正文编辑。
|
||||
|
||||
以下 Python 检查不触发编译或构建,可在工作分支执行:
|
||||
|
||||
```sh
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 scripts/audit-ai-research.py --check
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 scripts/summarize-ai-income.py --check
|
||||
```
|
||||
|
||||
若需对照研究数据检查报告正文,先用 `docs export` 导出当前文档,再给对应脚本传入 `--document-export <导出文件>`。默认 `--check` 核对研究数据的统计与审计不变量,不将历史迁移快照当作当前正文。脚本不带参数时仅在 `.artifacts/reports` 生成候选正文,需审阅后通过 `docs save` 写入数据库。
|
||||
|
||||
## 备份与首次迁移
|
||||
|
||||
```sh
|
||||
sudo /opt/endorphin-tabularium-docs/docs backup /auth/manual-backup.sqlite
|
||||
sudo /opt/endorphin-tabularium-docs/docs export /auth/current-documents.json
|
||||
```
|
||||
|
||||
backup 是包含登录、研究、正文、别名和修订的完整 SQLite 一致性备份;export 只导出当前正文与目录,作为空库初始化/外部迁移快照,不代替完整备份或恢复修订历史。输出路径均在容器内,文件已存在时拒绝覆盖。备份含认证状态,应留在私有挂载或受控备份位置,不能放到公开静态目录、仓库或日志。
|
||||
路径在容器内,输出文件已存在时拒绝覆盖。`backup` 是整个共享 SQLite 数据库的一致性备份,包含登录状态、研究数据、正文、别名和修订;`export` 只保存当前正文与目录,供空库初始化或外部迁移使用,不包含完整修订历史。认证配置单独保存在 `/auth/config.json`,整库备份不包含该配置文件。备份与导出应保存在私有挂载或受控备份位置。
|
||||
|
||||
代码回退不会撤销数据库编辑,但回退到旧文件式站点会显示旧正文。恢复完整数据库会同时影响登录和研究状态,必须先停止写入、保留当前备份并由管理员明确选择恢复点;部署脚本不会自动恢复数据库或删除新内容。
|
||||
首次迁移由 `scripts/export-legacy-documents.py` 从固定历史版本导出正文和目录,基线与研究正文覆盖版本在脚本中明确记录。导出写入 Git 忽略目录,权限为 `0600`,不进入生产镜像:
|
||||
|
||||
## 代码验证
|
||||
```sh
|
||||
python3 scripts/export-legacy-documents.py --output .artifacts/documents-migration.json
|
||||
```
|
||||
|
||||
使用 React、Next.js Pages Router、TypeScript 和 Tailwind CSS。JavaScript 用 pnpm,制图 Python 用 uv。编译、构建、打包和构建产物全部在已确认的 ces-control Runner 执行;`pnpm test:docs` 等测试脚本会调用 esbuild,也只能在 Runner 执行。
|
||||
该命令用于迁移准备,不是日常写作入口;目标文件存在时拒绝覆盖。部署先备份共享数据库,再用 `import --if-empty` 向空文档表事务导入。已有文档时跳过,不覆盖后续编辑;导入失败整体回滚并中止部署。`--ref` 仅用于明确选择历史导出版本。
|
||||
|
||||
工作分支与 PR 不运行 CI,仅做不会触发编译、构建的轻量检查。用户手动合并 main 后,才在 ces-control 执行 lint、类型检查、数据库事务/版本测试、现有认证与研究测试、图形检查、构建与容器冒烟;验证通过才沿用既有部署流程。
|
||||
程序回退不会撤销数据库编辑;回退到旧文件式站点则会显示旧正文。完整数据库恢复会同时影响登录、研究和文档状态,需先停止写入、保留当前备份,再明确选择恢复点。部署脚本只回退镜像,不自动恢复数据库。
|
||||
|
||||
冒烟使用隔离数据库卷:完整导入后逐项核对全部正文与目录,验证新建、更新、重命名、搜索立即变化及容器重启后的持久化,并覆盖全部文档地址、认证、媒体和原文端点。测试卷不会触及生产数据库。
|
||||
## 登录管理
|
||||
|
||||
`tools/figures/src/endor_figures` 与 `apps/docs/public/figures` 仍保存图形源码及可复现的静态资源。图形构建也在远程执行。
|
||||
|
||||
## 动态码登录与二维码
|
||||
|
||||
与 PROMPT 的参考流程一致:共享验证器的6位TOTP动态码,每30秒更新;成功登录的HttpOnly、SameSite=Lax会话有效7天。此方案是动态码单因素登录,不是密码加动态码的双因素组合。
|
||||
|
||||
正文、SVG、文档数据与API都经过认证,仅登录入口、登录所需代码资源和最小健康检查公开。20次/5分钟的共享尝试上限与已用时间步持久保存在SQLite中,跨进程和重启继续生效。缺失或损坏配置时拒绝访问,不自动降为公开站点。
|
||||
|
||||
生产认证文件位于服务器 `/opt/endorphin-tabularium-docs/private-auth`,目录0700、文件0600,只挂载进本项目容器。部署只初始化一次,后续更新保留同一验证器密钥与会话签名密钥。生产镜像、Git、CI日志和网页不包含密钥。
|
||||
|
||||
管理员通过SSH管理:
|
||||
站点使用共享验证器的 6 位 TOTP 动态码,每 30 秒更新。会话使用 HttpOnly、SameSite=Lax Cookie,有效期 7 天;共享尝试上限为每 5 分钟 20 次,重放与限流状态在数据库中持久保存。缺少或损坏认证配置时拒绝私有访问。
|
||||
|
||||
```sh
|
||||
sudo /opt/endorphin-tabularium-docs/auth qr
|
||||
sudo /opt/endorphin-tabularium-docs/auth verify
|
||||
```
|
||||
|
||||
`qr`只允许交互终端显示;`qr-png`用于通过SSH将PNG直接重定向到私有本地文件,不在CI中运行。二维码等同登录凭据,不放入本仓库或网页。只有明确执行 `auth rotate --confirm` 才更换密钥并使旧二维码、会话失效;重新显示或下载二维码不会轮换。
|
||||
认证配置和数据库位于服务器 `/opt/endorphin-tabularium-docs/private-auth`。正常部署保留验证器与会话签名密钥。`qr` 只在交互终端显示,`qr-png` 可导出到私有文件;只有 `auth rotate --confirm` 会轮换密钥并使旧会话失效。二维码和令牌不进入仓库、网页或 CI 日志。
|
||||
|
||||
构建、打包与会触发构建的测试只在用户确认的 ces-control Runner 上执行。本地不执行 `pnpm dev`、`pnpm build`、`pnpm typecheck` 或打包测试。
|
||||
当前 Compose 配置默认 `AUTH_COOKIE_SECURE=false`;实际采用 HTTPS 时,应在独立的生产配置中设置为 `true`。HTTP 不加密动态码与会话传输。生产部署使用无副作用的 `verify`,不执行会消耗动态码和触发限流的 `exercise`。
|
||||
|
||||
当前服务器入口为HTTP,与参考项目一致使用 `AUTH_COOKIE_SECURE=false`;配置HTTPS后应在独立production.env设置 `AUTH_COOKIE_SECURE=true`。HTTP本身不加密动态码和会话传输。
|
||||
## 开发、验证与发布
|
||||
|
||||
`pnpm test:auth`运行算法、会话、重放及限流测试。容器验证使用自己的匿名测试卷完成登录/并发重放/CSRF/限流测试,并在重启后检查状态仍存在。生产部署只运行无副作用的 `verify`,不会消耗用户动态码或触发生产限流。
|
||||
代码依赖要求见 `package.json` 和锁文件;当前要求 Node.js 22.13+、pnpm 11.25.0,制图使用 Python 与 uv。构建、打包、制图及会隐式编译的检查只在已确认的 `ces-control` Runner 执行,本机不执行这些步骤。
|
||||
|
||||
## AI 公司研究
|
||||
工作分支完成轻量检查后推送并创建 PR,由用户手动合并。**CI 仅在 main 推送或针对 main 的手动运行时执行,工作分支和 PR 不运行 CI。** 不自动合并,也不因确认 Runner 而扩大触发范围。
|
||||
|
||||
导航入口为 **其他研究 → AI 公司研究**,页面路径是
|
||||
`/docs/other-research/ai-company-research/overview`。工作台提供业务分类、公司搜索与筛选、
|
||||
公司明细、收入观测、原始证据和数据核查记录。所有页面与
|
||||
`GET /api/research/ai-companies` 均使用文档站现有的登录认证。
|
||||
主线通过 `.gitea/workflows/ci-deploy.yml` 执行 `scripts/ci.py`:
|
||||
|
||||
研究业务数据本轮暂不迁移,仍沿用版本化 JSON 导入机制。研究数据、文档与登录状态**共用文档站现有的 SQLite 数据库文件**:
|
||||
`AUTH_CONFIG_FILE` 同目录下的 `login-state.sqlite`。该文件名是历史名称,未创建第二个数据库。
|
||||
`login_state` 保留登录限流与重放状态,新增的 `research_*` 表保存数据版本、分类、
|
||||
公司、经营指标、证据与核查记录。沿用现有 `/auth` 持久挂载,数据库不会写入镜像或公开目录。
|
||||
1. 检查研究统计、审计与 Python 测试,生成首次迁移所需的历史导出。
|
||||
2. 根据程序、研究快照、图形、锁文件和构建脚本计算内容指纹。命中 verified 镜像时复用,否则安装依赖并执行 lint、类型检查、数据库测试、图形检查与构建。
|
||||
3. 使用隔离数据库卷核对完整迁移、认证、全部文档与资源,以及不重建内容更新和重启持久化;通过后标记镜像为 verified。
|
||||
4. `scripts/deploy.py` 确认仍为最新 main,备份并初始化文档数据,使用 `--no-build` 部署;失败时回退上一版镜像。
|
||||
|
||||
`content/research/ai-companies.seed.json` 是可评审的研究导入快照,
|
||||
服务端首次读取该版本时将它以事务导入同一数据库,之后从数据库查询记录。
|
||||
导入使用外键校验,不删除登录数据,也不覆盖旧研究版本。每次更新快照必须修改 `version`;
|
||||
同一版本内容变化会拒绝导入,避免静默读取旧数据。回退代码时继续读取对应旧版本。
|
||||
所有修改都经过工作分支和 PR,部署仍等待人工合并主线。
|
||||
`pnpm check`、`pnpm typecheck`、`pnpm test:auth`、`pnpm test:research`、`pnpm test:docs`、`pnpm figures`、`pnpm build` 和 `pnpm dev` 等命令涉及编译、构建或产物生成,不在本机运行。图形源码与生成资源保持可复现,前端继续使用 Pages Router。
|
||||
|
||||
当前资料包含 1,021 条公司/产品记录、10 类业务及待分类组、1,209 条证据记录和 189 条经营与技术观测。
|
||||
其中保留 93 家个案资料、912 条目录基础资料,并新增 16 个平台产品/业务样本;目录项不等于完成财务和技术核验。
|
||||
Product Hunt两年档案各首批19张、合计38张发布/推广卡片以`candidateBatches`保存;公司成立年待核者不自动新增公司。其中18条仍待核成立年;排除依据区分正式成立字段与2025前已运营证据,精确注册日期未核者明确标注。候选元数据与其他研究记录同库持久化,覆盖报告逐条展示状态、缺项和公司关联。Kilo Code与KiloClaw两项映射一个已被收购的2025公司个案,采用规模、token吞吐及同名健康公司的收入不作为其ARR。
|
||||
机器之心首批4条公司线索以独立候选批次记录:直达正文未读,经公司公开主页、官方代码或发起方公告补核PrismShadow与Stargate;SSI用2024官方历史更新排除,Utopai仍待核。不是两年中文来源全量。
|
||||
TechCrunch栏目首批3条公司候选已追溯原始材料;Wabi据两个公开名录2025年份入表,精确注册日未核。官方条款称当前免费,收入仍未知,不填零。DreamHQ及Thrive Holdings成立年仍待核,后者2025公开发布公告另存。DreamHQ与名录同名摄像头公司区分,ADT拟收购尚待交割,融资、估值和目标公司业绩不当新AI公司的ARR。分类接口与RSS访问403及两年档案缺项保留。
|
||||
Eloquent补按结果/用量混合收费与工作流自述;Peec补品牌套餐、招聘中的ARR里程碑和团队下界。招聘发布日期不当业务截止日,报价与客户降本不当供应商收入。Gojiberry报价保留原文$,未见币种代码不强转美元。平台MRR样本与统计不变。
|
||||
Bessemer、Menlo、Stripe年度信与SaaS Capital的已核方法和访问限制另存为背景基准适用性表;不将高增长选样、采购支出、平台商户或增长率摘要当新AI公司ARR分布。SaaS Capital完整报告及各来源底表未取得者继续留缺。
|
||||
补充ThinkWheel与中国NaiveAI;MiroMind成立年未核,公开研发年份单列。保留灵初智能成立年排除、深度原理日期待核和Naive近名实体复核记录。
|
||||
新增7条Speedrun个案,保留标题/正文冲突、七位数数量级、币种不明、合同管线与履历归属排除记录;相对日期不充当同期收入时点。核查页优先按人工确认的公司ID关联明细,旧版本保留按证据归属回退;74条核查中54条指向具体公司、8条属于全局方法、12条是未纳入候选。明细反向列出关联核查;币种不明的声明保留原值,不绘制美元收入长度条。
|
||||
Lightfield按Magical Tome主体与2025前历史业务列为CRM转型对照,成立年未知,不计新成立公司;Fundraisly成立年待核,其客户融资和创始人历史ARR不转移为公司收入。
|
||||
Bud保留为2025成立且YC标记已收购的案例;Rocket.new按前身2021成立、2025产品上线的转型对照记录,历史ARR不混入新成立公司同期统计。
|
||||
原候选36088 EveryApp从AI不明经补证入表,`directoryRechecks`保存后续复核,原1,428条台账及928条原始新增计数不改。OpenSEO与EveryApp不重复计公司;定价、星标、客户数和数据成本加价均不转为ARR。
|
||||
验收按2026-10-30确认的公开来源清单边界逐条核对并标明缺项;YC候选池有完整读取台账,其他来源目前为定向补充,不能把来源出现次数当作覆盖完成。收入披露不足且统计时点不统一,不代表全部 AI 公司。
|
||||
界面将公司自述、第三方估算、融资、客户收入和履历收入分开,未知收入不记为零。
|
||||
保留被撤回的声明作为核查记录,并排除收入比较;并购后业务、管理层预测与历史里程碑单列。
|
||||
团队规模下界在列表中显示比较符,商业成熟度展示公开交付证据,不等于技术能力排名。
|
||||
Emergent的1亿美元ARR里程碑补足至2026年2月;Peec采用规模和Wonderful团队规模各保留其公告日期,不将较新非收入指标当作ARR更新。Wonderful按2026年9月官方综合平台定位归C05。
|
||||
公司披露的 ARR 尚未建立同期同口径比较组。新增平台 MRR 单独计算探索性样本统计,
|
||||
不能称行业平均 ARR、总体离散排名或财务造假概率。
|
||||
根 README 等非构建输入变更不要求重新构建;数据库正文和目录更新也不触发构建。保留 pnpm、uv、Next.js 与 SWC 缓存,不清理共享 Docker 缓存或卷,不修改共享 Runner 或其他项目。
|
||||
|
||||
收入分布:`IncomeDistribution.tsx` 从数据库返回的观测中筛选平台 USD MRR,
|
||||
每个产品只保留符合条件的最新值,默认数据日期距截止日不超过7天。
|
||||
按业务类别、支付来源及可选收费模式分组,显示均值、中位数、样本CV(标准差分母n−1)、
|
||||
均值/中位数和移除最大值后的均值敏感性。n<3标注样本不足;n=1不计算CV。
|
||||
MRR×12仅作为显示单位转换,不改写为公司ARR;公司ARR、估算与近30日收入均不混入。
|
||||
`ai-income-analysis.json` 是独立Python复算的7日/90日快照参考,用于检查统计选择和计算,
|
||||
页面本身从同一SQLite中的公司与观测记录计算,不读取第二个数据库。
|
||||
## 部署位置与仓库
|
||||
|
||||
平台同步时间、连接过期状态、业务描述的可编辑性和产品/法律主体未去重限制均保留。
|
||||
覆盖审计页位于 `/docs/other-research/ai-company-research/coverage`,分类链接 `overview?category=<id>` 的筛选数据。
|
||||
`python3 scripts/audit-ai-research.py --check` 检查候选池逐条计数与来源审计一致性;CI在ces-control运行此检查。
|
||||
收入快照报告位于 `/docs/other-research/ai-company-research/income-snapshot`,样本名称链接 `overview?company=<id>` 的公司明细。
|
||||
`python3 scripts/summarize-ai-income.py --check` 独立复核统计与参考快照一致性;不触发编译或构建。数据变更后先审查方法,再运行不带参数的命令生成 `.artifacts/reports` 下的候选正文,使用 docs 管理工具显式更新数据库;不再生成受 Git 管理的正文。
|
||||
默认7日样本14个产品;90日窗口新增Gojiberry的8月历史点,营销样本的均值和CV随之明显变化。
|
||||
这提示先核查时间与样本构成,不能将高离散度直接解释为造假。两个近名营养应用按不同App Store ID分别建档。
|
||||
| 项目 | 位置或默认值 |
|
||||
| --- | --- |
|
||||
| Compose 项目 | `endorphin-tabularium-docs` |
|
||||
| 容器监听 | `0.0.0.0:3000` |
|
||||
| 服务器入口 | 默认端口 `18135`,由生产配置决定 |
|
||||
| 生产配置 | `/etc/endorphin-tabularium-docs/production.env` |
|
||||
| 发布记录与当前版本 | `/opt/endorphin-tabularium-docs/releases/<commit>` 与 `current` |
|
||||
| 健康检查 | `/api/health` |
|
||||
|
||||
覆盖核查:从 yc-oss 非官方镜像发现 2025—2026 八个 YC 批次的 1,428 条候选,
|
||||
逐条读取对应 YC 官方公开页面,以页面中的成立年份、团队字段和业务描述筛选。
|
||||
962 条初筛匹配,其中 34 条与原表 URL 重合,实际新增 928 条;268 条年份不符或缺失、
|
||||
192 条 AI 业务待确认、6 条成立年份晚于批次年份未纳入本批新增。
|
||||
`content/research/ai-companies.sources.json` 保留最初12项来源与后续补充渠道、用途、覆盖边界及下一步;覆盖报告逐渠道复算证据、公司及观测数量。背景报告、入口可读和候选池逐条读取分别标记,零计数不表示没有公司。
|
||||
`content/research/ai-companies.coverage.json` 保留逐条筛选结果、来源与响应摘要,
|
||||
数据快照中的 `coverage` 也随版本进入同一 SQLite 数据库。只覆盖该候选池,不能称全球全量。
|
||||
服务入口使用 `TABULARIUM_BIND`、`TABULARIUM_PORT` 配置。部署前检查端口是否空闲或属于本服务,不停止其他服务抢占端口;本项目不自动修改防火墙、域名或反向代理。
|
||||
|
||||
新增业务分类优先匹配主营简介,再参考目录标签;未匹配者进入待分类组。
|
||||
初分规则位于 `scripts/classify-ai-profiles.py`,只生成待评审标签,不执行抓取或构建。
|
||||
目录基础项保留 `classificationBasis` 和 `directoryTags` 供复核。
|
||||
当前目录基础项中160条已人工复核主营与买家,Contrario及14条收入声明/排除案例已深化为个案。`directoryOrigin`保留原发现来源,`profileOnly`只表示当前资料深度;深化不改变原候选池新增928条的审计分母。
|
||||
分类按主要工作任务和买家判断,例如Paragon归客户与收入运营,Oki归消费者应用,Lucidic归Agent设施。市场支出、采用规模、性能主张及客户收益均不作为公司收入。
|
||||
其中121条原待分类记录已按YC官方业务介绍人工复核主营业务,分类理由保存在公司字段与对应证据摘要中;
|
||||
仍有3条因简介不足保留待分类。人工复核分类不代表独立验证技术水平、客户效果或财务数据。
|
||||
AI 关键词可能出现在业务背景中,收入、买家和交付状态仍须逐家深查,不能把目录项的未知收入当作未披露。
|
||||
技术专项评审用独立字段记录层级、日期、摘要与证据:未评审、已读技术自述、公开代码/模型材料可访问、有第三方独立评测记录。
|
||||
当前14条完成该字段的专项记录,其中6条确认材料可访问、1条记录第三方已发表评测(Gradium TTS Sep 2026);本轮未自行运行模型测试。缺字段不表示无技术能力,代码或模型卡可读不证明性能。
|
||||
界面支持按资料深度、记录团队规模和技术验证证据筛选;证据列表每页 30 条,避免一次渲染全部来源。
|
||||
|
||||
`pnpm test:research` 在远程 Runner 上验证同库持久化、重复读取、版本导入与回退、
|
||||
登录限流保留、引用完整性及收入排除规则。镜像冒烟检查会验证研究页面与 API 的认证、
|
||||
返回记录数量、只读方法和容器重启后的读取结果。页面筛选交互仍需实际浏览器验证。
|
||||
|
||||
## CI/CD 与缓存
|
||||
|
||||
文档内容已退出构建输入。若需对照研究数据检查数据库中的报告正文,可先导出当前文档,再给研究脚本传入 `--document-export`;默认 `--check` 只核对研究数据本身的统计和审计不变量,避免把历史迁移快照继续当作日常正文源。
|
||||
|
||||
`.gitea/workflows/ci-deploy.yml` 使用已有 ces-control Runner,不修改共享 Runner 配置。
|
||||
工作流仅在 main 推送或针对 main 的手动运行时执行,工作分支与 PR 不触发 CI。确认 ces-control 是构建位置,不构成新增分支 CI 的授权;报告须区分轻量检查与主线 CI。
|
||||
|
||||
1. 对实际构建输入计算 SHA-256:前端源码、图片、Python 制图源码、研究业务快照、依赖锁文件、Dockerfile 和构建脚本。
|
||||
2. 命中 `verified-<scope>-<摘要>` 镜像时跳过依赖安装和 Next.js build,只做认证、实际路由与资源冒烟检查。
|
||||
3. 输入变化时运行 lint、类型检查、制图一致性检查与构建。pnpm store、uv 和 `.next/cache` 使用持久 Docker 卷,main 与每个 PR 独立;额外保留 Next.js SWC 下载缓存。
|
||||
4. 生产镜像只包含 Next.js standalone 产物;Python 留在检查容器,不进入运行镜像。镜像通过动态码认证、重放/限流、重启持久化、健康、全部文档、JS/CSS、SVG 和 404 检查后才标记为 verified。
|
||||
5. 部署前检查是否仍为最新 main,使用 `--no-build` 启动,失败恢复上一版镜像。
|
||||
|
||||
根 README、工作流或部署配置等非构建输入变化不会触发 Next.js 重建;数据库中的正文和目录变化直接通过请求读取,不触发任何构建。首次构建必须安装依赖并完整生成产物。缓存可能因基础镜像、工具链或锁文件变化而失效;不要清理共享 Docker 缓存或卷。
|
||||
|
||||
默认入口为服务器的 **18135** 端口。容器监听 3000,使用独立 Compose 项目 `endorphin-tabularium-docs`。部署前先检查端口是否空闲(或属于本服务),不会停止其他服务以抢占端口。服务器配置位于 `/etc/endorphin-tabularium-docs/production.env`:
|
||||
|
||||
```dotenv
|
||||
TABULARIUM_BIND=0.0.0.0
|
||||
TABULARIUM_PORT=18135
|
||||
```
|
||||
|
||||
发布记录位于 `/opt/endorphin-tabularium-docs/releases/<commit>`,`current` 指向最近成功发布。修改服务器入口配置后手动重跑 main 工作流即可,复用现有镜像。
|
||||
|
||||
健康检查:`/api/health`。本项目不修改 Hestia、PROMPT 或其他服务,也不自动修改防火墙、域名和反向代理。
|
||||
|
||||
## Git 远程
|
||||
|
||||
仓库:[endorphin-tabularium-docs](https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs)。开发者 Git 操作使用 codex-bot 专用 SSH;Actions 检出使用 Gitea 内置任务令牌,不保存检出凭据。
|
||||
仓库:[endorphin-tabularium-docs](https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs)。Git 操作仅使用 `codex-bot` 专用 SSH 身份与仓库级提交配置;Actions 检出使用 Gitea 内置任务令牌且不保存凭据。
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-markdown": "10.1.0",
|
||||
"remark-gfm": "4.0.1"
|
||||
"remark-gfm": "4.0.1",
|
||||
"sharp": "0.35.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
@@ -33,7 +34,6 @@
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "16.4.3",
|
||||
"postcss": "8.5.31",
|
||||
"sharp": "0.35.5",
|
||||
"tailwindcss": "4.3.3",
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
totp,
|
||||
isLoginRedirect,
|
||||
} from "../src/lib/auth";
|
||||
import { createDocumentKey, revokeDocumentKey } from "../src/lib/document-keys";
|
||||
import { readDocument, saveDocument } from "../src/lib/docs";
|
||||
import { consumeCode, initializeStore } from "../src/lib/auth-store";
|
||||
|
||||
@@ -181,10 +182,10 @@ async function main() {
|
||||
assert.equal(researchResponse.status, 200, "authenticated research API");
|
||||
assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store"));
|
||||
const research = await researchResponse.json();
|
||||
assert.equal(research.companies.length, 1021);
|
||||
assert.equal(research.companies.length, 1312);
|
||||
assert.equal(research.categories.length, 11);
|
||||
assert.equal(research.evidence.length, 1209);
|
||||
assert.equal(research.observations.length, 189);
|
||||
assert.equal(research.evidence.length, 5354);
|
||||
assert.equal(research.observations.length, 495);
|
||||
assert.equal((await request("/api/research/ai-companies", { method: "POST", headers })).status, 405);
|
||||
const researchPage = await request("/docs/other-research/ai-company-research/overview", { headers });
|
||||
assert.equal(researchPage.status, 200);
|
||||
@@ -210,6 +211,7 @@ async function main() {
|
||||
assert.equal(raw.status, 200);
|
||||
assert.ok((await raw.text()).startsWith("# Endor"));
|
||||
if (process.env.DOCUMENT_SMOKE_TEST) {
|
||||
if (command === "exercise") await exerciseDocumentApi(request, headers);
|
||||
const sourceSlug = "ci-live-document";
|
||||
const previous = readDocument(sourceSlug);
|
||||
if (command === "exercise") {
|
||||
@@ -356,3 +358,42 @@ main().catch((error) => {
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
async function exerciseDocumentApi(request: (path: string, init?: RequestInit) => Promise<Response>, session: Record<string, string>) {
|
||||
const writer = createDocumentKey("isolated API smoke", "read-write", 1);
|
||||
const reader = createDocumentKey("isolated read smoke", "read", 1);
|
||||
const headers = { Authorization: "Bearer " + writer.token, "Content-Type": "application/json", "Idempotency-Key": "smoke-batch" };
|
||||
const readHeaders = { Authorization: "Bearer " + reader.token, "Content-Type": "application/json" };
|
||||
const make = (name: string) => ({ slug: `endor/${name}`, sourceSlug: name, title: "API smoke",
|
||||
category: "Endor", collection: "Endor 文档", group: "CI", description: "Isolated API smoke",
|
||||
draft: true, featured: false, content: "# API smoke", revision: 0 });
|
||||
const payload = { documents: [make("ci-api-first"), make("ci-api-second")] };
|
||||
try {
|
||||
assert.equal((await request("/api/documents")).status, 401);
|
||||
assert.equal((await request("/api/documents", { method: "POST", headers: { ...session, "Content-Type": "application/json" }, body: JSON.stringify(payload.documents[0]) })).status, 401);
|
||||
assert.equal((await request("/api/research/ai-companies", { headers })).status, 401, "Document keys cannot read research");
|
||||
assert.equal((await request("/api/search", { headers })).status, 401, "Document keys cannot authenticate other APIs");
|
||||
assert.equal((await request("/api/documents/batch", { method: "POST", headers: readHeaders, body: JSON.stringify(payload) })).status, 403);
|
||||
const saved = await request("/api/documents/batch", { method: "POST", headers, body: JSON.stringify(payload) });
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal((await saved.json()).documents.length, 2);
|
||||
const replay = await request("/api/documents/batch", { method: "POST", headers, body: JSON.stringify(payload) });
|
||||
assert.equal(replay.status, 200); assert.equal((await replay.json()).replayed, true);
|
||||
const current = await request("/api/documents/item/endor/ci-api-first", { headers: readHeaders });
|
||||
assert.equal(current.status, 200); assert.equal((await current.json()).revision, 1);
|
||||
const page = await request("/api/documents?limit=1", { headers: readHeaders });
|
||||
assert.equal(page.status, 200);
|
||||
const listing = await page.json();
|
||||
assert.equal(listing.documents.length, 1); assert.equal(listing.documents[0].content, undefined);
|
||||
const bad = { documents: [{ ...payload.documents[0], revision: 1, content: "Must roll back" }, payload.documents[1]] };
|
||||
const conflict = await request("/api/documents/batch", { method: "POST", headers: { ...headers, "Idempotency-Key": "smoke-conflict" }, body: JSON.stringify(bad) });
|
||||
assert.equal(conflict.status, 409);
|
||||
const unchanged = await request("/api/documents/item/endor/ci-api-first", { headers: readHeaders });
|
||||
assert.equal((await unchanged.json()).content, "# API smoke");
|
||||
const history = await request("/api/documents/item/endor/ci-api-first?history=1", { headers });
|
||||
assert.equal(history.status, 200); assert.equal((await history.json()).total, 1);
|
||||
revokeDocumentKey(writer.id);
|
||||
assert.equal((await request("/api/documents", { headers })).status, 401);
|
||||
} finally { revokeDocumentKey(writer.id); revokeDocumentKey(reader.id); }
|
||||
console.log("Document API smoke passed: scoped keys, batch rollback, pagination, history, replay and revocation; no secrets displayed");
|
||||
}
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, openSync, closeSync, unlinkSync } from "node:fs";
|
||||
import { resolve, dirname, sep } from "node:path";
|
||||
import { createDocumentKey, listDocumentKeys, revokeDocumentKey, type DocumentKeyScope } from "../src/lib/document-keys";
|
||||
import { openSiteDatabase } from "../src/lib/site-database";
|
||||
import { allDocuments, readDocument, saveDocument, importDocuments, documentHistory } from "../src/lib/docs";
|
||||
|
||||
process.umask(0o077);
|
||||
const [command, argument] = process.argv.slice(2);
|
||||
const [command, argument, ...extra] = process.argv.slice(2);
|
||||
try {
|
||||
switch (command) {
|
||||
case "key-create": {
|
||||
const authFile = process.env.AUTH_CONFIG_FILE;
|
||||
if (!authFile || !argument || !extra[0]) throw Error("Usage: key-create <name> <private-file> [read|read-write] [days]");
|
||||
const file = resolve(extra[0]);
|
||||
const directory = resolve(dirname(authFile));
|
||||
if (!file.startsWith(directory + sep)) throw Error("Key output must be inside the private authentication directory");
|
||||
const fd = openSync(file, "wx", 0o600);
|
||||
let created: { id: string } | undefined;
|
||||
try {
|
||||
const key = createDocumentKey(argument, (extra[1] ?? "read-write") as DocumentKeyScope, Number(extra[2] ?? 90));
|
||||
created = key;
|
||||
writeFileSync(fd, JSON.stringify(key) + "\n");
|
||||
console.log(JSON.stringify({ id: key.id, scope: key.scope, expiresAt: key.expiresAt, saved: file }));
|
||||
} catch (error) {
|
||||
if (created) revokeDocumentKey(created.id);
|
||||
unlinkSync(file);
|
||||
throw error;
|
||||
} finally { closeSync(fd); }
|
||||
break;
|
||||
}
|
||||
case "key-list": console.log(JSON.stringify(listDocumentKeys(), null, 2)); break;
|
||||
case "key-revoke":
|
||||
if (!argument) throw Error("Provide key id");
|
||||
revokeDocumentKey(argument); console.log("Document key revoked"); break;
|
||||
case "backup": {
|
||||
if (!argument) throw Error("Provide a private backup filename");
|
||||
const db = openSiteDatabase();
|
||||
@@ -36,7 +62,7 @@ try {
|
||||
{ flag: "wx", mode: 0o600 });
|
||||
console.log("Document export created; existing files are never overwritten"); break;
|
||||
}
|
||||
default: throw Error("Usage: docs backup <private-file> | list | get <slug> | save < input.json | history <sourceSlug> | import [--if-empty] < export.json | export <private-file>");
|
||||
default: throw Error("Usage: docs key-create <name> <private-file> [read|read-write] [days] | key-list | key-revoke <id> | backup <private-file> | list | get <slug> | save < input.json | history <sourceSlug> | import [--if-empty] < export.json | export <private-file>");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : "Document operation failed");
|
||||
|
||||
@@ -6,9 +6,8 @@ import { spawnSync } from "node:child_process";
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), "tabularium-docs-tests-"));
|
||||
try {
|
||||
const file = join(dir, "docs.test.cjs");
|
||||
await build({ entryPoints: ["tests/docs.test.ts"], bundle: true,
|
||||
platform: "node", target: "node22", format: "cjs", outfile: file });
|
||||
const result = spawnSync(process.execPath, ["--test", file], { stdio: "inherit" });
|
||||
await build({ entryPoints: ["tests/docs.test.ts", "tests/documents-api.test.ts"], bundle: true,
|
||||
platform: "node", target: "node22", format: "cjs", outdir: dir, outExtension: { ".js": ".cjs" } });
|
||||
const result = spawnSync(process.execPath, ["--test", join(dir, "docs.test.cjs"), join(dir, "documents-api.test.cjs")], { stdio: "inherit" });
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally { await rm(dir, { recursive: true, force: true }); }
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import IncomeDistribution from "./IncomeDistribution";
|
||||
import ArrDisclosures from "./ArrDisclosures";
|
||||
import BusinessModelAssessment from "./BusinessModelAssessment";
|
||||
import ResearchCandidates from "./ResearchCandidates";
|
||||
import {
|
||||
categorySummary, evidenceUrl, technicalReviewLabels, reviewCompanyIds, type ResearchEvidence, type ResearchReview, type ResearchSnapshot,
|
||||
} from "@/lib/ai-research";
|
||||
|
||||
const pageSize = 12;
|
||||
const evidencePageSize = 30;
|
||||
const revenueLabels = { self: "公司披露,待核口径", estimate: "仅第三方估算", platform: "仅支付平台记录", unknown: "暂无可用收入数字" };
|
||||
const revenueLabels = { self: "企业披露,待核口径", estimate: "仅第三方估算", platform: "仅支付平台记录", unknown: "暂无可用收入数字" };
|
||||
const useLabels: Record<string, string> = {
|
||||
platform: "支付平台记录", signal: "公司披露信号", estimate: "第三方估算", historical: "历史里程碑",
|
||||
platform: "支付平台记录", signal: "企业披露信号", estimate: "第三方估算", historical: "历史里程碑",
|
||||
"historical-estimate": "其他时点估算", excluded: "排除收入比较", scale: "团队规模", adoption: "采用规模",
|
||||
funding: "融资", technical: "第三方技术评测", crosscheck: "同一里程碑的其他资料",
|
||||
};
|
||||
@@ -55,7 +58,7 @@ export default function AICompanyResearch() {
|
||||
: router.isReady && typeof linkedCompany === "string" && data?.companies.some((c) => c.id === linkedCompany)
|
||||
? linkedCompany : null;
|
||||
function setSelected(id: string | null) { setSelection({ route: router.asPath, id }); }
|
||||
const [tab, setTab] = useState<"companies" | "revenue" | "sources" | "reviews">("companies");
|
||||
const [tab, setTab] = useState<"companies" | "candidates" | "revenue" | "models" | "sources" | "reviews">("companies");
|
||||
const companySection = useRef<HTMLElement>(null);
|
||||
const detailSection = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -127,7 +130,7 @@ export default function AICompanyResearch() {
|
||||
<section ref={companySection} className="research-section" aria-labelledby="research-records-title">
|
||||
<h2 id="research-records-title">研究数据</h2>
|
||||
<div className="research-tabs" role="group" aria-label="研究数据视图">
|
||||
{([['companies','公司与产品'],['revenue','收入观测'],['sources','原始证据'],['reviews','核查记录']] as const).map(([id,label]) =>
|
||||
{([['companies','公司与产品'],['candidates','候选与缺项'],['revenue','收入观测'],['models','商业模式初评'],['sources','原始证据'],['reviews','核查记录']] as const).map(([id,label]) =>
|
||||
<button key={id} aria-pressed={tab === id} onClick={() => setTab(id)}>{label}</button>)}
|
||||
</div>
|
||||
{tab === "companies" && <>
|
||||
@@ -138,7 +141,7 @@ export default function AICompanyResearch() {
|
||||
<label>资料深度<select value={depth} onChange={(e) => { setDepth(e.target.value); setPage(0); }}><option value="">全部资料</option><option value="case">原有个案研究</option><option value="profile">目录基础资料</option></select></label>
|
||||
<label>记录团队规模<select value={teamBand} onChange={(e) => { setTeamBand(e.target.value); setPage(0); }}><option value="">全部规模</option><option value="small">1—5 人</option><option value="medium">6—20 人</option><option value="large">21—50 人</option><option value="xl">至少 51 人</option><option value="unknown">未披露或分组不确定</option></select></label>
|
||||
<label>技术验证证据<select value={technicalLevel} onChange={(e) => { setTechnicalLevel(e.target.value); setPage(0); }}><option value="">全部验证层级</option>{Object.entries(technicalReviewLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>收入资料<select value={income} onChange={(e) => { setIncome(e.target.value); setPage(0); }}><option value="">全部状态</option><option value="self">找到公司披露</option><option value="estimate">仅第三方估算</option><option value="platform">仅支付平台记录</option><option value="unknown">暂无可用数字</option></select></label>
|
||||
<label>收入资料<select value={income} onChange={(e) => { setIncome(e.target.value); setPage(0); }}><option value="">全部状态</option><option value="self">找到企业披露</option><option value="estimate">仅第三方估算</option><option value="platform">仅支付平台记录</option><option value="unknown">暂无可用数字</option></select></label>
|
||||
</div>
|
||||
<p className="research-muted">技术验证层级记录我们查到了什么,不是能力强弱评分。技术专项评审与业务分类复核分别记录。</p>
|
||||
<div className="research-result-count" role="status">{filtered.length} 条匹配记录<button onClick={() => { setCategory(""); setQuery(""); setCohort(""); setIncome(""); setDepth(""); setTeamBand(""); setTechnicalLevel(""); setPage(0); }}>清除筛选</button></div>
|
||||
@@ -152,16 +155,19 @@ export default function AICompanyResearch() {
|
||||
{!filtered.length && <p className="research-message">没有匹配公司,请调整筛选条件。</p>}
|
||||
{filtered.length > pageSize && <nav className="research-pagination" aria-label="公司数据分页"><button disabled={page === 0} onClick={() => setPage((n) => n - 1)}>上一页</button><span>{page + 1} / {Math.ceil(filtered.length / pageSize)}</span><button disabled={(page + 1) * pageSize >= filtered.length} onClick={() => setPage((n) => n + 1)}>下一页</button></nav>}
|
||||
</>}
|
||||
{tab === "models" && <BusinessModelAssessment data={data} onSelect={setSelected} onChooseCategory={chooseCategory} />}
|
||||
{tab === "candidates" && <ResearchCandidates data={data} onSelect={setSelected} onChooseCategory={chooseCategory} />}
|
||||
{tab === "revenue" && <>
|
||||
<IncomeDistribution data={data} onSelect={setSelected} />
|
||||
<h3>公司披露与估算的历史里程碑</h3>
|
||||
<p className="research-notice">这一组包含 {disclosedCompanies} 家公司的披露或估算信号,多为异时点历史里程碑,ARR 与年化收入口径仍待核对,暂不计算其同期领域均值。币种未确认的记录仅显示原值,不绘制美元长度条。上面的平台MRR统计与这一组分开。</p>
|
||||
<ArrDisclosures data={data} onSelect={setSelected} />
|
||||
<h3>企业披露与估算的历史里程碑</h3>
|
||||
<p className="research-notice">这一组包含 {disclosedCompanies} 家公司的披露或估算信号;企业披露含公司或交易当事方公开材料,披露主体与审计状态见证据。多为异时点历史里程碑,期间收入不直接年化,ARR 与年化收入口径仍待核对,暂不计算其同期领域均值。币种未确认的记录仅显示原值,不绘制美元长度条。上面的平台MRR统计与这一组分开。</p>
|
||||
<div className="research-revenue-chart" aria-label="不同时间的公开收入里程碑,线性长度,非同行排名">{observed.map((o) => <div key={o.id}>
|
||||
<div><button onClick={() => setSelected(o.company)}>{o.name}</button><strong>{o.bound !== "=" && o.bound}{money(o.value, o.unit)}</strong></div>
|
||||
{o.unit === "USD" && <span className="research-bar" data-estimate={o.selection === "estimate" || undefined} aria-hidden="true"><i style={{width: `${o.value / Math.max(1, ...observed.filter((v) => v.unit === "USD").map((v) => v.value)) * 100}%`}} /></span>}
|
||||
<small>{o.kind} · {useLabels[o.selection]} · {o.timing}</small>
|
||||
</div>)}</div>
|
||||
<div className="research-table-wrap"><table><caption>当前研究数据中的收入可得性,目录项未逐一检索;未建立同期、同龄、同口径比较组</caption><thead><tr><th>业务分类</th><th>公司披露数</th><th>仅估算数</th><th>仅平台数</th><th>暂无可用数字</th><th>同期均值 / 离散度</th></tr></thead><tbody>{summaries.map((c) => <tr key={c.id}><td><button onClick={() => chooseCategory(c.id)}>{c.name}</button></td><td>{c.self}</td><td>{c.estimate}</td><td>{c.platform}</td><td>{c.unknown}</td><td>条件不足</td></tr>)}</tbody></table></div>
|
||||
<div className="research-table-wrap"><table><caption>当前研究数据中的收入可得性,目录项未逐一检索;未建立同期、同龄、同口径比较组</caption><thead><tr><th>业务分类</th><th>企业披露数</th><th>仅估算数</th><th>仅平台数</th><th>暂无可用数字</th><th>同期均值 / 离散度</th></tr></thead><tbody>{summaries.map((c) => <tr key={c.id}><td><button onClick={() => chooseCategory(c.id)}>{c.name}</button></td><td>{c.self}</td><td>{c.estimate}</td><td>{c.platform}</td><td>{c.unknown}</td><td>条件不足</td></tr>)}</tbody></table></div>
|
||||
<p className="research-muted">离散度用于筛选值得核查的异常,还需排除公司年龄、口径和披露偏差;不能据此认定财务造假。</p>
|
||||
</>}
|
||||
{tab === "sources" && <><p className="research-muted">来源链接保留原始位置。相同 URL 的记录可能属于同一披露,不能按记录数计算独立证据数量。</p><ul className="research-evidence-list">{data.evidence.slice(sourcePage * evidencePageSize, (sourcePage + 1) * evidencePageSize).map((e) => <Evidence key={e.id} item={e} />)}</ul>{data.evidence.length > evidencePageSize && <nav className="research-pagination" aria-label="原始证据分页"><button disabled={sourcePage === 0} onClick={() => setSourcePage((n) => n - 1)}>上一页</button><span>{sourcePage + 1} / {Math.ceil(data.evidence.length / evidencePageSize)}</span><button disabled={(sourcePage + 1) * evidencePageSize >= data.evidence.length} onClick={() => setSourcePage((n) => n + 1)}>下一页</button></nav>}</>}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import { evidenceUrl, type ResearchSnapshot } from "@/lib/ai-research";
|
||||
import { arrDisclosureCoverage, arrDisclosures, arrFieldGaps, type ArrFieldGap } from "@/lib/research-statistics";
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
signal: "企业或当事方披露", estimate: "第三方估算",
|
||||
historical: "历史披露", "historical-estimate": "历史估算",
|
||||
};
|
||||
const gapLabels: Record<ArrFieldGap, string> = {
|
||||
date: "缺有效财务截至日", currency: "USD 未确认(含其他币种)",
|
||||
amount: "下界、近似或非精确金额", estimate: "第三方估算",
|
||||
};
|
||||
|
||||
export default function ArrDisclosures({ data, onSelect }: { data: ResearchSnapshot; onSelect: (id: string) => void }) {
|
||||
const [category, setCategory] = useState("");
|
||||
const [year, setYear] = useState("");
|
||||
const [gap, setGap] = useState<ArrFieldGap | "">("");
|
||||
const claims = arrDisclosures(data, { category, foundedYear: year ? Number(year) : undefined });
|
||||
const rows = claims.filter(({ observation }) => !gap || arrFieldGaps(observation, data.asOf).includes(gap));
|
||||
const gapCounts = (Object.keys(gapLabels) as ArrFieldGap[]).map((id) => ({
|
||||
id, count: claims.filter(({ observation }) => arrFieldGaps(observation, data.asOf).includes(id)).length,
|
||||
}));
|
||||
const completeFields = claims.filter(({ observation }) => !arrFieldGaps(observation, data.asOf).length).length;
|
||||
const coverage = arrDisclosureCoverage(data, year ? Number(year) : undefined);
|
||||
const companies = new Set(claims.map((row) => row.company.id));
|
||||
const categories = new Map(data.categories.map((item) => [item.id, item.name]));
|
||||
const evidence = new Map(data.evidence.map((item) => [item.id, item]));
|
||||
return <section aria-label="ARR披露分类表">
|
||||
<h3>2025—2026 年成立公司的 ARR 披露</h3>
|
||||
<p className="research-notice">这是跨时点证据表,共 {companies.size} 家公司、{claims.length} 条记录;同一公司的历史里程碑逐条保留,不能相加或视为独立公司。ARR 标签沿用原始披露,未证明经常性收入定义或审计状态;不纳入 MRR 图表,不计算同期行业均值或造假概率。仅收录明确标注的 ARR;定义冲突、目标、承诺、客户和前雇主收入排除,缺失不作零。</p>
|
||||
<div className="research-filters">
|
||||
<label>业务分类<select value={category} onChange={(event) => setCategory(event.target.value)}><option value="">全部分类</option>{data.categories.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
|
||||
<label>成立年份<select value={year} onChange={(event) => setYear(event.target.value)}><option value="">2025 与 2026</option><option value="2025">2025</option><option value="2026">2026</option></select></label>
|
||||
<label>补证项目<select value={gap} onChange={(event) => setGap(event.target.value as ArrFieldGap | "")}><option value="">全部记录</option>{gapCounts.map((item) => <option key={item.id} value={item.id}>{gapLabels[item.id]}({item.count} 条)</option>)}</select></label>
|
||||
</div>
|
||||
<div className="research-table-wrap"><table>
|
||||
<caption>ARR 披露覆盖:按公司当前分类及所选成立年份、每家公司计一次。点击分类筛选下方证据表。</caption>
|
||||
<thead><tr><th>业务分类</th><th>已采集公司</th><th>企业或当事方披露</th><th>仅第三方估算</th><th>缺少 ARR</th><th>披露覆盖率</th></tr></thead>
|
||||
<tbody>{coverage.filter((item) => item.companies > 0).map((item) => <tr key={item.id}>
|
||||
<td><button onClick={() => setCategory(item.id)} aria-pressed={category === item.id}>{item.name}</button></td>
|
||||
<td>{item.companies}</td><td>{item.reported}</td><td>{item.estimatedOnly}</td><td>{item.missing}</td>
|
||||
<td>{new Intl.NumberFormat("zh-CN", { style: "percent", maximumFractionDigits: 1 }).format(item.disclosed / item.companies)}</td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
<p className="research-muted">分母含仅有目录资料的公司,覆盖率反映本次采集深度,不代表行业披露率。分类及收费模式采用公司当前资料;公司转向或同时经营多个产品时,历史 ARR 未必属于当前业务,需打开公司核对产品、主体与期间,不能据当前分类重归历史收入。企业披露含历史里程碑,同一公司兼有披露和估算时归企业披露;缺少 ARR 的公司仍可能有其他收入。披露时点、币种与合同定义未对齐,不能将已披露少数公司的均值外推至整个分类,也不能由离散性判断财务造假。</p>
|
||||
<aside className="research-notice" aria-label="ARR比较前的补证检查">
|
||||
<strong>ARR 比较前的补证检查</strong>
|
||||
<p>当前分类及成立年下有 {claims.length} 条披露,{completeFields} 条同时具有有效日期、明确 USD、精确金额且不是第三方估算。补证筛选后显示 {rows.length} 条。</p>
|
||||
<div className="income-options">{gapCounts.map((item) => <button key={item.id} aria-pressed={gap === item.id} onClick={() => setGap(gap === item.id ? "" : item.id)}>{gapLabels[item.id]}:{item.count}</button>)}</div>
|
||||
<p>各项可重叠,不能相加为独立公司数。日期必须是经营指标截至日,网页访问、发布或更新时间不能代替;其他币种保留原值,不擅自换算。基础字段齐全也不证明可比,还需对齐收入定义、业务期间、收费模式、主体与独立验证;这些缺项不作为造假判定。</p>
|
||||
</aside>
|
||||
<div className="research-table-wrap"><table>
|
||||
<caption>金额保留原币种、下界和估算身份;日期栏保留业务截至日缺项</caption>
|
||||
<thead><tr><th>公司 / 当前分类</th><th>当前收费与交付模式</th><th>原始 ARR 声明</th><th>时点</th><th>身份 / 限制</th><th>证据</th></tr></thead>
|
||||
<tbody>{rows.map(({ company, observation: item }) => {
|
||||
const source = evidence.get(item.evidence);
|
||||
const url = source && evidenceUrl(source.url);
|
||||
return <tr key={item.id}>
|
||||
<td><button onClick={() => onSelect(company.id)}>{company.name}</button><small>{categories.get(company.cat)} · {company.year}</small></td>
|
||||
<td>{company.businessModel ?? company.price}</td>
|
||||
<td>{item.bound !== "=" && item.bound}{new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(item.value)} {item.unit}<small>{item.kind}</small></td>
|
||||
<td>{item.timing}</td><td>{labels[item.selection]}<small>{item.reason}</small><small>{arrFieldGaps(item, data.asOf).map((id) => gapLabels[id]).join(";") || "基础字段齐全;定义、期间与主体仍须核实"}</small></td>
|
||||
<td>{url ? <a href={url} target="_blank" rel="noopener noreferrer">{item.evidence} ↗</a> : item.evidence}</td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table></div>
|
||||
{!rows.length && <p>当前分类、成立年与补证筛选没有符合条件的已记录 ARR 声明;不代表这些公司收入为零。</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { businessModelReviewType, evidenceUrl, reviewCompanyIds, type ResearchSnapshot } from "@/lib/ai-research";
|
||||
|
||||
export default function BusinessModelAssessment({ data, onSelect, onChooseCategory }: {
|
||||
data: ResearchSnapshot; onSelect: (id: string) => void; onChooseCategory: (id: string) => void;
|
||||
}) {
|
||||
const reviews = data.reviews.filter((review) => review.type === businessModelReviewType && review.scope === "companies");
|
||||
const evidence = new Map(data.evidence.map((item) => [item.id, item]));
|
||||
return <section aria-labelledby="research-models-title">
|
||||
<h3 id="research-models-title">商业模式初评</h3>
|
||||
<p>从已核个案提炼收费方式、成本约束与下一步验证条件。已知事实与研究判断分列;同一种收费方式可以跨业务分类,以下个案不能代表整个行业。</p>
|
||||
<p className="research-muted">这是基于当前证据的初步评估,不是收入预测或盈利排名。判断能否用于我们的业务,还需用实际客户任务、付费留存与交付成本验证;ARR 或平台 MRR 单独不能证明利润。</p>
|
||||
{reviews.length ? <div className="research-table-wrap"><table>
|
||||
<caption>收费模式与业务分类、公司明细及原始证据相互关联</caption>
|
||||
<thead><tr><th scope="col">模式 / 业务分类</th><th scope="col">已核事实</th><th scope="col">初步判断 / 验证条件</th><th scope="col">参考个案与证据</th></tr></thead>
|
||||
<tbody>{reviews.map((review) => {
|
||||
const companyIds = new Set(reviewCompanyIds(data, review));
|
||||
const companies = data.companies.filter((company) => companyIds.has(company.id));
|
||||
const categories = data.categories.filter((category) => companies.some((company) => company.cat === category.id));
|
||||
return <tr key={review.id}>
|
||||
<td><strong>{review.company}</strong><div>{categories.map((category) => <button key={category.id} onClick={() => onChooseCategory(category.id)}>{category.name}</button>)}</div></td>
|
||||
<td>{review.summary}</td><td>{review.handling}</td>
|
||||
<td><div>{companies.map((company) => <button key={company.id} className="research-company-name" onClick={() => onSelect(company.id)}>{company.name}</button>)}</div>
|
||||
<div>{review.evidenceIds.map((id) => {
|
||||
const item = evidence.get(id); const url = item && evidenceUrl(item.url);
|
||||
return item && url ? <p key={id}><a href={url} target="_blank" rel="noopener noreferrer">{item.title} ↗</a></p> : null;
|
||||
})}</div>
|
||||
</td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table></div> : <p>当前数据版本尚未记录商业模式初评。</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ResearchSnapshot } from "@/lib/ai-research";
|
||||
import { groupIncome, platformIncomeSample } from "@/lib/research-statistics";
|
||||
import { compareIncomeGroups, groupIncome, platformIncomeSample } from "@/lib/research-statistics";
|
||||
|
||||
const usd = (value: number | null) => value === null ? "—" : new Intl.NumberFormat("zh-CN", {
|
||||
style: "currency", currency: "USD", maximumFractionDigits: 0,
|
||||
@@ -23,6 +23,7 @@ export default function IncomeDistribution({ data, onSelect }: {
|
||||
const categoryGroups = [...new Set(groups.map((group) => group.category))].map((category) => ({
|
||||
category, rows: groups.filter((group) => group.category === category),
|
||||
}));
|
||||
const comparisons = compareIncomeGroups(groups);
|
||||
const categories = new Map(data.categories.map((category) => [category.id, category.name]));
|
||||
const metric = annualize ? "MRR×12参考年化" : "平台MRR";
|
||||
const values = sample.points.map((point) => point.value);
|
||||
@@ -47,6 +48,20 @@ export default function IncomeDistribution({ data, onSelect }: {
|
||||
<p>{splitModels ? "分组:业务类别 × 支付平台 × 收费与交付模式。同一类别下的多行是不同统计组,各自计算均值和中位数。" : "分组:业务类别 × 支付平台。同一平台内的收费模式已合并,不同支付平台仍分别统计。"}</p>
|
||||
<p className="research-notice">资料截至 {data.asOf},纳入 {sample.points.length} 个产品,另有 {sample.excluded.length} 个 MRR 观测未纳入。{annualize ? "乘12只改变显示单位,不证明全年收入、续约或ARR;CV与相对敏感性不变。" : "MRR不等于近30日收款;平台数据连接也不等于财务审计。"}{dateWindow === "all" && " 当前包含不同时间的历史观测,不能称为同期对照。"}</p>
|
||||
{sample.points.length ? <>
|
||||
<aside aria-label="当前窗口样本解读" className="research-notice">
|
||||
<strong>当前窗口的样本解读</strong>
|
||||
{comparisons.length ? comparisons.map((comparison) => <div key={comparison.key}>
|
||||
<p>{comparison.provider} · {comparison.model}(仅比较各组 n≥3):
|
||||
{comparison.leastDispersed.stats.cv === comparison.mostDispersed.stats.cv
|
||||
? <>各组 CV 相同({comparison.leastDispersed.stats.cv?.toFixed(3)}),没有离散度高低差异。</>
|
||||
: <>{categories.get(comparison.mostDispersed.category)}更离散(CV {comparison.mostDispersed.stats.cv?.toFixed(3)}),{categories.get(comparison.leastDispersed.category)}较集中(CV {comparison.leastDispersed.stats.cv?.toFixed(3)})。</>}
|
||||
{comparison.leastSensitive.stats.largestRemovalDrop === comparison.mostSensitive.stats.largestRemovalDrop
|
||||
? <>各组移除最大值后的均值下降幅度相同({percent(comparison.leastSensitive.stats.largestRemovalDrop)})。</>
|
||||
: <>{categories.get(comparison.mostSensitive.category)}的均值更受最大值影响,移除后下降 {percent(comparison.mostSensitive.stats.largestRemovalDrop)};{categories.get(comparison.leastSensitive.category)}下降 {percent(comparison.leastSensitive.stats.largestRemovalDrop)}。</>}
|
||||
</p>
|
||||
</div>) : <p>当前没有至少两个可对照的统计组。需要相同支付平台、明确的收费模式标签,且各组至少 3 个产品;可调整同步窗口或创立年,不能将单个产品或两点均值当作领域典型。</p>}
|
||||
<p>这些是所选产品样本内的描述,较集中或较不敏感也不证明行业均值可靠。最后同步日并非统一财务结账日,产品年龄、主体独立性、成本和留存仍待核;参考年化也不是已披露 ARR。</p>
|
||||
</aside>
|
||||
<figure>
|
||||
<div className="research-table-wrap"><svg className="income-distribution-chart" viewBox={`0 0 900 ${groups.length * 70 + 65}`} role="group" aria-label={`${metric}分布,${logScale ? "对数" : "线性"}横轴;圆点为产品,竖线为均值,菱形为中位数`}>
|
||||
{ticks.map((tick) => <g key={tick}><line x1={x(tick)} x2={x(tick)} y1={15} y2={groups.length * 70 + 10} stroke="#dfe6e0" /><text x={x(tick)} y={groups.length * 70 + 35} textAnchor="middle" fontSize="11" fill="#637066">{usd(tick)}</text></g>)}
|
||||
@@ -75,7 +90,7 @@ export default function IncomeDistribution({ data, onSelect }: {
|
||||
</svg></div>
|
||||
<figcaption>圆点:产品观测 竖线:样本均值 菱形:样本中位数。业务类别标题下各行按支付平台{splitModels && "及收费与交付模式"}分别统计;n为该组产品数。点击点或下表产品名可看原始证据。</figcaption>
|
||||
</figure>
|
||||
<div className="research-table-wrap"><table><caption>{metric}样本统计;每行是类别下的一个统计组,CV使用样本标准差</caption><thead><tr><th scope="col">业务类别</th><th scope="col">支付平台 / 收费模式</th><th scope="col">n</th><th scope="col">均值</th><th scope="col">中位数</th><th scope="col">均值/中位数</th><th scope="col">CV</th><th scope="col">去除最大值后的均值 / 下降幅度</th><th scope="col">产品与限制</th></tr></thead>{categoryGroups.map(({ category, rows }) => <tbody key={category}>{rows.map((group, index) => <tr key={group.key}>
|
||||
<div className="research-table-wrap"><table className="income-statistics"><caption>{metric}样本统计;每行是类别下的一个统计组,CV使用样本标准差</caption><thead><tr><th scope="col">业务类别</th><th scope="col">支付平台 / 收费模式</th><th scope="col">n</th><th scope="col">均值</th><th scope="col">中位数</th><th scope="col">均值/中位数</th><th scope="col">CV</th><th scope="col">去除最大值后的均值 / 下降幅度</th><th scope="col">产品与限制</th></tr></thead>{categoryGroups.map(({ category, rows }) => <tbody key={category}>{rows.map((group, index) => <tr key={group.key}>
|
||||
{index === 0 && <th scope="rowgroup" rowSpan={rows.length}>{categories.get(category)}<small>{rows.length}个统计组</small></th>}
|
||||
<td>{group.provider}<small>{group.model}</small></td><td>{group.stats.n}</td><td>{usd(group.stats.mean)}</td><td>{usd(group.stats.median)}</td><td>{group.stats.meanMedianRatio?.toFixed(2) ?? "—"}</td><td>{group.stats.cv?.toFixed(2) ?? "—"}</td><td>{usd(group.stats.meanWithoutLargest)}<small>{percent(group.stats.largestRemovalDrop)}</small></td>
|
||||
<td>{group.points.map((point) => <span key={point.company.id}><button className="research-company-name" onClick={() => onSelect(point.company.id)}>{point.company.name}</button>{" "}</span>)}<small>{group.stats.n < 3 ? "样本少于3,不能据此判断领域典型收入。" : "自选的小样本,须结合产品年龄、成本与留存评估。"}</small></td>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { evidenceUrl, type ResearchCandidateBatch, type ResearchSnapshot } from "@/lib/ai-research";
|
||||
|
||||
type CandidateStatus = ResearchCandidateBatch["records"][number]["status"];
|
||||
const statusLabels: Record<CandidateStatus, string> = {
|
||||
"pending-year": "成立年或主体待核", "pending-ai": "AI 主营待核", linked: "已关联公司",
|
||||
"existing-outside-cohort": "已关联范围外对照", "outside-cohort": "范围外候选", promotion: "推广活动",
|
||||
};
|
||||
const pageSize = 20;
|
||||
const sourceNames: Record<string, string> = {
|
||||
S01: "Y Combinator", S02: "TechCrunch AI", S04: "Product Hunt", S05: "Sacra",
|
||||
S10: "机器之心", S11: "36氪", S13: "TrustMRR", S14: "a16z Speedrun",
|
||||
};
|
||||
const sourceLabel = (id: string) => `${id} · ${sourceNames[id] ?? "其他来源"}`;
|
||||
|
||||
function SourceLink({ url, label }: { url: string | null; label: string }) {
|
||||
const safe = url && evidenceUrl(url);
|
||||
return safe ? <a href={safe} target="_blank" rel="noopener noreferrer">{label} ↗</a> : <span>{label}(链接未取得)</span>;
|
||||
}
|
||||
|
||||
export default function ResearchCandidates({ data, onSelect, onChooseCategory }: {
|
||||
data: ResearchSnapshot; onSelect: (id: string) => void; onChooseCategory: (id: string) => void;
|
||||
}) {
|
||||
const [source, setSource] = useState("");
|
||||
const [batchId, setBatchId] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [status, setStatus] = useState<CandidateStatus | "pending" | "">("pending");
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const all = useMemo(() => (data.candidateBatches ?? []).flatMap((batch) => batch.records.map((record) => ({ batch, record }))), [data.candidateBatches]);
|
||||
const sources = [...new Set(all.map(({ batch }) => batch.sourceId))].sort();
|
||||
const batches = (data.candidateBatches ?? []).filter((batch) => !source || batch.sourceId === source);
|
||||
const matched = all.filter(({ batch, record }) => (!source || batch.sourceId === source)
|
||||
&& (!batchId || batch.id === batchId) && (!category || record.categoryId === category)
|
||||
&& `${record.name} ${record.id} ${record.note}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
|
||||
const pending = (value: CandidateStatus) => value === "pending-year" || value === "pending-ai";
|
||||
const rows = matched.filter(({ record }) => !status || (status === "pending" ? pending(record.status) : record.status === status));
|
||||
const companies = new Map(data.companies.map((company) => [company.id, company]));
|
||||
const evidence = new Map(data.evidence.map((item) => [item.id, item]));
|
||||
const categories = new Map(data.categories.map((item) => [item.id, item.name]));
|
||||
return <section aria-labelledby="research-candidates-title">
|
||||
<h3 id="research-candidates-title">候选与缺项</h3>
|
||||
<p>展示已登记的有限来源批次及后续核查。默认显示成立年、主体或 AI 主营待核的条目;待核不是确认排除,也不计入公司表。每行是一次发现位置,同一公司或产品可以重复出现。</p>
|
||||
<p className="research-muted">候选初分类与公司表数量分开,来源批次不等全年或全站名单。原始发现范围及后续结论见每行说明;暂无收入资料不作零,推广活动也不算公司。</p>
|
||||
<div className="research-filters">
|
||||
<label className="research-query">搜索候选<input type="search" value={query} placeholder="名称、编号或核查说明" onChange={(event) => { setQuery(event.target.value); setPage(0); }} /></label>
|
||||
<label>发现来源<select value={source} onChange={(event) => { setSource(event.target.value); setBatchId(""); setPage(0); }}><option value="">全部已登记来源</option>{sources.map((id) => <option key={id} value={id}>{sourceLabel(id)}</option>)}</select></label>
|
||||
<label>发现批次<select value={batchId} onChange={(event) => { setBatchId(event.target.value); setPage(0); }}><option value="">全部批次</option>{batches.map((batch) => <option key={batch.id} value={batch.id}>{batch.id}</option>)}</select></label>
|
||||
<label>候选初分类<select value={category} onChange={(event) => { setCategory(event.target.value); setPage(0); }}><option value="">全部分类</option>{data.categories.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
|
||||
<label>核查状态<select value={status} onChange={(event) => { setStatus(event.target.value as typeof status); setPage(0); }}>
|
||||
<option value="">全部状态({matched.length})</option><option value="pending">全部待核({matched.filter(({ record }) => pending(record.status)).length})</option>
|
||||
{Object.entries(statusLabels).map(([id, label]) => <option key={id} value={id}>{label}({matched.filter(({ record }) => record.status === id).length})</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
<p role="status">{rows.length} 个发现位置匹配;不是独立公司数。</p>
|
||||
<div className="research-table-wrap"><table>
|
||||
<caption>有限来源候选与处理记录;选择关联公司查看主数据,原始证据直接链接来源</caption>
|
||||
<thead><tr><th scope="col">候选</th><th scope="col">初分类 / 状态</th><th scope="col">来源及范围</th><th scope="col">核查结论 / 缺项</th><th scope="col">关联公司 / 证据</th></tr></thead>
|
||||
<tbody>{rows.slice(page * pageSize, (page + 1) * pageSize).map(({ batch, record }) => {
|
||||
const company = record.companyId && companies.get(record.companyId);
|
||||
return <tr key={record.id}>
|
||||
<td><SourceLink url={record.url} label={record.name} /><small>{record.id}</small></td>
|
||||
<td><button onClick={() => onChooseCategory(record.categoryId)} aria-label={`查看${categories.get(record.categoryId)}分类内的公司`}>{categories.get(record.categoryId)}</button><small>{statusLabels[record.status]}</small></td>
|
||||
<td><SourceLink url={batch.url} label={sourceLabel(batch.sourceId)} /><small>{batch.id} · 读取 {batch.accessedAt}</small><details><summary>发现批次范围</summary><p>{batch.scope}</p><p>{batch.complete ? "该批次标记已完成;不代表来源全站覆盖。" : "该批次仍保留覆盖缺项。"}</p></details></td>
|
||||
<td>{record.note}</td>
|
||||
<td>{company ? <button className="research-company-name" onClick={() => onSelect(company.id)}>{company.name}</button> : <small>尚未关联公司主记录</small>}
|
||||
{record.evidenceIds?.map((id) => { const item = evidence.get(id); return item ? <p key={id}><SourceLink url={item.url} label={id} /></p> : null; })}
|
||||
</td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table></div>
|
||||
{!rows.length && <p>当前筛选没有已登记的候选记录;不代表该来源或分类没有公司。</p>}
|
||||
{rows.length > pageSize && <nav className="research-pagination" aria-label="候选与缺项分页"><button disabled={page === 0} onClick={() => setPage((value) => value - 1)}>上一页</button><span>{page + 1} / {Math.ceil(rows.length / pageSize)}</span><button disabled={(page + 1) * pageSize >= rows.length} onClick={() => setPage((value) => value + 1)}>下一页</button></nav>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export type ResearchCategory = { id: string; name: string; description: string };
|
||||
export const businessModelReviewType = "商业模式初评";
|
||||
export const technicalReviewLabels = {
|
||||
unreviewed: "技术证据尚未专项评审",
|
||||
description: "已读技术自述",
|
||||
@@ -48,7 +49,7 @@ export type ResearchCandidateBatch = {
|
||||
records: {
|
||||
id: string; name: string; url: string | null; categoryId: string;
|
||||
kind: "product" | "company" | "promotion"; profileStatus: "readable" | "unavailable" | "archive-only";
|
||||
status: "pending-year" | "linked" | "existing-outside-cohort" | "outside-cohort" | "promotion";
|
||||
status: "pending-year" | "pending-ai" | "linked" | "existing-outside-cohort" | "outside-cohort" | "promotion";
|
||||
companyId?: string; evidenceIds?: string[]; note: string;
|
||||
}[];
|
||||
};
|
||||
@@ -125,13 +126,13 @@ export function validateResearch(snapshot: ResearchSnapshot) {
|
||||
if (!row.name.trim() || !row.note.trim() || !categories.has(row.categoryId) ||
|
||||
!["product", "company", "promotion"].includes(row.kind) ||
|
||||
!["readable", "unavailable", "archive-only"].includes(row.profileStatus) ||
|
||||
!["pending-year", "linked", "existing-outside-cohort", "outside-cohort", "promotion"].includes(row.status) ||
|
||||
!["pending-year", "pending-ai", "linked", "existing-outside-cohort", "outside-cohort", "promotion"].includes(row.status) ||
|
||||
(row.kind !== "promotion" && (!row.url || !evidenceUrl(row.url))) ||
|
||||
(row.url !== null && !evidenceUrl(row.url)) ||
|
||||
linked !== !!row.companyId || (row.companyId && !companies.has(row.companyId)) ||
|
||||
(row.kind === "promotion") !== (row.status === "promotion") ||
|
||||
(row.evidenceIds ?? []).some((id) => !evidence.has(id)) ||
|
||||
(row.status === "outside-cohort" && !row.evidenceIds?.length))
|
||||
(["outside-cohort", "pending-ai"].includes(row.status) && !row.evidenceIds?.length))
|
||||
throw new Error(`Invalid research candidate: ${row.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
+178
-18
@@ -1,5 +1,9 @@
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import { openSiteDatabase } from "./site-database";
|
||||
import { DocumentValidationError, DocumentConflictError } from "./document-errors";
|
||||
import { sectionSchema, loadSections, reconcileSections, persistSections, splitSections, outline,
|
||||
applySectionOperations, parseSectionEdit, type DocumentSection } from "./document-sections";
|
||||
export { DocumentValidationError, DocumentConflictError } from "./document-errors";
|
||||
|
||||
export type DocLink = {
|
||||
slug: string; sourceSlug: string; category: string; title: string; searchText?: string;
|
||||
@@ -9,6 +13,7 @@ export type DocLink = {
|
||||
export type Doc = DocLink & { content: string };
|
||||
export type DocInput = Omit<Doc, "revision" | "searchText">;
|
||||
export type DocumentExport = { format: "tabularium-documents-v1"; documents: Doc[] };
|
||||
export type DocumentReceipt = Pick<Doc, "slug" | "sourceSlug" | "revision">;
|
||||
export const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
|
||||
|
||||
function database() {
|
||||
@@ -25,58 +30,131 @@ function database() {
|
||||
document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(document_id, revision)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_write_requests (
|
||||
namespace TEXT NOT NULL, request_id TEXT NOT NULL, digest TEXT NOT NULL,
|
||||
response TEXT NOT NULL, PRIMARY KEY(namespace, request_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_imports (
|
||||
digest TEXT PRIMARY KEY, document_count INTEGER NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
sectionSchema(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function validateDocument(input: DocInput): void {
|
||||
if (!input || typeof input !== "object") throw Error("Invalid document");
|
||||
if (!input || typeof input !== "object") throw new DocumentValidationError("Invalid document");
|
||||
for (const key of ["slug", "sourceSlug"] as const)
|
||||
if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw Error(`Invalid ${key}`);
|
||||
if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw Error("Use a semantic public slug");
|
||||
if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw new DocumentValidationError(`Invalid ${key}`);
|
||||
if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw new DocumentValidationError("Use a semantic public slug");
|
||||
for (const key of ["title", "category", "collection", "group"] as const)
|
||||
if (typeof input[key] !== "string" || !input[key].trim()) throw Error(`Missing ${key}`);
|
||||
if (typeof input[key] !== "string" || !input[key].trim()) throw new DocumentValidationError(`Missing ${key}`);
|
||||
if (typeof input.content !== "string" || typeof input.description !== "string" ||
|
||||
typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw Error("Invalid document fields");
|
||||
if (input.content.length > 2_000_000) throw Error("Document exceeds content limit");
|
||||
typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw new DocumentValidationError("Invalid document fields");
|
||||
if (input.content.length > 2_000_000) throw new DocumentValidationError("Document exceeds content limit");
|
||||
const images = [
|
||||
...input.content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g),
|
||||
...input.content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi),
|
||||
];
|
||||
if (images.some(([, source]) => /\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source)))
|
||||
throw Error("Use compressed WebP images instead of original rasters");
|
||||
throw new DocumentValidationError("Use compressed WebP images instead of original rasters");
|
||||
}
|
||||
|
||||
function write(db: ReturnType<typeof database>, input: DocInput, expectedRevision: number): Doc {
|
||||
function write(db: ReturnType<typeof database>, input: DocInput, expectedRevision: number, editedSections?: DocumentSection[]): Doc {
|
||||
validateDocument(input);
|
||||
const existing = db.prepare("SELECT id, revision FROM documents WHERE source_slug=?")
|
||||
.get(input.sourceSlug) as { id: string; revision: number } | undefined;
|
||||
if ((existing?.revision ?? 0) !== expectedRevision) throw Error("Document revision conflict");
|
||||
const existing = db.prepare("SELECT id, revision, payload FROM documents WHERE source_slug=?")
|
||||
.get(input.sourceSlug) as { id: string; revision: number; payload: string } | undefined;
|
||||
if ((existing?.revision ?? 0) !== expectedRevision) throw new DocumentConflictError("Document revision conflict");
|
||||
const id = existing?.id ?? randomUUID();
|
||||
const previous = existing ? loadSections(db, id, (JSON.parse(existing.payload) as Doc).content) : [];
|
||||
const doc: Doc = { slug: input.slug, sourceSlug: input.sourceSlug, category: input.category,
|
||||
title: input.title, group: input.group, collection: input.collection, description: input.description,
|
||||
draft: input.draft, featured: input.featured, content: input.content, revision: expectedRevision + 1 };
|
||||
const payload = JSON.stringify(doc);
|
||||
// Aliases remain reserved after renaming; collision rolls back the whole write.
|
||||
db.prepare(`INSERT INTO documents VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
|
||||
slug=excluded.slug, revision=excluded.revision, payload=excluded.payload`)
|
||||
.run(id, doc.slug, doc.sourceSlug, doc.revision, payload);
|
||||
// Reserve both canonical and historical names before updating any rows.
|
||||
for (const alias of new Set([doc.slug, doc.sourceSlug])) {
|
||||
const prior = db.prepare("SELECT document_id FROM document_aliases WHERE alias=?")
|
||||
.get(alias) as { document_id: string } | undefined;
|
||||
if (prior && prior.document_id !== id) throw Error(`Document alias collision: ${alias}`);
|
||||
db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id);
|
||||
if (prior && prior.document_id !== id) throw new DocumentConflictError(`Document alias collision: ${alias}`);
|
||||
}
|
||||
db.prepare(`INSERT INTO documents VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
|
||||
slug=excluded.slug, revision=excluded.revision, payload=excluded.payload`)
|
||||
.run(id, doc.slug, doc.sourceSlug, doc.revision, payload);
|
||||
for (const alias of new Set([doc.slug, doc.sourceSlug]))
|
||||
db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id);
|
||||
db.prepare("INSERT INTO document_revisions VALUES (?,?,?,?)")
|
||||
.run(id, doc.revision, payload, new Date().toISOString());
|
||||
persistSections(db, id, doc.revision, editedSections ?? reconcileSections(doc.content, previous));
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Internal summaries never enter Doc, catalog, search or browser page props.
|
||||
export function readDocumentSections(slug: string, revision?: number) {
|
||||
if (!validSlug.test(slug)) throw new DocumentValidationError("Invalid document slug");
|
||||
if (revision !== undefined && (!Number.isSafeInteger(revision) || revision < 1)) throw new DocumentValidationError("Invalid revision");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN");
|
||||
const row = db.prepare(`SELECT d.id,d.payload FROM documents d JOIN document_aliases a
|
||||
ON a.document_id=d.id WHERE a.alias=?`).get(slug) as { id: string; payload: string } | undefined;
|
||||
if (!row) { db.exec("COMMIT"); return null; }
|
||||
let doc = JSON.parse(row.payload) as Doc;
|
||||
let sections: DocumentSection[];
|
||||
if (revision === undefined || revision === doc.revision) sections = loadSections(db, row.id, doc.content);
|
||||
else {
|
||||
const prior = db.prepare("SELECT payload FROM document_revisions WHERE document_id=? AND revision=?")
|
||||
.get(row.id, revision) as { payload: string } | undefined;
|
||||
if (!prior) { db.exec("COMMIT"); return null; }
|
||||
doc = JSON.parse(prior.payload) as Doc;
|
||||
const snapshot = db.prepare("SELECT payload FROM document_section_revisions WHERE document_id=? AND revision=?")
|
||||
.get(row.id, revision) as { payload: string } | undefined;
|
||||
sections = snapshot ? JSON.parse(snapshot.payload) as DocumentSection[] : splitSections(doc.content);
|
||||
}
|
||||
db.exec("COMMIT");
|
||||
return { slug: doc.slug, sourceSlug: doc.sourceSlug, title: doc.title, revision: doc.revision, sections };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function editDocumentSections(slug: string, value: unknown, request?: { namespace: string; id: string }) {
|
||||
if (!validSlug.test(slug)) throw new DocumentValidationError("Invalid document slug");
|
||||
const edit = parseSectionEdit(value);
|
||||
if (request && (!request.namespace || !/^[A-Za-z0-9._:-]{1,128}$/.test(request.id))) throw new DocumentValidationError("Invalid Idempotency-Key");
|
||||
const digest = createHash("sha256").update(JSON.stringify({ slug, edit })).digest("hex");
|
||||
const namespace = request ? request.namespace + ":sections" : undefined;
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
if (request) {
|
||||
const prior = db.prepare("SELECT digest,response FROM document_write_requests WHERE namespace=? AND request_id=?")
|
||||
.get(namespace!, request.id) as { digest: string; response: string } | undefined;
|
||||
if (prior) {
|
||||
if (prior.digest !== digest) throw new DocumentConflictError("Idempotency-Key was used with different content");
|
||||
db.exec("COMMIT");
|
||||
return { ...JSON.parse(prior.response) as { slug: string; sourceSlug: string; revision: number; sections: ReturnType<typeof outline> }, replayed: true };
|
||||
}
|
||||
}
|
||||
const row = db.prepare(`SELECT d.id,d.payload FROM documents d JOIN document_aliases a
|
||||
ON a.document_id=d.id WHERE a.alias=?`).get(slug) as { id: string; payload: string } | undefined;
|
||||
if (!row) { db.exec("COMMIT"); return null; }
|
||||
const doc = JSON.parse(row.payload) as Doc;
|
||||
if (doc.revision !== edit.revision) throw new DocumentConflictError("Document revision conflict");
|
||||
const previous = loadSections(db, row.id, doc.content);
|
||||
const sections = applySectionOperations(previous, edit.operations);
|
||||
// Preserve the initial outline when upgrading an existing document for the first time.
|
||||
db.prepare("INSERT OR IGNORE INTO document_section_revisions VALUES (?,?,?)").run(row.id, doc.revision, JSON.stringify(previous));
|
||||
const saved = write(db, { ...doc, content: sections.map((s) => s.content).join("") }, doc.revision, sections);
|
||||
const receipt = { slug: saved.slug, sourceSlug: saved.sourceSlug, revision: saved.revision, sections: outline(sections) };
|
||||
if (request) db.prepare("INSERT INTO document_write_requests VALUES (?,?,?,?)")
|
||||
.run(namespace!, request.id, digest, JSON.stringify(receipt));
|
||||
db.exec("COMMIT");
|
||||
return { ...receipt, replayed: false };
|
||||
} catch (error) { db.exec("ROLLBACK"); throw error; }
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function saveDocument(input: DocInput, expectedRevision: number): Doc {
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw Error("Provide expected revision");
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new DocumentValidationError("Provide expected revision");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
@@ -145,3 +223,85 @@ export function documentHistory(sourceSlug: string): Doc[] {
|
||||
.map((row) => JSON.parse(row.payload) as Doc);
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
// API batches commit entirely or not at all; receipts make lost-response retries safe.
|
||||
export function saveDocumentBatch(documents: Doc[], request?: { namespace: string; id: string }) {
|
||||
if (!Array.isArray(documents) || !documents.length || documents.length > 100)
|
||||
throw new DocumentValidationError("Provide 1 to 100 documents");
|
||||
const seen = new Set<string>();
|
||||
for (const doc of documents) {
|
||||
validateDocument(doc);
|
||||
if (!Number.isSafeInteger(doc.revision) || doc.revision < 0)
|
||||
throw new DocumentValidationError("Provide expected revision");
|
||||
if (seen.has(doc.sourceSlug)) throw new DocumentValidationError("Duplicate sourceSlug in batch");
|
||||
seen.add(doc.sourceSlug);
|
||||
}
|
||||
if (request && (!request.namespace || !/^[A-Za-z0-9._:-]{1,128}$/.test(request.id)))
|
||||
throw new DocumentValidationError("Invalid Idempotency-Key");
|
||||
const digest = createHash("sha256").update(JSON.stringify(documents)).digest("hex");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
if (request) {
|
||||
const prior = db.prepare("SELECT digest,response FROM document_write_requests WHERE namespace=? AND request_id=?")
|
||||
.get(request.namespace, request.id) as { digest: string; response: string } | undefined;
|
||||
if (prior) {
|
||||
if (prior.digest !== digest) throw new DocumentConflictError("Idempotency-Key was used with different content");
|
||||
db.exec("COMMIT");
|
||||
return { documents: JSON.parse(prior.response) as DocumentReceipt[], replayed: true };
|
||||
}
|
||||
}
|
||||
const receipts = documents.map((input) => {
|
||||
const { slug, sourceSlug, revision } = write(db, input, input.revision);
|
||||
return { slug, sourceSlug, revision };
|
||||
});
|
||||
if (request) db.prepare("INSERT INTO document_write_requests VALUES (?,?,?,?)")
|
||||
.run(request.namespace, request.id, digest, JSON.stringify(receipts));
|
||||
db.exec("COMMIT");
|
||||
return { documents: receipts, replayed: false };
|
||||
} catch (error) { db.exec("ROLLBACK"); throw error; }
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function documentPage(limit = 50, offset = 0) {
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0)
|
||||
throw new DocumentValidationError("Invalid pagination");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN");
|
||||
const total = (db.prepare("SELECT COUNT(*) AS n FROM documents").get() as { n: number }).n;
|
||||
const documents = (db.prepare("SELECT json_remove(payload,'$.content') AS payload FROM documents ORDER BY source_slug LIMIT ? OFFSET ?")
|
||||
.all(limit, offset) as { payload: string }[]).map((row) => JSON.parse(row.payload) as DocLink);
|
||||
db.exec("COMMIT");
|
||||
return { documents, total, limit, offset, nextOffset: offset + documents.length < total ? offset + documents.length : null };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function documentHistoryPage(sourceSlug: string, limit = 50, offset = 0) {
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0)
|
||||
throw new DocumentValidationError("Invalid pagination");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN");
|
||||
const total = (db.prepare(`SELECT COUNT(*) AS n FROM document_revisions r JOIN documents d
|
||||
ON d.id=r.document_id WHERE d.source_slug=?`).get(sourceSlug) as { n: number }).n;
|
||||
const revisions = (db.prepare(`SELECT json_remove(r.payload,'$.content') AS payload, r.created_at AS createdAt
|
||||
FROM document_revisions r JOIN documents d ON d.id=r.document_id
|
||||
WHERE d.source_slug=? ORDER BY r.revision DESC LIMIT ? OFFSET ?`)
|
||||
.all(sourceSlug, limit, offset) as { payload: string; createdAt: string }[])
|
||||
.map((row) => ({ ...JSON.parse(row.payload) as DocLink, createdAt: row.createdAt }));
|
||||
db.exec("COMMIT");
|
||||
return { revisions, total, limit, offset, nextOffset: offset + revisions.length < total ? offset + revisions.length : null };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function documentRevision(sourceSlug: string, revision: number): Doc | null {
|
||||
if (!Number.isSafeInteger(revision) || revision < 1) throw new DocumentValidationError("Invalid revision");
|
||||
const db = database();
|
||||
try {
|
||||
const row = db.prepare(`SELECT r.payload FROM document_revisions r JOIN documents d
|
||||
ON d.id=r.document_id WHERE d.source_slug=? AND r.revision=?`).get(sourceSlug, revision) as { payload: string } | undefined;
|
||||
return row ? JSON.parse(row.payload) as Doc : null;
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export class DocumentValidationError extends Error {}
|
||||
export class DocumentConflictError extends Error {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { openSiteDatabase } from "./site-database";
|
||||
|
||||
function openImages() {
|
||||
const db = openSiteDatabase();
|
||||
try {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS document_images (
|
||||
id TEXT PRIMARY KEY,
|
||||
bytes BLOB NOT NULL
|
||||
)`);
|
||||
return db;
|
||||
} catch (error) {
|
||||
db.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDocumentImage(bytes: Buffer) {
|
||||
const id = createHash("sha256").update(bytes).digest("hex");
|
||||
const db = openImages();
|
||||
try {
|
||||
db.prepare("INSERT INTO document_images (id, bytes) VALUES (?, ?) ON CONFLICT(id) DO NOTHING").run(id, bytes);
|
||||
return id;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function readDocumentImage(id: string) {
|
||||
const db = openImages();
|
||||
try {
|
||||
const row = db.prepare("SELECT bytes FROM document_images WHERE id = ?").get(id) as
|
||||
| { bytes: Uint8Array }
|
||||
| undefined;
|
||||
return row ? Buffer.from(row.bytes) : undefined;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { openSiteDatabase } from "./site-database";
|
||||
|
||||
export type DocumentKeyScope = "read" | "read-write";
|
||||
export type DocumentKey = { id: string; name: string; scope: DocumentKeyScope;
|
||||
createdAt: number; expiresAt: number; revokedAt: number | null; lastUsedAt: number | null };
|
||||
function database() {
|
||||
const db = openSiteDatabase();
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS document_api_keys (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, digest TEXT NOT NULL UNIQUE,
|
||||
scope TEXT NOT NULL CHECK(scope IN ('read','read-write')), created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL, revoked_at INTEGER, last_used_at INTEGER
|
||||
);`);
|
||||
return db;
|
||||
}
|
||||
export function createDocumentKey(name: string, scope: DocumentKeyScope = "read-write", days = 90) {
|
||||
if (!name.trim() || name.length > 100 || !["read", "read-write"].includes(scope) ||
|
||||
!Number.isSafeInteger(days) || days < 1 || days > 365) throw Error("Invalid key name, scope or lifetime");
|
||||
const id = randomUUID();
|
||||
const token = "tdoc_" + randomBytes(32).toString("base64url");
|
||||
const createdAt = Date.now();
|
||||
const expiresAt = createdAt + days * 86400000;
|
||||
const db = database();
|
||||
try {
|
||||
db.prepare("INSERT INTO document_api_keys(id,name,digest,scope,created_at,expires_at) VALUES (?,?,?,?,?,?)")
|
||||
.run(id, name.trim(), createHash("sha256").update(token).digest("hex"), scope, createdAt, expiresAt);
|
||||
return { id, token, name: name.trim(), scope, createdAt, expiresAt };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
export function authenticateDocumentKey(token: string | undefined, now = Date.now()) {
|
||||
if (!token || !/^tdoc_[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
||||
const db = database();
|
||||
try {
|
||||
const row = db.prepare("SELECT id,scope,last_used_at AS lastUsedAt FROM document_api_keys WHERE digest=? AND revoked_at IS NULL AND expires_at>?")
|
||||
.get(createHash("sha256").update(token).digest("hex"), now) as { id: string; scope: DocumentKeyScope; lastUsedAt: number | null } | undefined;
|
||||
if (!row) return null;
|
||||
if (row.lastUsedAt === null || row.lastUsedAt < now - 60000)
|
||||
db.prepare("UPDATE document_api_keys SET last_used_at=? WHERE id=? AND (last_used_at IS NULL OR last_used_at<?)")
|
||||
.run(now, row.id, now - 60000);
|
||||
return { id: row.id, scope: row.scope };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
export function listDocumentKeys(): DocumentKey[] {
|
||||
const db = database();
|
||||
try {
|
||||
return db.prepare(`SELECT id,name,scope,created_at AS createdAt,expires_at AS expiresAt,
|
||||
revoked_at AS revokedAt,last_used_at AS lastUsedAt FROM document_api_keys ORDER BY created_at,id`).all() as DocumentKey[];
|
||||
} finally { db.close(); }
|
||||
}
|
||||
export function revokeDocumentKey(id: string) {
|
||||
const db = database();
|
||||
try {
|
||||
const result = db.prepare("UPDATE document_api_keys SET revoked_at=COALESCE(revoked_at,?) WHERE id=?").run(Date.now(), id);
|
||||
if (!result.changes) throw Error("Document key not found");
|
||||
} finally { db.close(); }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { DocumentValidationError } from "./document-errors";
|
||||
|
||||
export type DocumentSection = {
|
||||
id: string; title: string; content: string; summary: string;
|
||||
};
|
||||
export type SectionOperation =
|
||||
| { op: "update"; id: string; content?: string; summary?: string }
|
||||
| { op: "insert"; after: string; content: string; summary: string }
|
||||
| { op: "delete"; id: string }
|
||||
| { op: "move"; id: string; after: string };
|
||||
export type SectionEdit = { revision: number; operations: SectionOperation[] };
|
||||
const hash = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||
|
||||
// Preserve every byte, including CRLF and separators. H3-H6 belong to their H2 chapter.
|
||||
export function splitSections(content: string): DocumentSection[] {
|
||||
const starts: { offset: number; title: string }[] = [{ offset: 0, title: "导言" }];
|
||||
let offset = 0;
|
||||
let fence: { marker: string; length: number } | undefined;
|
||||
for (const line of content.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
|
||||
const opening = line.match(/^ {0,3}(`{3,}|~{3,})(.*?)(?:\r?\n)?$/);
|
||||
if (fence) {
|
||||
if (new RegExp(`^ {0,3}${fence.marker}{${fence.length},}[ \\t]*\\r?\\n?$`).test(line)) fence = undefined;
|
||||
} else if (opening && !(opening[1][0] === "`" && opening[2].includes("`"))) {
|
||||
fence = { marker: opening[1][0], length: opening[1].length };
|
||||
} else {
|
||||
const heading = line.match(/^##(?:[ \t]+(.*?))?[ \t]*\r?\n?$/);
|
||||
if (heading) starts.push({ offset, title: (heading[1] ?? "").replace(/[ \t]+#+[ \t]*$/, "").trim() });
|
||||
}
|
||||
offset += line.length;
|
||||
}
|
||||
const seen = new Map<string, number>();
|
||||
return starts.map((start, index) => {
|
||||
const occurrence = seen.get(start.title) ?? 0;
|
||||
seen.set(start.title, occurrence + 1);
|
||||
return { id: index === 0 ? "preamble" : "s_" + hash(`${start.title}\0${occurrence}`).slice(0, 24),
|
||||
title: start.title, content: content.slice(start.offset, starts[index + 1]?.offset), summary: "" };
|
||||
});
|
||||
}
|
||||
|
||||
export function sectionSchema(db: DatabaseSync) {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS document_sections (
|
||||
document_id TEXT PRIMARY KEY REFERENCES documents(id), payload TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_section_revisions (
|
||||
document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL, PRIMARY KEY(document_id, revision)
|
||||
);`);
|
||||
}
|
||||
|
||||
export function loadSections(db: DatabaseSync, documentId: string, content: string): DocumentSection[] {
|
||||
const row = db.prepare("SELECT payload FROM document_sections WHERE document_id=?")
|
||||
.get(documentId) as { payload: string } | undefined;
|
||||
if (!row) return splitSections(content);
|
||||
const sections = JSON.parse(row.payload) as DocumentSection[];
|
||||
// A legacy writer must not leave an apparently current outline over different Markdown.
|
||||
return sections.map((s) => s.content).join("") === content ? sections : reconcileSections(content, sections);
|
||||
}
|
||||
|
||||
export function reconcileSections(content: string, previous: DocumentSection[]): DocumentSection[] {
|
||||
const unused = new Set(previous);
|
||||
return splitSections(content).map((section) => {
|
||||
const prior = section.id === "preamble" ? previous.find((p) => p.id === "preamble") :
|
||||
previous.find((p) => p.id !== "preamble" && unused.has(p) && p.content === section.content) ??
|
||||
previous.find((p) => p.id !== "preamble" && unused.has(p) && p.title === section.title);
|
||||
if (prior) unused.delete(prior);
|
||||
return { ...section, id: prior?.id ?? (section.id === "preamble" ? "preamble" : "s_" + randomUUID()),
|
||||
summary: prior?.content === section.content ? prior.summary : "" };
|
||||
});
|
||||
}
|
||||
|
||||
export function persistSections(db: DatabaseSync, documentId: string, revision: number, sections: DocumentSection[]) {
|
||||
const payload = JSON.stringify(sections);
|
||||
db.prepare(`INSERT INTO document_sections VALUES (?,?) ON CONFLICT(document_id)
|
||||
DO UPDATE SET payload=excluded.payload`).run(documentId, payload);
|
||||
db.prepare("INSERT INTO document_section_revisions VALUES (?,?,?)").run(documentId, revision, payload);
|
||||
}
|
||||
|
||||
export function outline(sections: DocumentSection[]) {
|
||||
return sections.map(({ content, ...section }, position) => ({ ...section, position,
|
||||
contentHash: hash(content), summaryStatus: section.summary.trim() ? "current" : "missing" }));
|
||||
}
|
||||
|
||||
export function parseSectionEdit(value: unknown): SectionEdit {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new DocumentValidationError("Expected section edit object");
|
||||
const row = value as Record<string, unknown>;
|
||||
if (Object.keys(row).some((key) => !["revision", "operations"].includes(key)) ||
|
||||
!Number.isSafeInteger(row.revision) || (row.revision as number) < 1 || !Array.isArray(row.operations) ||
|
||||
!row.operations.length || row.operations.length > 100) throw new DocumentValidationError("Provide revision and 1 to 100 operations");
|
||||
for (const raw of row.operations) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DocumentValidationError("Invalid operation");
|
||||
const op = raw as Record<string, unknown>;
|
||||
const fields: Record<string, string[]> = { update: ["op", "id", "content", "summary"],
|
||||
insert: ["op", "after", "content", "summary"], delete: ["op", "id"], move: ["op", "id", "after"] };
|
||||
if (typeof op.op !== "string" || !Object.hasOwn(fields, op.op) || Object.keys(op).some((key) => !fields[op.op as string].includes(key)))
|
||||
throw new DocumentValidationError("Unknown operation or field");
|
||||
for (const key of fields[op.op].filter((key) => !["content", "summary", "op"].includes(key)))
|
||||
if (typeof op[key] !== "string" || !(op[key] as string).length) throw new DocumentValidationError("Missing section identifier");
|
||||
for (const key of ["content", "summary"])
|
||||
if (op[key] !== undefined && typeof op[key] !== "string") throw new DocumentValidationError(`Invalid ${key}`);
|
||||
if (op.op === "update" && op.content === undefined && op.summary === undefined) throw new DocumentValidationError("Empty update");
|
||||
if (op.op === "insert" && (typeof op.content !== "string" || typeof op.summary !== "string" || !op.summary.trim()))
|
||||
throw new DocumentValidationError("Insert requires content and summary");
|
||||
if (typeof op.summary === "string" && (!op.summary.trim() || op.summary.length > 8000))
|
||||
throw new DocumentValidationError("Summary must contain 1 to 8000 characters");
|
||||
}
|
||||
return { revision: row.revision as number, operations: row.operations as SectionOperation[] };
|
||||
}
|
||||
|
||||
export function applySectionOperations(previous: DocumentSection[], operations: SectionOperation[]) {
|
||||
const sections = previous.map((section) => ({ ...section }));
|
||||
function index(id: string) {
|
||||
const result = sections.findIndex((s) => s.id === id);
|
||||
if (result < 0) throw new DocumentValidationError("Unknown section id");
|
||||
return result;
|
||||
}
|
||||
for (const op of operations) {
|
||||
if (op.op === "insert") {
|
||||
sections.splice(index(op.after) + 1, 0, { id: "s_" + randomUUID(), title: "", content: op.content, summary: op.summary });
|
||||
} else {
|
||||
const at = index(op.id);
|
||||
if (op.op === "update") {
|
||||
if (op.content !== undefined && op.content !== sections[at].content) sections[at].summary = "";
|
||||
if (op.content !== undefined) sections[at].content = op.content;
|
||||
if (op.summary !== undefined) sections[at].summary = op.summary;
|
||||
} else {
|
||||
if (op.id === "preamble") throw new DocumentValidationError("Cannot delete or move the preamble");
|
||||
if (op.op === "delete") sections.splice(at, 1);
|
||||
else {
|
||||
if (op.id === op.after) throw new DocumentValidationError("Cannot move section after itself");
|
||||
index(op.after);
|
||||
const [section] = sections.splice(at, 1);
|
||||
sections.splice(index(op.after) + 1, 0, section);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const content = sections.map((s) => s.content).join("");
|
||||
const parsed = splitSections(content);
|
||||
if (parsed.length !== sections.length || parsed.some((s, i) => s.content !== sections[i].content))
|
||||
throw new DocumentValidationError("Each chapter must start with one H2 heading; preserve newline boundaries and balanced fences");
|
||||
return sections.map((s, i) => ({ ...s, title: parsed[i].title }));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authenticateDocumentKey } from "./document-keys";
|
||||
import { DocumentConflictError, DocumentValidationError, validateDocument, type Doc } from "./docs";
|
||||
|
||||
export function authorizeDocuments(request: NextApiRequest, response: NextApiResponse, write = false) {
|
||||
response.setHeader("Cache-Control", "private, no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
const header = request.headers.authorization;
|
||||
const token = typeof header === "string" && header.startsWith("Bearer ") ? header.slice(7) : undefined;
|
||||
const key = authenticateDocumentKey(token);
|
||||
if (!key) {
|
||||
response.status(401).json({ error: "需要有效的文档 API Key", code: "unauthorized" });
|
||||
return null;
|
||||
}
|
||||
if (write && key.scope !== "read-write") {
|
||||
response.status(403).json({ error: "该 API Key 仅允许读取文档", code: "read_only" });
|
||||
return null;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
export function documentApiError(response: NextApiResponse, error: unknown) {
|
||||
if (error instanceof DocumentConflictError)
|
||||
return response.status(409).json({ error: error.message, code: "conflict" });
|
||||
if (error instanceof DocumentValidationError)
|
||||
return response.status(400).json({ error: error.message, code: "invalid_document" });
|
||||
return response.status(503).json({ error: "文档数据库暂时不可用", code: "unavailable" });
|
||||
}
|
||||
export function parseDocument(value: unknown): Doc {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new DocumentValidationError("Expected a document object");
|
||||
const row = value as Record<string, unknown>;
|
||||
const fields = ["slug", "sourceSlug", "title", "category", "collection", "group", "description", "draft", "featured", "content", "revision"];
|
||||
if (Object.keys(row).some((key) => !fields.includes(key))) throw new DocumentValidationError("Unknown document field");
|
||||
const doc = Object.fromEntries(fields.map((key) => [key, row[key]])) as Doc;
|
||||
validateDocument(doc);
|
||||
if (!Number.isSafeInteger(doc.revision) || doc.revision < 0) throw new DocumentValidationError("Provide expected revision");
|
||||
return doc;
|
||||
}
|
||||
export function pagination(request: NextApiRequest) {
|
||||
const number = (value: string | string[] | undefined, fallback: number) => {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new DocumentValidationError("Invalid pagination");
|
||||
return Number(value);
|
||||
};
|
||||
return { limit: number(request.query.limit, 50), offset: number(request.query.offset, 0) };
|
||||
}
|
||||
export function idempotency(request: NextApiRequest, namespace: string) {
|
||||
const id = request.headers["idempotency-key"];
|
||||
if (id === undefined) return undefined;
|
||||
if (typeof id !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(id)) throw new DocumentValidationError("Invalid Idempotency-Key");
|
||||
return { namespace, id };
|
||||
}
|
||||
@@ -1,6 +1,50 @@
|
||||
import type { ResearchCompany, ResearchObservation, ResearchSnapshot } from "./ai-research";
|
||||
|
||||
export type IncomePoint = { company: ResearchCompany; observation: ResearchObservation; value: number };
|
||||
|
||||
/** An evidence ledger, never a comparable sample: retain historical claims and estimates separately. */
|
||||
export function arrDisclosures(snapshot: ResearchSnapshot, options: { category?: string; foundedYear?: number } = {}) {
|
||||
const companies = new Map(snapshot.companies.map((company) => [company.id, company]));
|
||||
return snapshot.observations.flatMap((observation) => {
|
||||
const company = companies.get(observation.company);
|
||||
if (!company || ![2025, 2026].includes(company.year ?? 0) || observation.metric !== "arr" ||
|
||||
!["signal", "estimate", "historical", "historical-estimate"].includes(observation.selection) ||
|
||||
!Number.isFinite(observation.value) || observation.value < 0 ||
|
||||
(options.category && company.cat !== options.category) ||
|
||||
(options.foundedYear && company.year !== options.foundedYear)) return [];
|
||||
return [{ company, observation }];
|
||||
});
|
||||
}
|
||||
|
||||
/** Count companies once; missing ARR is a coverage gap, never zero revenue. */
|
||||
export function arrDisclosureCoverage(snapshot: ResearchSnapshot, foundedYear?: number) {
|
||||
const rows = arrDisclosures(snapshot, { foundedYear });
|
||||
return snapshot.categories.map((category) => {
|
||||
const companies = snapshot.companies.filter((company) => company.cat === category.id &&
|
||||
[2025, 2026].includes(company.year ?? 0) && (!foundedYear || company.year === foundedYear));
|
||||
const claims = rows.filter((row) => row.company.cat === category.id);
|
||||
const disclosed = new Set(claims.map((row) => row.company.id));
|
||||
const reported = new Set(claims.filter((row) =>
|
||||
["signal", "historical"].includes(row.observation.selection)).map((row) => row.company.id));
|
||||
return { id: category.id, name: category.name, companies: companies.length,
|
||||
disclosed: disclosed.size, reported: reported.size, estimatedOnly: disclosed.size - reported.size,
|
||||
missing: companies.length - disclosed.size, records: claims.length };
|
||||
});
|
||||
}
|
||||
|
||||
export type ArrFieldGap = "date" | "currency" | "amount" | "estimate";
|
||||
|
||||
/** Prerequisites for inspecting exact, dated USD disclosures; never a comparability or audit verdict. */
|
||||
export function arrFieldGaps(observation: ResearchObservation, asOf: string): ArrFieldGap[] {
|
||||
const gaps: ArrFieldGap[] = [];
|
||||
const day = calendarDay(observation.observedAt);
|
||||
const end = calendarDay(asOf);
|
||||
if (day === null || end === null || day > end) gaps.push("date");
|
||||
if (observation.unit !== "USD") gaps.push("currency");
|
||||
if (observation.bound !== "=" || !Number.isFinite(observation.value) || observation.value < 0) gaps.push("amount");
|
||||
if (["estimate", "historical-estimate"].includes(observation.selection)) gaps.push("estimate");
|
||||
return gaps;
|
||||
}
|
||||
export type IncomeStats = {
|
||||
n: number; mean: number; median: number; min: number; max: number;
|
||||
sampleDeviation: number | null; cv: number | null; meanMedianRatio: number | null;
|
||||
@@ -80,3 +124,24 @@ export function groupIncome(points: IncomePoint[], splitModels = true) {
|
||||
}
|
||||
return [...groups.entries()].map(([key, group]) => ({ key, ...group, stats: describeIncome(group.points.map((point) => point.value))! }));
|
||||
}
|
||||
|
||||
/** Compare descriptive statistics only within the same provider and delivery model. */
|
||||
export function compareIncomeGroups(groups: ReturnType<typeof groupIncome>) {
|
||||
const buckets = new Map<string, typeof groups>();
|
||||
for (const group of groups) {
|
||||
if (group.stats.n < 3 || group.stats.cv === null || group.stats.largestRemovalDrop === null ||
|
||||
group.model === "全部收费模式" || group.model === "收费模式未核验") continue;
|
||||
const key = JSON.stringify([group.provider, group.model]);
|
||||
const bucket = buckets.get(key) ?? [];
|
||||
bucket.push(group);
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
return [...buckets.entries()].flatMap(([key, rows]) => {
|
||||
if (rows.length < 2) return [];
|
||||
const byCV = [...rows].sort((a, b) => a.stats.cv! - b.stats.cv! || a.key.localeCompare(b.key));
|
||||
const bySensitivity = [...rows].sort((a, b) => a.stats.largestRemovalDrop! - b.stats.largestRemovalDrop! || a.key.localeCompare(b.key));
|
||||
return [{ key, provider: rows[0].provider, model: rows[0].model,
|
||||
leastDispersed: byCV[0], mostDispersed: byCV[byCV.length - 1],
|
||||
leastSensitive: bySensitivity[0], mostSensitive: bySensitivity[bySensitivity.length - 1] }];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { DocumentValidationError, saveDocumentBatch } from "@/lib/docs";
|
||||
import { authorizeDocuments, documentApiError, idempotency, parseDocument } from "@/lib/documents-api";
|
||||
|
||||
export const config = { api: { bodyParser: { sizeLimit: "8mb" } } };
|
||||
export default function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
try {
|
||||
const key = authorizeDocuments(request, response, true);
|
||||
if (!key) return;
|
||||
if (request.method !== "POST") {
|
||||
response.setHeader("Allow", "POST");
|
||||
return response.status(405).end();
|
||||
}
|
||||
const body = request.body;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body) ||
|
||||
Object.keys(body).length !== 1 || !Array.isArray(body.documents) ||
|
||||
!body.documents.length || body.documents.length > 100)
|
||||
throw new DocumentValidationError("Expected { documents: [...] } with 1 to 100 documents");
|
||||
const documents = body.documents.map((row: unknown) => parseDocument(row));
|
||||
return response.status(200).json(saveDocumentBatch(documents, idempotency(request, key.id)));
|
||||
} catch (error) { return documentApiError(response, error); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { documentApiError } from "@/lib/documents-api";
|
||||
import { readDocumentImage } from "@/lib/document-images";
|
||||
|
||||
// The site proxy accepts a logged-in reader or a document API key on this route.
|
||||
export default function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
try {
|
||||
response.setHeader("Cache-Control", "private, no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
response.setHeader("Allow", "GET, HEAD");
|
||||
return response.status(405).end();
|
||||
}
|
||||
const id = request.query.id;
|
||||
if (typeof id !== "string" || !/^[a-f0-9]{64}$/.test(id)) return response.status(404).end();
|
||||
const bytes = readDocumentImage(id);
|
||||
if (!bytes) return response.status(404).end();
|
||||
response.setHeader("Content-Type", "image/webp");
|
||||
response.setHeader("Content-Length", bytes.length);
|
||||
return request.method === "HEAD" ? response.status(200).end() : response.status(200).send(bytes);
|
||||
} catch (error) {
|
||||
return documentApiError(response, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import sharp from "sharp";
|
||||
import { authorizeDocuments, documentApiError } from "@/lib/documents-api";
|
||||
import { saveDocumentImage } from "@/lib/document-images";
|
||||
|
||||
const maxUploadBytes = 8 * 1024 * 1024;
|
||||
const maxStoredBytes = 2 * 1024 * 1024;
|
||||
const imageTypes = new Set(["image/png", "image/jpeg", "image/webp"]);
|
||||
const imageFormats = new Set(["png", "jpeg", "webp"]);
|
||||
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
export default async function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
try {
|
||||
if (request.method !== "POST") {
|
||||
response.setHeader("Allow", "POST");
|
||||
return response.status(405).end();
|
||||
}
|
||||
if (!authorizeDocuments(request, response, true)) return;
|
||||
|
||||
const type = request.headers["content-type"]?.split(";")[0].trim().toLowerCase();
|
||||
if (!type || !imageTypes.has(type)) {
|
||||
return response.status(415).json({ error: "请上传 PNG、JPEG 或 WebP 图片" });
|
||||
}
|
||||
if (Number(request.headers["content-length"]) > maxUploadBytes) {
|
||||
return response.status(413).json({ error: "图片上传上限为 8 MiB" });
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let length = 0;
|
||||
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
length += bytes.length;
|
||||
if (length > maxUploadBytes) {
|
||||
return response.status(413).json({ error: "图片上传上限为 8 MiB" });
|
||||
}
|
||||
chunks.push(bytes);
|
||||
}
|
||||
|
||||
let encoded: Buffer;
|
||||
try {
|
||||
const input = sharp(Buffer.concat(chunks), { limitInputPixels: 25_000_000 });
|
||||
const { format, pages } = await input.metadata();
|
||||
if (!format || !imageFormats.has(format) || type !== `image/${format}` || (pages ?? 1) > 1) {
|
||||
return response.status(415).json({ error: "图片格式需与声明一致,且只支持静态图片" });
|
||||
}
|
||||
encoded = await input.rotate()
|
||||
.resize({ width: 2000, height: 4000, fit: "inside", withoutEnlargement: true })
|
||||
.webp({ quality: 85 })
|
||||
.toBuffer();
|
||||
} catch {
|
||||
return response.status(400).json({ error: "无法解码图片,或像素数量超过限制" });
|
||||
}
|
||||
if (encoded.length > maxStoredBytes) {
|
||||
return response.status(413).json({ error: "处理后的图片超过 2 MiB,请缩小后重试" });
|
||||
}
|
||||
|
||||
const id = saveDocumentImage(encoded);
|
||||
const url = `/api/documents/images/${id}`;
|
||||
return response.status(200).json({ id, url, contentType: "image/webp", size: encoded.length });
|
||||
} catch (error) {
|
||||
return documentApiError(response, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { documentPage, saveDocumentBatch } from "@/lib/docs";
|
||||
import { authorizeDocuments, documentApiError, idempotency, pagination, parseDocument } from "@/lib/documents-api";
|
||||
|
||||
export const config = { api: { bodyParser: { sizeLimit: "8mb" } } };
|
||||
export default function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
try {
|
||||
const key = authorizeDocuments(request, response, request.method === "POST");
|
||||
if (!key) return;
|
||||
if (request.method === "GET") {
|
||||
const { limit, offset } = pagination(request);
|
||||
return response.status(200).json(documentPage(limit, offset));
|
||||
}
|
||||
if (request.method === "POST") {
|
||||
const result = saveDocumentBatch([parseDocument(request.body)], idempotency(request, key.id));
|
||||
return response.status(200).json({ document: result.documents[0], replayed: result.replayed });
|
||||
}
|
||||
response.setHeader("Allow", "GET, POST");
|
||||
return response.status(405).end();
|
||||
} catch (error) { return documentApiError(response, error); }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { DocumentValidationError, documentHistoryPage, documentRevision, readDocument } from "@/lib/docs";
|
||||
import { authorizeDocuments, documentApiError, pagination } from "@/lib/documents-api";
|
||||
|
||||
export default function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
try {
|
||||
if (!authorizeDocuments(request, response)) return;
|
||||
if (request.method !== "GET") {
|
||||
response.setHeader("Allow", "GET");
|
||||
return response.status(405).end();
|
||||
}
|
||||
const slug = request.query.slug;
|
||||
if (!Array.isArray(slug)) throw new DocumentValidationError("Invalid document slug");
|
||||
const doc = readDocument(slug.join("/"));
|
||||
if (!doc) return response.status(404).json({ error: "文档不存在", code: "not_found" });
|
||||
if (request.query.revision !== undefined) {
|
||||
if (request.query.history !== undefined || typeof request.query.revision !== "string" || !/^\d+$/.test(request.query.revision))
|
||||
throw new DocumentValidationError("Invalid revision query");
|
||||
const prior = documentRevision(doc.sourceSlug, Number(request.query.revision));
|
||||
if (!prior) return response.status(404).json({ error: "修订不存在", code: "not_found" });
|
||||
return response.status(200).json(prior);
|
||||
}
|
||||
if (request.query.history !== undefined) {
|
||||
if (request.query.history !== "1") throw new DocumentValidationError("Use history=1");
|
||||
const { limit, offset } = pagination(request);
|
||||
return response.status(200).json(documentHistoryPage(doc.sourceSlug, limit, offset));
|
||||
}
|
||||
return response.status(200).json(doc);
|
||||
} catch (error) { return documentApiError(response, error); }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { DocumentValidationError, readDocumentSections, editDocumentSections } from "@/lib/docs";
|
||||
import { outline } from "@/lib/document-sections";
|
||||
import { authorizeDocuments, documentApiError, idempotency } from "@/lib/documents-api";
|
||||
|
||||
export const config = { api: { bodyParser: { sizeLimit: "8mb" } } };
|
||||
export default function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
try {
|
||||
const key = authorizeDocuments(request, response, request.method === "POST");
|
||||
if (!key) return;
|
||||
if (request.method !== "GET" && request.method !== "POST") {
|
||||
response.setHeader("Allow", "GET, POST");
|
||||
return response.status(405).end();
|
||||
}
|
||||
if (!Array.isArray(request.query.slug)) throw new DocumentValidationError("Invalid document slug");
|
||||
const slug = request.query.slug.join("/");
|
||||
if (Object.keys(request.query).some((k) => !["slug", "section", "revision"].includes(k)))
|
||||
throw new DocumentValidationError("Unknown section query");
|
||||
if (request.method === "POST") {
|
||||
if (request.query.section !== undefined || request.query.revision !== undefined)
|
||||
throw new DocumentValidationError("POST uses revision and operations in the body");
|
||||
const saved = editDocumentSections(slug, request.body, idempotency(request, key.id));
|
||||
return saved ? response.status(200).json(saved) : response.status(404).json({ code: "not_found" });
|
||||
}
|
||||
const revision = request.query.revision;
|
||||
if (revision !== undefined && (typeof revision !== "string" || !/^[1-9]\d*$/.test(revision)))
|
||||
throw new DocumentValidationError("Invalid revision");
|
||||
const doc = readDocumentSections(slug, revision === undefined ? undefined : Number(revision));
|
||||
if (!doc) return response.status(404).json({ code: "not_found" });
|
||||
const selected = request.query.section;
|
||||
if (selected === undefined) return response.status(200).json({ ...doc, sections: outline(doc.sections) });
|
||||
const ids = typeof selected === "string" ? [selected] : selected;
|
||||
if (!ids.length || ids.length > 100 || ids.some((id) => !id)) throw new DocumentValidationError("Select 1 to 100 sections");
|
||||
if (ids.some((id) => !doc.sections.some((s) => s.id === id))) return response.status(404).json({ code: "not_found" });
|
||||
return response.status(200).json({ ...doc, sections: doc.sections.filter((s) => ids.includes(s.id)) });
|
||||
} catch (error) { return documentApiError(response, error); }
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { collectionEntries, collectionLabel } from "@/lib/collections";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { headingsFor } from "@/lib/navigation";
|
||||
|
||||
function isZoomableFigure(source: string) {
|
||||
return source.startsWith("/figures/endor-") || /^\/api\/documents\/images\/[a-f0-9]{64}$/.test(source);
|
||||
}
|
||||
|
||||
type Props = { doc: Doc; docs: DocLink[] };
|
||||
const AICompanyResearch = dynamic(() => import("@/components/AICompanyResearch"));
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async ({ params, res, resolvedUrl }) => {
|
||||
@@ -120,14 +124,14 @@ export default function DocPage({
|
||||
const slides = node?.children.flatMap((child) =>
|
||||
child.type === "element" && child.tagName === "img" &&
|
||||
typeof child.properties.src === "string" &&
|
||||
child.properties.src.startsWith("/figures/endor-")
|
||||
isZoomableFigure(child.properties.src)
|
||||
? [{ source: child.properties.src, alt: String(child.properties.alt ?? "") }]
|
||||
: [],
|
||||
) ?? [];
|
||||
const onlyImages = node?.children.every((child) =>
|
||||
(child.type === "element" && child.tagName === "img" &&
|
||||
typeof child.properties.src === "string" &&
|
||||
child.properties.src.startsWith("/figures/endor-")) ||
|
||||
isZoomableFigure(child.properties.src)) ||
|
||||
(child.type === "text" && child.value.trim() === ""),
|
||||
);
|
||||
if (slides.length > 1 && onlyImages) return <FigureGallery slides={slides} />;
|
||||
@@ -141,7 +145,7 @@ export default function DocPage({
|
||||
);
|
||||
},
|
||||
img: ({ src, alt }) =>
|
||||
typeof src === "string" && src.startsWith("/figures/endor-") ? (
|
||||
typeof src === "string" && isZoomableFigure(src) ? (
|
||||
<ArchitectureFigure source={src} alt={alt ?? "Endor 架构图"} />
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
|
||||
import { authenticateDocumentKey } from "./lib/document-keys";
|
||||
export function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
const headers = {
|
||||
@@ -31,6 +32,14 @@ export function proxy(request: NextRequest) {
|
||||
? authorization.slice(7)
|
||||
: undefined;
|
||||
const token = bearer ?? request.cookies.get(SESSION_COOKIE)?.value;
|
||||
// Document keys are scoped to the document API; they never authenticate other pages or APIs.
|
||||
if (bearer && (path === "/api/documents" || path.startsWith("/api/documents/"))) {
|
||||
try {
|
||||
if (authenticateDocumentKey(bearer)) return NextResponse.next({ headers });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "文档认证暂时不可用" }, { status: 503, headers });
|
||||
}
|
||||
}
|
||||
if (validSession(token, config)) {
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !bearer) {
|
||||
try {
|
||||
|
||||
@@ -1473,6 +1473,13 @@ svg { flex-shrink: 0; }
|
||||
|
||||
.research-distribution { margin: 24px 0 38px; }
|
||||
.research-distribution figure { margin: 20px 0; }
|
||||
.ai-research .income-statistics { min-width: 1200px; }
|
||||
/* Row-spanning category headings shift cell indexes between rows. */
|
||||
.ai-research .income-statistics td { min-width: 0; }
|
||||
.ai-research .income-statistics th[scope="rowgroup"] { min-width: 150px; white-space: nowrap; }
|
||||
.ai-research .income-statistics th[scope="rowgroup"] small { display: block; margin-top: 5px; }
|
||||
.ai-research .income-statistics td:not(:last-child) { white-space: nowrap; }
|
||||
.ai-research .income-statistics td:last-child { min-width: 220px; }
|
||||
.income-distribution-chart { display: block; width: 100%; min-width: 900px; height: auto; }
|
||||
.income-distribution-chart [role="button"] { cursor: pointer; outline: none; }
|
||||
.income-distribution-chart [role="button"]:focus circle:last-child { stroke: #b17725; stroke-width: 3; }
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import documents from "../src/pages/api/documents/index";
|
||||
import batch from "../src/pages/api/documents/batch";
|
||||
import item from "../src/pages/api/documents/item/[...slug]";
|
||||
import sections from "../src/pages/api/documents/sections/[...slug]";
|
||||
import { splitSections } from "../src/lib/document-sections";
|
||||
import { openSiteDatabase } from "../src/lib/site-database";
|
||||
import { createDocumentKey, authenticateDocumentKey, revokeDocumentKey, listDocumentKeys } from "../src/lib/document-keys";
|
||||
import { allDocuments, documentHistory, readDocument, saveDocument, readDocumentSections, type Doc } from "../src/lib/docs";
|
||||
import { newAuthConfig, signSession } from "../src/lib/auth";
|
||||
|
||||
const input: Doc = { slug: "endor/example", sourceSlug: "00-example", title: "Example", category: "Endor",
|
||||
collection: "Endor 文档", group: "讨论", description: "Description", draft: true, featured: false,
|
||||
content: "# Example\n\nOriginal body", revision: 0 };
|
||||
type Options = { method?: string; body?: unknown; headers?: Record<string, string>;
|
||||
query?: Record<string, string | string[]> };
|
||||
function call(handler: (req: NextApiRequest, res: NextApiResponse) => unknown, options: Options = {}) {
|
||||
let status = 200;
|
||||
let body: unknown;
|
||||
const headers: Record<string, string> = {};
|
||||
const response = {
|
||||
status(value: number) { status = value; return this; },
|
||||
setHeader(name: string, value: string) { headers[name.toLowerCase()] = value; return this; },
|
||||
json(value: unknown) { body = value; return this; },
|
||||
end() { return this; },
|
||||
};
|
||||
handler({ method: options.method ?? "GET", body: options.body, headers: options.headers ?? {},
|
||||
query: options.query ?? {} } as unknown as NextApiRequest, response as unknown as NextApiResponse);
|
||||
return { status, body, headers };
|
||||
}
|
||||
function isolated(run: (context: { directory: string; headers: Record<string, string> }) => void) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "tabularium-doc-api-"));
|
||||
const previous = process.env.AUTH_CONFIG_FILE;
|
||||
process.env.AUTH_CONFIG_FILE = join(directory, "config.json");
|
||||
writeFileSync(process.env.AUTH_CONFIG_FILE, JSON.stringify(newAuthConfig()), { mode: 0o600 });
|
||||
try {
|
||||
const key = createDocumentKey("test uploader");
|
||||
run({ directory, headers: { authorization: "Bearer " + key.token } });
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
|
||||
else process.env.AUTH_CONFIG_FILE = previous;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("new API requires document keys, rejects browser cookies and broad web sessions", () => isolated(() => {
|
||||
assert.equal(call(documents).status, 401);
|
||||
assert.equal(call(documents, { headers: { authorization: "Bearer invalid" } }).status, 401);
|
||||
const config = JSON.parse(readFileSync(process.env.AUTH_CONFIG_FILE!, "utf8"));
|
||||
const token = signSession(config);
|
||||
assert.equal(call(documents, { headers: { authorization: "Bearer " + token } }).status, 401);
|
||||
assert.equal(call(documents, { headers: { cookie: "tabularium_session=" + token } }).status, 401);
|
||||
assert.deepEqual(allDocuments(), []);
|
||||
}));
|
||||
|
||||
test("key scopes, revocation, expiry and secret-free listing are persistent", () => isolated(({ directory }) => {
|
||||
const key = createDocumentKey("reader", "read", 1);
|
||||
const headers = { authorization: "Bearer " + key.token };
|
||||
assert.equal(call(documents, { headers }).status, 200);
|
||||
assert.equal(call(documents, { method: "POST", headers, body: input }).status, 403);
|
||||
assert.equal(call(batch, { method: "POST", headers, body: { documents: [input] } }).status, 403);
|
||||
assert.ok(!JSON.stringify(listDocumentKeys()).includes(key.token));
|
||||
assert.ok(!readFileSync(join(directory, "login-state.sqlite")).includes(Buffer.from(key.token)));
|
||||
assert.equal(authenticateDocumentKey(key.token, key.expiresAt), null);
|
||||
revokeDocumentKey(key.id);
|
||||
assert.equal(call(documents, { headers }).status, 401);
|
||||
const revoked = listDocumentKeys().find((row) => row.id === key.id);
|
||||
assert.ok(revoked); assert.equal(typeof revoked.revokedAt, "number");
|
||||
}));
|
||||
|
||||
test("single save, aliases, metadata and historical content are visible without rebuilding", () => isolated(({ headers }) => {
|
||||
assert.equal(call(documents, { method: "POST", headers, body: input }).status, 200);
|
||||
assert.equal((call(item, { headers, query: { slug: input.slug.split("/") } }).body as Doc).revision, 1);
|
||||
const changed = { ...input, revision: 1, slug: "endor/renamed", content: "# Changed", collection: "新合集" };
|
||||
assert.equal(call(documents, { method: "POST", headers, body: changed }).status, 200);
|
||||
assert.equal((call(item, { headers, query: { slug: input.slug.split("/") } }).body as Doc).slug, changed.slug);
|
||||
assert.equal((call(item, { headers, query: { slug: [input.sourceSlug], revision: "1" } }).body as Doc).content, input.content);
|
||||
const history = call(item, { headers, query: { slug: [input.sourceSlug], history: "1", limit: "1" } });
|
||||
const page = history.body as { revisions: Doc[]; total: number; nextOffset: number };
|
||||
assert.equal(page.total, 2);
|
||||
assert.equal(page.nextOffset, 1);
|
||||
assert.equal(page.revisions[0].revision, 2);
|
||||
assert.equal(page.revisions[0].content, undefined);
|
||||
assert.equal(call(item, { headers, query: { slug: [input.sourceSlug], revision: "99" } }).status, 404);
|
||||
assert.equal(call(item, { headers, query: { slug: [input.sourceSlug], revision: "0" } }).status, 400);
|
||||
}));
|
||||
|
||||
test("pagination returns metadata only with strict bounds", () => isolated(({ headers }) => {
|
||||
const second = { ...input, slug: "endor/second", sourceSlug: "01-second" };
|
||||
assert.equal(call(batch, { method: "POST", headers, body: { documents: [input, second] } }).status, 200);
|
||||
const result = call(documents, { headers, query: { limit: "1" } });
|
||||
const page = result.body as { documents: Doc[]; total: number; nextOffset: number };
|
||||
assert.equal(page.total, 2); assert.equal(page.nextOffset, 1);
|
||||
assert.equal(page.documents[0].content, undefined);
|
||||
assert.equal((call(documents, { headers, query: { limit: "1", offset: "1" } }).body as typeof page).documents[0].slug, second.slug);
|
||||
const invalidQueries: Record<string, string | string[]>[] = [
|
||||
{ limit: "101" }, { offset: "-1" }, { limit: ["1", "2"] }, { limit: "1e2" },
|
||||
];
|
||||
for (const query of invalidQueries) assert.equal(call(documents, { headers, query }).status, 400);
|
||||
assert.equal(result.headers["cache-control"], "private, no-store");
|
||||
}));
|
||||
|
||||
test("late batch conflict rolls back earlier rows and their revision history", () => isolated(({ headers }) => {
|
||||
saveDocument({ ...input, slug: "endor/existing", sourceSlug: "existing" }, 0);
|
||||
const conflict = { ...input, slug: "endor/existing", sourceSlug: "existing", revision: 0 };
|
||||
assert.equal(call(batch, { method: "POST", headers, body: { documents: [input, conflict] } }).status, 409);
|
||||
assert.equal(readDocument(input.slug), null);
|
||||
assert.equal(documentHistory(input.sourceSlug).length, 0);
|
||||
assert.equal(documentHistory("existing").length, 1);
|
||||
assert.equal(allDocuments().length, 1);
|
||||
}));
|
||||
|
||||
test("idempotent retries return the original receipt without creating another revision", () => isolated(({ headers }) => {
|
||||
const keyed = { ...headers, "idempotency-key": "upload-1" };
|
||||
const first = call(documents, { method: "POST", headers: keyed, body: input });
|
||||
const second = call(documents, { method: "POST", headers: keyed, body: input });
|
||||
assert.equal(first.status, 200); assert.equal(second.status, 200);
|
||||
assert.equal((second.body as { replayed: boolean }).replayed, true);
|
||||
assert.equal(documentHistory(input.sourceSlug).length, 1);
|
||||
assert.equal(call(documents, { method: "POST", headers: keyed, body: { ...input, content: "Different" } }).status, 409);
|
||||
assert.equal(call(documents, { method: "POST", headers, body: input }).status, 409);
|
||||
}));
|
||||
|
||||
test("invalid batch members, duplicate identifiers, raw images and malformed fields never write", () => isolated(({ headers }) => {
|
||||
for (const body of [{ documents: [] }, { documents: [input, input] },
|
||||
{ documents: [input], unexpected: true }, { documents: Array.from({ length: 101 }, () => input) }])
|
||||
assert.equal(call(batch, { method: "POST", headers, body }).status, 400);
|
||||
for (const body of [{ ...input, revision: undefined }, { ...input, typo: true },
|
||||
{ ...input, slug: "../private" }, { ...input, content: "" }, null])
|
||||
assert.equal(call(documents, { method: "POST", headers, body }).status, 400);
|
||||
assert.deepEqual(allDocuments(), []);
|
||||
assert.equal(call(documents, { method: "DELETE", headers }).status, 405);
|
||||
assert.equal(call(item, { headers, query: { slug: ["missing"] } }).status, 404);
|
||||
}));
|
||||
|
||||
const chapterContent = "# Example\n\nIntroduction.\n\n## First\n\nFirst body.\n\n### Detail\n\nChild stays in chapter.\n\n## Second\n\nSecond body.\n";
|
||||
type SectionResponse = { revision: number; replayed?: boolean;
|
||||
sections: { id: string; title: string; summary: string; summaryStatus?: string; content?: string }[] };
|
||||
const chapterQuery = { slug: input.slug.split("/") };
|
||||
|
||||
test("chapter parser preserves bytes, fences, duplicate headings and CRLF", () => {
|
||||
const content = "# Title\r\n\r\n## Repeated\r\n~~~text\r\n## Inside fence\r\n~~~\r\n### Nested\r\n```js\r\n## Also inside\r\n````\r\n\r\n## Repeated\r\nTail";
|
||||
const parts = splitSections(content);
|
||||
assert.equal(parts.length, 3);
|
||||
assert.notEqual(parts[1].id, parts[2].id);
|
||||
assert.equal(parts.map((p) => p.content).join(""), content);
|
||||
assert.equal(parts[1].title, "Repeated");
|
||||
assert.ok(parts[1].content.includes("### Nested"));
|
||||
assert.equal(splitSections("").length, 1);
|
||||
assert.equal(splitSections("# Title\n\n- Item\n ## List detail\n\n> ## Quoted\n").length, 1);
|
||||
});
|
||||
|
||||
test("outline reads existing Markdown without changing it and returns only selected bodies", () => isolated(({ headers }) => {
|
||||
saveDocument({ ...input, content: chapterContent }, 0);
|
||||
const db = openSiteDatabase();
|
||||
db.exec("DELETE FROM document_sections; DELETE FROM document_section_revisions;"); db.close();
|
||||
const result = call(sections, { headers, query: chapterQuery });
|
||||
assert.equal(result.status, 200);
|
||||
const outline = result.body as SectionResponse;
|
||||
assert.equal(outline.revision, 1); assert.equal(outline.sections.length, 3);
|
||||
assert.deepEqual(outline.sections.map((s) => s.title), ["导言", "First", "Second"]);
|
||||
assert.ok(outline.sections.every((s) => s.content === undefined && s.summaryStatus === "missing"));
|
||||
const selected = call(sections, { headers, query: { ...chapterQuery, section: outline.sections[1].id } }).body as SectionResponse;
|
||||
assert.equal(selected.sections.length, 1);
|
||||
assert.ok(selected.sections[0].content!.includes("### Detail"));
|
||||
assert.equal(readDocument(input.slug)!.content, chapterContent);
|
||||
assert.equal(call(sections, { headers, query: { ...chapterQuery, section: "missing" } }).status, 404);
|
||||
assert.equal(call(sections, { headers, query: { ...chapterQuery, revision: "0" } }).status, 400);
|
||||
}));
|
||||
|
||||
test("internal summaries persist separately and chapter updates leave other bytes untouched", () => isolated(({ headers }) => {
|
||||
saveDocument({ ...input, content: chapterContent }, 0);
|
||||
const before = readDocumentSections(input.slug)!;
|
||||
const first = before.sections[1]; const second = before.sections[2];
|
||||
const changed = first.content.replace("First body.", "Revised body.");
|
||||
const edited = call(sections, { method: "POST", headers, query: chapterQuery, body: { revision: 1, operations: [
|
||||
{ op: "update", id: first.id, content: changed, summary: "INTERNAL_SUMMARY_FIRST" },
|
||||
{ op: "update", id: second.id, summary: "INTERNAL_SUMMARY_SECOND" },
|
||||
] } });
|
||||
assert.equal(edited.status, 200);
|
||||
const after = readDocumentSections(input.slug)!;
|
||||
assert.equal(after.revision, 2); assert.equal(after.sections[1].id, first.id);
|
||||
assert.equal(after.sections[0].content, before.sections[0].content);
|
||||
assert.equal(after.sections[2].content, before.sections[2].content);
|
||||
assert.equal(after.sections[1].summary, "INTERNAL_SUMMARY_FIRST");
|
||||
assert.equal(readDocument(input.slug)!.content, chapterContent.replace("First body.", "Revised body."));
|
||||
assert.ok(!JSON.stringify(allDocuments()).includes("INTERNAL_SUMMARY"));
|
||||
assert.ok(!JSON.stringify(call(item, { headers, query: chapterQuery }).body).includes("INTERNAL_SUMMARY"));
|
||||
assert.ok(!JSON.stringify(call(documents, { headers }).body).includes("INTERNAL_SUMMARY"));
|
||||
const history = readDocumentSections(input.slug, 1)!;
|
||||
assert.equal(history.sections[1].content, first.content); assert.equal(history.sections[1].summary, "");
|
||||
assert.equal(readDocumentSections(input.slug, 2)!.sections[1].summary, "INTERNAL_SUMMARY_FIRST");
|
||||
}));
|
||||
|
||||
test("insert, move and delete chapters commit together with replay and conflict protection", () => isolated(({ headers }) => {
|
||||
saveDocument({ ...input, content: chapterContent }, 0);
|
||||
const before = readDocumentSections(input.slug)!;
|
||||
const first = before.sections[1]; const second = before.sections[2];
|
||||
const body = { revision: 1, operations: [
|
||||
{ op: "move", id: second.id, after: "preamble" },
|
||||
{ op: "delete", id: first.id },
|
||||
{ op: "insert", after: second.id, content: "## New chapter\n\nNew body.\n", summary: "New material" },
|
||||
] };
|
||||
const options = { method: "POST", headers: { ...headers, "idempotency-key": "chapters-1" }, query: chapterQuery, body };
|
||||
assert.equal(call(sections, options).status, 200);
|
||||
const repeated = call(sections, options);
|
||||
assert.equal(repeated.status, 200); assert.equal((repeated.body as SectionResponse).replayed, true);
|
||||
const after = readDocumentSections(input.slug)!;
|
||||
assert.deepEqual(after.sections.map((s) => s.title), ["导言", "Second", "New chapter"]);
|
||||
assert.equal(after.sections[1].content, second.content);
|
||||
assert.equal(after.revision, 2); assert.equal(documentHistory(input.sourceSlug).length, 2);
|
||||
assert.equal(call(sections, { ...options, body: { ...body, revision: 2 } }).status, 409);
|
||||
assert.equal(call(sections, { ...options, headers }).status, 409);
|
||||
assert.equal(saveDocument({ ...readDocument(input.slug)!, description: "Changed metadata" }, 2).revision, 3);
|
||||
assert.equal(readDocumentSections(input.slug)!.sections[2].summary, "New material");
|
||||
}));
|
||||
|
||||
test("invalid late operation rolls back content, summary and history; read-only keys cannot edit", () => isolated(({ headers }) => {
|
||||
saveDocument({ ...input, content: chapterContent }, 0);
|
||||
const before = readDocumentSections(input.slug)!;
|
||||
const invalid = [
|
||||
[{ op: "update", id: before.sections[1].id, summary: "Should roll back" }, { op: "delete", id: "missing" }],
|
||||
[{ op: "delete", id: "preamble" }],
|
||||
[{ op: "move", id: before.sections[1].id, after: before.sections[1].id }],
|
||||
[{ op: "update", id: before.sections[1].id, content: "## First\n\n## Extra\n" }],
|
||||
[{ op: "update", id: before.sections[1].id, content: "## First\n\n" }],
|
||||
[{ op: "insert", after: "preamble", content: "## Missing summary\n" }],
|
||||
[{ op: "delete", id: before.sections[1].id, unexpected: true }],
|
||||
];
|
||||
for (const operations of invalid) {
|
||||
assert.equal(call(sections, { method: "POST", headers, query: chapterQuery, body: { revision: 1, operations } }).status, 400);
|
||||
assert.deepEqual(readDocumentSections(input.slug), before);
|
||||
assert.equal(documentHistory(input.sourceSlug).length, 1);
|
||||
}
|
||||
assert.equal(call(sections, { query: chapterQuery }).status, 401);
|
||||
const reader = createDocumentKey("chapter reader", "read");
|
||||
const readHeaders = { authorization: "Bearer " + reader.token };
|
||||
assert.equal(call(sections, { headers: readHeaders, query: chapterQuery }).status, 200);
|
||||
assert.equal(call(sections, { method: "POST", headers: readHeaders, query: chapterQuery, body: { revision: 1, operations: [] } }).status, 403);
|
||||
}));
|
||||
|
||||
test("full-document saves invalidate changed chapter summaries and preserve unaffected summaries and ids", () => isolated(({ headers }) => {
|
||||
saveDocument({ ...input, content: chapterContent }, 0);
|
||||
const before = readDocumentSections(input.slug)!;
|
||||
assert.equal(call(sections, { method: "POST", headers, query: chapterQuery, body: { revision: 1, operations:
|
||||
before.sections.map((s) => ({ op: "update", id: s.id, summary: "Summary of " + s.title })) } }).status, 200);
|
||||
saveDocument({ ...readDocument(input.slug)!, content: chapterContent.replace("First body.", "Changed externally.") }, 2);
|
||||
const after = readDocumentSections(input.slug)!;
|
||||
assert.equal(after.sections[1].id, before.sections[1].id);
|
||||
assert.equal(after.sections[1].summary, "");
|
||||
assert.equal(after.sections[2].summary, "Summary of Second");
|
||||
assert.equal(readDocumentSections(input.slug, 2)!.sections[1].summary, "Summary of First");
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
+99049
-6010
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sourceVersion": "2026-10-30-context-1",
|
||||
"asOf": "2026-10-30",
|
||||
"sourceVersion": "2026-11-01-source-closeout-1",
|
||||
"asOf": "2026-11-01",
|
||||
"metric": "platform-mrr-usd",
|
||||
"sampleUnit": "product-or-business",
|
||||
"note": "独立Python复算参考值,仅验证样本计算;不是行业估计或财务审计。",
|
||||
@@ -21,7 +21,9 @@
|
||||
"MR011",
|
||||
"MR012",
|
||||
"MR015",
|
||||
"MR016"
|
||||
"MR016",
|
||||
"MR018",
|
||||
"MR019"
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
@@ -35,7 +37,7 @@
|
||||
"MR016"
|
||||
],
|
||||
"n": 4,
|
||||
"mean": 34815.0,
|
||||
"mean": 34815,
|
||||
"median": 36723.0,
|
||||
"sampleDeviation": 16334.54845820151,
|
||||
"cv": 0.46918134304758036,
|
||||
@@ -50,7 +52,7 @@
|
||||
"MR001"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 53101.0,
|
||||
"mean": 53101,
|
||||
"median": 53101,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
@@ -65,7 +67,7 @@
|
||||
"MR002"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 62423.0,
|
||||
"mean": 62423,
|
||||
"median": 62423,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
@@ -79,15 +81,17 @@
|
||||
"products": [
|
||||
"MR003",
|
||||
"MR004",
|
||||
"MR005"
|
||||
"MR005",
|
||||
"MR018",
|
||||
"MR019"
|
||||
],
|
||||
"n": 3,
|
||||
"mean": 41693.0,
|
||||
"median": 49069,
|
||||
"sampleDeviation": 28111.375064197768,
|
||||
"cv": 0.6742468775141575,
|
||||
"meanWithoutLargest": 29849.0,
|
||||
"largestRemovalDrop": 0.2840764636749574
|
||||
"n": 5,
|
||||
"mean": 51674.8,
|
||||
"median": 65381,
|
||||
"sampleDeviation": 24138.82269705795,
|
||||
"cv": 0.4671294847209461,
|
||||
"meanWithoutLargest": 47627.5,
|
||||
"largestRemovalDrop": 0.07832250923080501
|
||||
},
|
||||
{
|
||||
"category": "C09",
|
||||
@@ -105,7 +109,7 @@
|
||||
"median": 20483,
|
||||
"sampleDeviation": 11942.947278624319,
|
||||
"cv": 0.4711370488466823,
|
||||
"meanWithoutLargest": 20046.0,
|
||||
"meanWithoutLargest": 20046,
|
||||
"largestRemovalDrop": 0.20920581320120557
|
||||
}
|
||||
]
|
||||
@@ -127,9 +131,27 @@
|
||||
"MR012",
|
||||
"MR015",
|
||||
"MR016",
|
||||
"AI016"
|
||||
"AI016",
|
||||
"MR017",
|
||||
"MR018",
|
||||
"MR019"
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"category": "C02",
|
||||
"provider": "Stripe",
|
||||
"model": "模型网关订阅与用量",
|
||||
"products": [
|
||||
"MR017"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 78764,
|
||||
"median": 78764,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
"meanWithoutLargest": null,
|
||||
"largestRemovalDrop": null
|
||||
},
|
||||
{
|
||||
"category": "C06",
|
||||
"provider": "Stripe",
|
||||
@@ -146,7 +168,7 @@
|
||||
"median": 40112,
|
||||
"sampleDeviation": 174786.78744916618,
|
||||
"cv": 1.5505509613536426,
|
||||
"meanWithoutLargest": 34815.0,
|
||||
"meanWithoutLargest": 34815,
|
||||
"largestRemovalDrop": 0.6911526751687284
|
||||
},
|
||||
{
|
||||
@@ -157,7 +179,7 @@
|
||||
"MR001"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 53101.0,
|
||||
"mean": 53101,
|
||||
"median": 53101,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
@@ -172,7 +194,7 @@
|
||||
"MR002"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 62423.0,
|
||||
"mean": 62423,
|
||||
"median": 62423,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
@@ -186,15 +208,17 @@
|
||||
"products": [
|
||||
"MR003",
|
||||
"MR004",
|
||||
"MR005"
|
||||
"MR005",
|
||||
"MR018",
|
||||
"MR019"
|
||||
],
|
||||
"n": 3,
|
||||
"mean": 41693.0,
|
||||
"median": 49069,
|
||||
"sampleDeviation": 28111.375064197768,
|
||||
"cv": 0.6742468775141575,
|
||||
"meanWithoutLargest": 29849.0,
|
||||
"largestRemovalDrop": 0.2840764636749574
|
||||
"n": 5,
|
||||
"mean": 51674.8,
|
||||
"median": 65381,
|
||||
"sampleDeviation": 24138.82269705795,
|
||||
"cv": 0.4671294847209461,
|
||||
"meanWithoutLargest": 47627.5,
|
||||
"largestRemovalDrop": 0.07832250923080501
|
||||
},
|
||||
{
|
||||
"category": "C09",
|
||||
@@ -212,7 +236,7 @@
|
||||
"median": 20483,
|
||||
"sampleDeviation": 11942.947278624319,
|
||||
"cv": 0.4711370488466823,
|
||||
"meanWithoutLargest": 20046.0,
|
||||
"meanWithoutLargest": 20046,
|
||||
"largestRemovalDrop": 0.20920581320120557
|
||||
}
|
||||
]
|
||||
|
||||
Generated
+3
-3
@@ -28,6 +28,9 @@ importers:
|
||||
remark-gfm:
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1(supports-color@7.2.0)
|
||||
sharp:
|
||||
specifier: 0.35.5
|
||||
version: 0.35.5(@types/node@22.20.5)
|
||||
devDependencies:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: 4.3.3
|
||||
@@ -59,9 +62,6 @@ importers:
|
||||
postcss:
|
||||
specifier: 8.5.31
|
||||
version: 8.5.31
|
||||
sharp:
|
||||
specifier: 0.35.5
|
||||
version: 0.35.5(@types/node@22.20.5)
|
||||
tailwindcss:
|
||||
specifier: 4.3.3
|
||||
version: 4.3.3
|
||||
|
||||
+168
-10
@@ -4,7 +4,7 @@ from collections import Counter
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DATA = json.loads((ROOT / 'content/research/ai-companies.seed.json').read_text())
|
||||
@@ -16,6 +16,20 @@ SOURCE_SLUG = 'ai-company-research/02-coverage-and-gaps'
|
||||
BASE = '/docs/other-research/ai-company-research/overview'
|
||||
|
||||
|
||||
def yc_coverage_summary():
|
||||
companies = {row['id']: row for row in DATA['companies']}
|
||||
imported = [companies[f"YC{row['ycId']}"] for row in AUDIT['records'] if row['status'] == 'imported']
|
||||
profile_only = sum(bool(row.get('profileOnly')) for row in imported)
|
||||
groups = {}
|
||||
for label, prefix in [('unclearAI', 'YC-AI-UNCLEAR-'), ('missingYear', 'YC-MISSING-YEAR-'),
|
||||
('dateConflict', 'YC-DATE-CONFLICT-')]:
|
||||
rows = [row for batch in DATA.get('candidateBatches', []) if batch['id'].startswith(prefix)
|
||||
for row in batch['records']]
|
||||
groups[label] = {'records': len(rows), 'statuses': dict(sorted(Counter(row['status'] for row in rows).items()))}
|
||||
return {'initialProfiles': len(AUDIT['records']), 'originalImported': len(imported),
|
||||
'profileOnly': profile_only, 'deepenedOriginal': len(imported) - profile_only, **groups}
|
||||
|
||||
|
||||
|
||||
def source_register():
|
||||
sources = SOURCES['sources']
|
||||
@@ -26,6 +40,63 @@ def source_register():
|
||||
for source in sources:
|
||||
assert source['status'] in {'candidate-audit', 'targeted', 'pending', 'context', 'access-limited', 'per-company'}
|
||||
assert source['boundary'] and source['nextCheck']
|
||||
if source['id'] == 'S01':
|
||||
assert source.get('coverageSummary') == yc_coverage_summary(), 'YC source summary is stale'
|
||||
if source['id'] == 'S14' and source.get('rosterAudit'):
|
||||
roster = source['rosterAudit']
|
||||
batch = next(batch for batch in DATA['candidateBatches'] if batch['id'] == roster['candidateBatchId'])
|
||||
assert batch['sourceId'] == 'S14' and not batch['complete']
|
||||
assert roster['defaultCohort'] == 'SR007' and roster['next'] is None
|
||||
assert len(batch['records']) == roster['recordCount'] == roster['reportedCount']
|
||||
assert len({row['url'] for row in batch['records']}) == roster['recordCount']
|
||||
if source['id'] == 'S14' and source.get('publicRosterAudits'):
|
||||
audits = source['publicRosterAudits']
|
||||
assert {a['cohort'] for a in audits} == {f'SR{i:03d}' for i in range(1, 8)}
|
||||
assert len(audits) == 7
|
||||
profile_urls = []
|
||||
for audit in audits:
|
||||
batch = next(b for b in DATA['candidateBatches'] if b['id'] == audit['candidateBatchId'])
|
||||
query = parse_qs(urlsplit(audit['url']).query)
|
||||
assert urlsplit(audit['url']).netloc == 'speedrun.a16z.com'
|
||||
assert query == {'limit': ['100'], 'offset': ['0'], 'cohort': [audit['cohort']]}
|
||||
assert urlsplit(audit['url']).path == '/api/companies/companyparams'
|
||||
assert audit['httpStatus'] == 200 and audit['accessedAt'] <= DATA['asOf']
|
||||
assert audit['next'] is None and audit['previous'] is None
|
||||
assert len(batch['records']) == audit['recordCount'] == audit['reportedCount']
|
||||
assert audit['uniquePublicIds'] == audit['uniqueSlugs'] == audit['recordCount']
|
||||
assert len(audit['responseSha256']) == 64 and all(c in '0123456789abcdef' for c in audit['responseSha256'])
|
||||
assert batch['sourceId'] == 'S14' and not batch['complete']
|
||||
proof = next(e for e in DATA['evidence'] if e['id'] == audit['evidenceId'])
|
||||
assert proof['url'] == audit['url'] and proof['companyId'] is None
|
||||
profile_urls.extend(row['url'] for row in batch['records'])
|
||||
assert len(profile_urls) == len(set(profile_urls)), 'duplicate profiles across public cohorts'
|
||||
if source['id'] == 'S14' and source.get('profileAudit'):
|
||||
audit = source['profileAudit']
|
||||
entries = audit['records']
|
||||
candidates = {row['id']: row for b in DATA['candidateBatches'] for row in b['records']}
|
||||
evidence_by_id = {row['id']: row for row in DATA['evidence']}
|
||||
assert len(entries) == audit['recordCount'] == audit['readable']
|
||||
assert audit['failed'] == 0 and len({e['candidateId'] for e in entries}) == len(entries)
|
||||
years = Counter(str(e['foundedYear']) for e in entries)
|
||||
assert dict(years) == audit['yearCounts']
|
||||
for entry in entries:
|
||||
row = candidates[entry['candidateId']]
|
||||
proof = evidence_by_id[entry['evidenceId']]
|
||||
assert row['profileStatus'] == 'readable' and proof['url'] == row['url']
|
||||
assert entry['evidenceId'] in row['evidenceIds']
|
||||
assert isinstance(entry['foundedYear'], int) and 1800 <= entry['foundedYear'] <= int(DATA['asOf'][:4])
|
||||
assert isinstance(entry['teamSize'], int) and entry['teamSize'] >= 0
|
||||
assert len(entry['htmlSha256']) == 64 and all(c in '0123456789abcdef' for c in entry['htmlSha256'])
|
||||
if entry['foundedYear'] not in [2025, 2026]:
|
||||
assert row['status'] == 'outside-cohort' and not row.get('companyId')
|
||||
assert sum(e['foundedYear'] not in [2025, 2026] for e in entries) == audit['outsideTargetYears']
|
||||
assert sum(e['foundedYear'] in [2025, 2026] for e in entries) == audit['targetYearSelfReports']
|
||||
assert sum(candidates[e['candidateId']]['status'] == 'linked' for e in entries) == audit['newLinkedCompanies']
|
||||
assert sum(candidates[e['candidateId']]['status'] == 'pending-ai' for e in entries) == audit['pendingCurrentAIOrIdentity']
|
||||
assert sum(candidates[e['candidateId']]['status'] == 'pending-year' for e in entries) == audit['pendingFoundingIdentity']
|
||||
assert audit['targetYearSelfReports'] == (
|
||||
audit['newLinkedCompanies'] + audit['pendingCurrentAIOrIdentity'] + audit['pendingFoundingIdentity']
|
||||
), 'self-reported years must retain unresolved brand/entity dates'
|
||||
if source['url']:
|
||||
assert urlsplit(source['url']).scheme in {'http', 'https'} and urlsplit(source['url']).netloc
|
||||
for host in source['hosts']:
|
||||
@@ -49,7 +120,18 @@ def source_register():
|
||||
company_ids = {e['companyId'] for e in evidence if e['companyId']}
|
||||
observations = sum(o['evidence'] in evidence_ids for o in DATA['observations'])
|
||||
name = f"[{source['name']}]({source['url']})" if source['url'] else source['name']
|
||||
lines.append(f"| {source['id']} {name} | {statuses[source['status']]} | {len(evidence)} / {len(company_ids)} / {observations} | {source['boundary']} | {source['nextCheck']} |")
|
||||
boundary = source['boundary']
|
||||
if source['id'] == 'S01':
|
||||
summary = source['coverageSummary']
|
||||
boundary += (f" 当前原候选池{summary['initialProfiles']}项,原新增{summary['originalImported']}项中"
|
||||
f"{summary['profileOnly']}项目录基础资料、{summary['deepenedOriginal']}项深化个案。")
|
||||
for key, label in [('unclearAI', '原AI不明'), ('missingYear', '原缺年'), ('dateConflict', '原日期冲突')]:
|
||||
group = summary[key]
|
||||
labels = {'linked': '关联公司', 'pending-ai': 'AI主营待核',
|
||||
'pending-year': '成立/主体待核', 'outside-cohort': '范围外'}
|
||||
counts = '、'.join(f'{labels.get(status, status)} {count}' for status, count in group['statuses'].items())
|
||||
boundary += f" {label}{group['records']}项当前:{counts}。"
|
||||
lines.append(f"| {source['id']} {name} | {statuses[source['status']]} | {len(evidence)} / {len(company_ids)} / {observations} | {boundary} | {source['nextCheck']} |")
|
||||
lines += ['', 'Dealroom、Product Hunt、机器之心与36氪入口于2026-10-30重新访问。官网首页可读不证明已取得完整筛选结果;安全检测页面不作为文章内容。候选池之外的来源边界尚需建立可回查条目,完成前不宣布来源清单已全部覆盖。', '']
|
||||
return lines
|
||||
|
||||
@@ -85,9 +167,9 @@ def candidate_register():
|
||||
all_ids = [r['id'] for b in batches for r in b['records']]
|
||||
assert len(set(all_ids)) == len(all_ids)
|
||||
lines = []
|
||||
statuses = {'pending-year': '成立年待核', 'linked': '已关联公司',
|
||||
statuses = {'pending-year': '成立年待核', 'pending-ai': 'AI主营待核', 'linked': '已关联公司',
|
||||
'existing-outside-cohort': '已有关联,年份范围外',
|
||||
'outside-cohort': '成立年范围外,不新增公司', 'promotion': '推广活动,非公司新增'}
|
||||
'outside-cohort': '范围外或既有业务,不新增公司', 'promotion': '推广活动,非公司新增'}
|
||||
for batch in batches:
|
||||
assert batch['sourceId'] in known_sources
|
||||
assert batch['accessedAt'] <= DATA['asOf']
|
||||
@@ -97,13 +179,13 @@ def candidate_register():
|
||||
assert sum(counts.values()) == len(rows)
|
||||
lines += ['', f"## {batch['sourceId']} 候选核对({batch['id']})", '',
|
||||
f"[发现页面]({batch['url']}),读取于{batch['accessedAt']}。{batch['scope']}", '',
|
||||
f"本批{len(rows)}条:{counts['linked']}条关联公司、{counts['existing-outside-cohort']}条关联范围外对照、{counts['outside-cohort']}条范围外不新增公司、{counts['pending-year']}条成立年待核、{counts['promotion']}条推广活动。完整来源覆盖标记:{'已完成' if batch['complete'] else '未完成'}。主营或产品任务的初分类不计入主公司表数量,只有已关联的公司记录进入对应公司明细。候选与缺项随研究版本保存在文档站同一SQLite的研究元数据中。", '',
|
||||
f"本批{len(rows)}条:{counts['linked']}条关联公司、{counts['existing-outside-cohort']}条关联范围外对照、{counts['outside-cohort']}条范围外不新增公司、{counts['pending-year']}条成立年待核、{counts['pending-ai']}条AI主营待核、{counts['promotion']}条推广活动。完整来源覆盖标记:{'已完成' if batch['complete'] else '未完成'}。主营或产品任务的初分类不计入主公司表数量,只有已关联的公司记录进入对应公司明细。候选与缺项随研究版本保存在文档站同一SQLite的研究元数据中。", '',
|
||||
'| 发现条目 | 主营或产品任务初分 | 核对状态 | 关联公司 | 核对证据 | 限制 |', '| --- | --- | --- | --- | --- | --- |']
|
||||
for row in rows:
|
||||
assert row['categoryId'] in categories
|
||||
assert row['status'] in statuses
|
||||
assert all(eid in evidence for eid in row.get('evidenceIds', []))
|
||||
if row['status'] == 'outside-cohort':
|
||||
if row['status'] in {'outside-cohort', 'pending-ai'}:
|
||||
assert row.get('evidenceIds') and not row.get('companyId')
|
||||
name = f"[{row['name']}]({row['url']})" if row['url'] else row['name']
|
||||
company = '—'
|
||||
@@ -121,6 +203,73 @@ def candidate_register():
|
||||
return lines
|
||||
|
||||
|
||||
def uncertain_ai_followups():
|
||||
original = {row['source']: row for row in AUDIT['records'] if row['status'] == 'ai-unclear'}
|
||||
rows = [row for batch in DATA.get('candidateBatches', [])
|
||||
if batch['id'].startswith('YC-AI-UNCLEAR-') for row in batch['records']]
|
||||
if not rows:
|
||||
return []
|
||||
urls = [row['url'] for row in rows]
|
||||
assert len(set(urls)) == len(urls), 'Duplicate AI-relevance follow-up URL'
|
||||
rechecks = {row['ycId']: row for row in DATA.get('directoryRechecks', [])}
|
||||
counts = Counter(row['status'] for row in rows)
|
||||
assert set(counts) <= {'linked', 'pending-ai'}
|
||||
for row in rows:
|
||||
assert row['url'] in original and row.get('evidenceIds')
|
||||
baseline = original[row['url']]
|
||||
if row['status'] == 'linked':
|
||||
assert rechecks[baseline['ycId']]['companyId'] == row['companyId']
|
||||
else:
|
||||
assert not row.get('companyId') and baseline['ycId'] not in rechecks
|
||||
unread = len(original) - len(rows)
|
||||
assert unread >= 0
|
||||
return ['', '## 原AI主营不明名单的后续覆盖', '',
|
||||
f"原名单{len(original)}项,已有后续记录{len(rows)}项:{counts['linked']}项关联公司、{counts['pending-ai']}项仍待核AI主营,{unread}项尚无本批系列的后续记录。计数按原始YC页面去重;EveryApp此前的独立复核与末批记录不重复相加。", '',
|
||||
'读完名单只证明每项有可回查的处理记录,不证明待核项均为AI公司,也不证明法律主体、收入或技术效果已独立验证。原始发现审计保持不变,成立年范围外/缺失、成立冲突及其他来源的缺项仍单列。', '']
|
||||
|
||||
|
||||
def founding_year_followups():
|
||||
original = {row['source']: row for row in AUDIT['records'] if row['status'] == 'date-conflict'}
|
||||
rows = [row for batch in DATA.get('candidateBatches', [])
|
||||
if batch['id'].startswith('YC-DATE-CONFLICT-') for row in batch['records']]
|
||||
urls = [row['url'] for row in rows]
|
||||
assert len(set(urls)) == len(urls), 'Duplicate founding-conflict follow-up URL'
|
||||
assert set(urls) <= set(original), 'Unknown founding-conflict follow-up URL'
|
||||
evidence = {row['id']: row for row in DATA['evidence']}
|
||||
for row in rows:
|
||||
assert row['status'] == 'pending-year' and not row.get('companyId')
|
||||
assert row.get('evidenceIds') and all(eid in evidence for eid in row['evidenceIds'])
|
||||
assert all(evidence[eid]['companyId'] is None for eid in row['evidenceIds'])
|
||||
unread = len(original) - len(rows)
|
||||
return ['', '## 原成立年冲突名单的后续覆盖', '',
|
||||
f"原名单{len(original)}项,已有后续记录{len(rows)}项,均保留成立年待核,{unread}项尚无本系列后续记录。当前业务初分类见候选表,不计入主公司表及收入统计。", '',
|
||||
'2025旧品牌发布可证明当时已有产品材料;当前目录的2026字段、批次、条款日期和运营者名称不能单独证明法律主体成立或新旧主体连续。未据此强改成立年。Layers当前AI核心仍另待核。', '']
|
||||
|
||||
|
||||
def missing_year_followups():
|
||||
original = {row['source'] for row in AUDIT['records']
|
||||
if row['status'] == 'year-outside-or-missing' and row['foundedYear'] is None}
|
||||
rows = [row for batch in DATA.get('candidateBatches', [])
|
||||
if batch['id'].startswith('YC-MISSING-YEAR-') for row in batch['records']]
|
||||
urls = [row['url'] for row in rows]
|
||||
assert len(set(urls)) == len(urls) and set(urls) <= original
|
||||
evidence = {e['id']: e for e in DATA['evidence']}
|
||||
for row in rows:
|
||||
assert row['status'] in {'outside-cohort', 'pending-year', 'pending-ai', 'linked'}
|
||||
assert row.get('evidenceIds')
|
||||
if row['status'] == 'linked':
|
||||
matches = [r for r in DATA.get('directoryRechecks', []) if r['companyId'] == row.get('companyId')]
|
||||
assert len(matches) == 1 and matches[0]['originalStatus'] == 'year-outside-or-missing'
|
||||
assert all(eid in evidence and evidence[eid]['companyId'] == row['companyId'] for eid in row['evidenceIds'])
|
||||
else:
|
||||
assert not row.get('companyId')
|
||||
assert all(eid in evidence and evidence[eid]['companyId'] is None for eid in row['evidenceIds'])
|
||||
counts = Counter(row['status'] for row in rows)
|
||||
return ['', '## 原缺少成立年名单的后续覆盖', '',
|
||||
f"原快照缺年{len(original)}项,外部补核已有{len(rows)}项:{counts['linked']}项关联公司、{counts['outside-cohort']}项既有主体/业务范围外、{counts['pending-year']}项成立年待核、{counts['pending-ai']}项AI主营待核,{len(original)-len(rows)}项尚无本系列外部补核记录。", '',
|
||||
'当前YC缺年字段复读与外部主体核查分别统计。登记创建日、公司自述年、产品发布、条款更新和批次不相互替代;已查英国或法国主体不代表完整集团沿革已核。原54项发现计数不回写,也不以Inactive补零收入。', '']
|
||||
|
||||
|
||||
def directory_rechecks():
|
||||
rows = DATA.get('directoryRechecks', [])
|
||||
if not rows:
|
||||
@@ -167,6 +316,11 @@ def render():
|
||||
'year-outside-or-missing': cov['otherOrMissingYear'],
|
||||
'ai-unclear': cov['unclearAI'], 'date-conflict': cov['dateConflicts']}
|
||||
assert len({x['ycId'] for x in AUDIT['records']}) == len(AUDIT['records'])
|
||||
excluded_years = Counter(x['foundedYear'] for x in AUDIT['records'] if x['status'] == 'year-outside-or-missing')
|
||||
outside_years = {year: count for year, count in excluded_years.items() if year is not None}
|
||||
assert all(year not in (2025, 2026) for year in outside_years)
|
||||
assert sum(outside_years.values()) + excluded_years[None] == cov['otherOrMissingYear']
|
||||
year_detail = '、'.join(f'{year}年{count}项' for year, count in sorted(outside_years.items()))
|
||||
lines = ['# AI 公司资料覆盖与缺项', '',
|
||||
f"截至 {DATA['asOf']},研究表含{len(companies):,}条公司或产品记录。YC候选池有逐条读取审计;其他来源为定向补充。清单尚不能作为全球AI公司普查,也不能将收入未知解释为零。", '',
|
||||
'验收范围已于2026-10-30确认为:以确认的公开来源清单为边界,逐条核对并标明缺项。下方区分已逐条审计的候选池与定向补充来源;已使用某来源不代表该来源已被完整覆盖。', '',
|
||||
@@ -178,7 +332,8 @@ def render():
|
||||
f"| 初筛匹配 | {cov['matchingProfiles']:,} | 成立年及AI相关描述通过初筛,尚非逐家商业验证 |",
|
||||
f"| 与原表重合 | {cov['existingProfiles']} | URL匹配,未再次新增 |",
|
||||
f"| 新增目录记录 | {cov['newProfiles']:,} | 原始入表来源,后续深化不改变发现计数 |",
|
||||
f"| 年份不符或缺失 | {cov['otherOrMissingYear']} | 此轮不作为成立年匹配项 |",
|
||||
f"| 原目录列为范围外年份 | {sum(outside_years.values())} | {year_detail};原快照自述,不是独立登记核验 |",
|
||||
f"| 原目录未给成立年 | {excluded_years[None]} | 与已列范围外年份分开,仍待补证 |",
|
||||
f"| AI业务待确认 | {cov['unclearAI']} | 不强行把背景关键词当主营 |",
|
||||
f"| 成立年与批次冲突 | {cov['dateConflicts']} | 另保留核查记录 |", '',
|
||||
f"全表由{depth}条目录基础项和{len(companies)-depth}条个案或平台记录组成。候选池已匹配项之外另有{len(companies)-cov['matchingProfiles']}条记录,包含其他来源及发布/转型对照;其中已有{promoted}条原目录记录深化为个案,仍保留目录来源。两种统计边界不同。候选池数字只是该镜像快照的覆盖,Bud等后续定向补充不回填为原候选池已覆盖。", '',
|
||||
@@ -191,8 +346,8 @@ def render():
|
||||
f"| 其他目录基础项 | {depth-reviewed} | 规则初分或资料不足,继续待复核 |",
|
||||
f"| 有团队数字 | {sum(c['team'] is not None for c in companies)} | 多为自报Team Size或Employees,时点与口径不统一 |",
|
||||
f"| 专项技术证据记录 | {len(companies)-technical['unreviewed']} | 其中{technical['description']}条自述、{technical['public-artifact']}条公开材料、{technical['independent-test']}条第三方评测 |",
|
||||
f"| 公司披露收入线索 | {income['self']} | 含历史、下界、币种不明及产品业务声明,不等于可比ARR |",
|
||||
f"| 仅平台收入线索 | {income['platform']} | 同时有公司披露者计入上一行,不重复计数 |",
|
||||
f"| 企业披露收入线索 | {income['self']} | 含公司或交易当事方披露、历史、下界及币种不明,不等于可比ARR |",
|
||||
f"| 仅平台收入线索 | {income['platform']} | 同时有企业披露者计入上一行,不重复计数 |",
|
||||
f"| 仅第三方估算 | {income['estimate']} | 不与直接披露混合 |",
|
||||
f"| 暂无可用收入数字 | {income['unknown']} | 包含尚未逐家搜索的目录项,不代表没有收入 |", '',
|
||||
'## 业务分类与数据入口', '',
|
||||
@@ -217,13 +372,16 @@ def render():
|
||||
lines.append(f"| {name} | {len(entries)} | {len({e['url'] for e in entries})} | {note} |")
|
||||
others = [e for e in DATA['evidence'] if urlsplit(e['url']).netloc not in included]
|
||||
lines.append(f"| 其他官网、评测及公开资料 | {len(others)} | {len({e['url'] for e in others})} | 按每条来源限制理解,不能统一当作独立证据 |")
|
||||
lines += uncertain_ai_followups()
|
||||
lines += founding_year_followups()
|
||||
lines += missing_year_followups()
|
||||
lines += directory_rechecks()
|
||||
lines += source_register()
|
||||
lines += context_methods()
|
||||
lines += candidate_register()
|
||||
lines += ['', '## 尚未满足的研究条件', '',
|
||||
'行业ARR比较仍缺统一业务截止日、币种、经常性定义和业务边界。现有平台MRR可描述已采集产品,不足以代表全部新公司。技术评测只覆盖所列版本与任务,不宜跨领域汇总为统一强弱分数。', '',
|
||||
'下一轮优先补收入线索的日期及合同定义、非YC和非英文来源、规则初分的人工复核,以及停止服务或被收购案例。这样能减少“只见赢家”和“只见融资公司”的偏差。公开来源池之外的未披露公司仍无法证明已被穷尽。', '',
|
||||
'下一轮优先补收入线索的日期及合同定义、成立年冲突、AI主营仍待核项、非YC和非英文来源,以及停止服务或被收购案例。这样能减少“只见赢家”和“只见融资公司”的偏差。公开来源池之外的未披露公司仍无法证明已被穷尽。', '',
|
||||
]
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
set -eu
|
||||
cd /opt/endorphin-tabularium-docs/current
|
||||
case "${1:-}" in
|
||||
list|get|save|history|export|backup|import)
|
||||
list|get|save|history|export|backup|import|key-create|key-list|key-revoke)
|
||||
docker compose -p endorphin-tabularium-docs --env-file /etc/endorphin-tabularium-docs/production.env --env-file release.env -f compose.yml exec -T docs node apps/docs/docs-cli.cjs "$@" ;;
|
||||
*) echo 'Usage: docs list | get <slug> | save < JSON | history <sourceSlug> | export <private-file> | backup <private-file> | import < JSON' >&2; exit 1 ;;
|
||||
*) echo 'Usage: docs key-create <name> <private-file> [read|read-write] [days] | key-list | key-revoke <id> | list | get <slug> | save < JSON | history <sourceSlug> | export <private-file> | backup <private-file> | import < JSON' >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Upload documents through the database API; credentials stay in a private file."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
|
||||
class DocumentClientError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
# Never forward a bearer credential to a redirected host or protocol.
|
||||
return None
|
||||
|
||||
|
||||
def server_url(value):
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if (parsed.username or parsed.password or parsed.query or parsed.fragment
|
||||
or parsed.path not in ('', '/') or not parsed.hostname):
|
||||
raise ValueError('Provide a server origin without credentials, path, query or fragment')
|
||||
if parsed.scheme != 'https' and not (
|
||||
parsed.scheme == 'http' and parsed.hostname in ('localhost', '127.0.0.1', '::1')):
|
||||
raise ValueError('Use HTTPS or an HTTP loopback connection through a trusted SSH tunnel')
|
||||
return value.rstrip('/')
|
||||
|
||||
|
||||
def read_token(filename):
|
||||
path = Path(filename)
|
||||
if stat.S_IMODE(path.stat().st_mode) & 0o077:
|
||||
raise ValueError('API key file must be private (chmod 600)')
|
||||
value = path.read_text().strip()
|
||||
if value.startswith('{'):
|
||||
value = json.loads(value)['token']
|
||||
if not isinstance(value, str) or not value.startswith('tdoc_') or len(value) != 48:
|
||||
raise ValueError('Invalid document API key file')
|
||||
return value
|
||||
|
||||
|
||||
def request(base, token, method, path, payload=None, request_id=None, *, image_type=None):
|
||||
data = payload if image_type else (None if payload is None else json.dumps(payload, ensure_ascii=False).encode())
|
||||
if data is not None and len(data) > 8 * 1024 * 1024:
|
||||
raise ValueError('Request exceeds 8 MiB; split into smaller atomic batches')
|
||||
headers = {'Authorization': 'Bearer ' + token, 'Accept': 'application/json'}
|
||||
if data is not None:
|
||||
headers['Content-Type'] = image_type or 'application/json'
|
||||
if request_id is not None:
|
||||
headers['Idempotency-Key'] = request_id
|
||||
opener = urllib.request.build_opener(NoRedirect())
|
||||
for attempt in range(2):
|
||||
req = urllib.request.Request(base + path, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with opener.open(req, timeout=30) as response:
|
||||
return json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code in (502, 503, 504) and attempt == 0:
|
||||
error.close()
|
||||
time.sleep(1)
|
||||
continue
|
||||
status = error.code
|
||||
error.close()
|
||||
explanations = {400: 'Invalid document or pagination', 401: 'Key missing, expired or revoked',
|
||||
403: 'Read-only key', 404: 'Document or revision not found',
|
||||
409: 'Revision or request-id conflict; read current state before retrying',
|
||||
413: 'Request too large', 503: 'Database unavailable'}
|
||||
raise DocumentClientError(f'HTTP {status}: ' + explanations.get(status, 'Request failed')) from None
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
if attempt == 0:
|
||||
time.sleep(1)
|
||||
continue
|
||||
raise DocumentClientError('Connection failed; retry using the same request id and unchanged input') from None
|
||||
|
||||
|
||||
def document_batches(documents, max_bytes=8 * 1024 * 1024):
|
||||
if not isinstance(documents, list) or not documents:
|
||||
raise DocumentClientError('Provide a nonempty documents array')
|
||||
batches, chunk, size, seen = [], [], 20, set()
|
||||
for document in documents:
|
||||
if not isinstance(document, dict) or not isinstance(document.get('sourceSlug'), str):
|
||||
raise DocumentClientError('Every document needs a sourceSlug')
|
||||
if document['sourceSlug'] in seen:
|
||||
raise DocumentClientError('Duplicate sourceSlug in upload')
|
||||
seen.add(document['sourceSlug'])
|
||||
encoded = len(json.dumps(document, ensure_ascii=False).encode()) + 2
|
||||
if encoded + 20 > max_bytes:
|
||||
raise DocumentClientError('A document exceeds the request limit')
|
||||
if chunk and (len(chunk) == 100 or size + encoded > max_bytes):
|
||||
batches.append(chunk)
|
||||
chunk, size = [], 20
|
||||
chunk.append(document)
|
||||
size += encoded
|
||||
batches.append(chunk)
|
||||
return batches
|
||||
|
||||
|
||||
def main():
|
||||
os.umask(0o077)
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--url', default=os.environ.get('TABULARIUM_DOCS_URL'))
|
||||
parser.add_argument('--key-file', default=os.environ.get('TABULARIUM_DOCS_KEY_FILE'))
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
listing = sub.add_parser('list')
|
||||
listing.add_argument('--limit', type=int, default=50)
|
||||
listing.add_argument('--offset', type=int, default=0)
|
||||
reading = sub.add_parser('get')
|
||||
reading.add_argument('slug')
|
||||
mode = reading.add_mutually_exclusive_group()
|
||||
mode.add_argument('--history', action='store_true')
|
||||
mode.add_argument('--revision', type=int)
|
||||
reading.add_argument('--limit', type=int, default=50)
|
||||
reading.add_argument('--offset', type=int, default=0)
|
||||
sections = sub.add_parser('outline', help='Read chapter titles and internal summaries without bodies')
|
||||
sections.add_argument('slug')
|
||||
sections.add_argument('--revision', type=int)
|
||||
section = sub.add_parser('sections', help='Read selected chapter bodies')
|
||||
section.add_argument('slug')
|
||||
section.add_argument('ids', nargs='+')
|
||||
section.add_argument('--revision', type=int)
|
||||
editing = sub.add_parser('edit-sections', help='Atomically apply chapter operations from private JSON')
|
||||
editing.add_argument('slug')
|
||||
editing.add_argument('file')
|
||||
editing.add_argument('--request-id')
|
||||
upload = sub.add_parser('upload-image', help='Upload a static image and print its Markdown URL')
|
||||
upload.add_argument('file')
|
||||
saving = sub.add_parser('save')
|
||||
saving.add_argument('file', help='Private JSON file or - for stdin; one document or {documents: [...]}')
|
||||
saving.add_argument('--request-id', help='Reuse this id only with the same input when retrying')
|
||||
args = parser.parse_args()
|
||||
if not args.url or not args.key_file:
|
||||
parser.error('Set --url and --key-file or TABULARIUM_DOCS_URL and TABULARIUM_DOCS_KEY_FILE')
|
||||
try:
|
||||
base = server_url(args.url)
|
||||
token = read_token(args.key_file)
|
||||
if args.command == 'upload-image':
|
||||
image = Path(args.file)
|
||||
image_types = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp'}
|
||||
image_type = image_types.get(image.suffix.lower())
|
||||
if not image_type:
|
||||
raise ValueError('Use a PNG, JPEG or WebP image')
|
||||
with image.open('rb') as source:
|
||||
data = source.read(8 * 1024 * 1024 + 1)
|
||||
if len(data) > 8 * 1024 * 1024:
|
||||
raise ValueError('Image exceeds 8 MiB')
|
||||
result = request(base, token, 'POST', '/api/documents/images', data, image_type=image_type)
|
||||
elif args.command == 'list':
|
||||
result = request(base, token, 'GET', '/api/documents?' + urllib.parse.urlencode(
|
||||
{'limit': args.limit, 'offset': args.offset}))
|
||||
elif args.command == 'get':
|
||||
path = '/api/documents/item/' + urllib.parse.quote(args.slug, safe='/')
|
||||
if args.history:
|
||||
path += '?' + urllib.parse.urlencode({'history': 1, 'limit': args.limit, 'offset': args.offset})
|
||||
elif args.revision is not None:
|
||||
path += '?' + urllib.parse.urlencode({'revision': args.revision})
|
||||
result = request(base, token, 'GET', path)
|
||||
elif args.command in ('outline', 'sections', 'edit-sections'):
|
||||
path = '/api/documents/sections/' + urllib.parse.quote(args.slug, safe='/')
|
||||
if args.command == 'edit-sections':
|
||||
payload = json.load(sys.stdin) if args.file == '-' else json.loads(Path(args.file).read_text())
|
||||
request_id = args.request_id or str(uuid.uuid4())
|
||||
if not request_id or len(request_id) > 100 or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:-' for c in request_id):
|
||||
raise ValueError('Invalid request id')
|
||||
print('Request id: ' + request_id, file=sys.stderr)
|
||||
result = request(base, token, 'POST', path, payload, request_id)
|
||||
else:
|
||||
query = []
|
||||
if args.revision is not None:
|
||||
query.append(('revision', args.revision))
|
||||
if args.command == 'sections':
|
||||
query.extend(('section', section_id) for section_id in args.ids)
|
||||
if query:
|
||||
path += '?' + urllib.parse.urlencode(query)
|
||||
result = request(base, token, 'GET', path)
|
||||
else:
|
||||
payload = json.load(sys.stdin) if args.file == '-' else json.loads(Path(args.file).read_text())
|
||||
request_id = args.request_id or str(uuid.uuid4())
|
||||
if not request_id or len(request_id) > 100 or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:-' for c in request_id):
|
||||
raise ValueError('Invalid request id')
|
||||
print('Request id: ' + request_id, file=sys.stderr)
|
||||
if isinstance(payload, dict) and 'documents' in payload:
|
||||
if set(payload) != {'documents'}:
|
||||
raise DocumentClientError('Batch payload must contain only documents')
|
||||
chunks = document_batches(payload['documents'])
|
||||
receipts, replayed = [], 0
|
||||
for index, chunk in enumerate(chunks, 1):
|
||||
batch_id = f'{request_id}:{index}'
|
||||
saved = request(base, token, 'POST', '/api/documents/batch', {'documents': chunk}, batch_id)
|
||||
receipts.extend(saved['documents'])
|
||||
replayed += int(saved['replayed'])
|
||||
print(f'Batch {index}/{len(chunks)} committed; {len(receipts)} documents total; request id: {batch_id}', file=sys.stderr)
|
||||
result = {'documents': receipts, 'batches': len(chunks), 'replayedBatches': replayed, 'requestId': request_id}
|
||||
else:
|
||||
result = request(base, token, 'POST', '/api/documents', payload, request_id)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
except DocumentClientError as error:
|
||||
raise SystemExit(str(error)) from None
|
||||
except (ValueError, OSError, KeyError, TypeError):
|
||||
# Never print exception objects that could contain credentials or document data.
|
||||
raise SystemExit('Document request failed. Check the URL, private key file, JSON schema and server response; credentials omitted.') from None
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -89,23 +89,42 @@ def link(cid):
|
||||
|
||||
def render():
|
||||
points, excluded = sample(7)
|
||||
grouped = groups(points)
|
||||
described = [(key, stats(values)) for key, values in sorted(grouped.items())]
|
||||
sizes = [value['n'] for _, value in described]
|
||||
eligible = [(key, value) for key, value in described if value['n'] >= 3]
|
||||
interpretation = '各组均不足3个样本,暂不比较分组离散度或收入高低。'
|
||||
if eligible:
|
||||
highest_key, highest = max(eligible, key=lambda item: item[1]['mean'])
|
||||
sensitive_key, sensitive = max(eligible, key=lambda item: item[1]['largestRemovalDrop'] or 0)
|
||||
cvs = [value['cv'] for _, value in eligible if value['cv'] is not None]
|
||||
cv_text = f"CV范围为{min(cvs):.3f}—{max(cvs):.3f}。" if cvs else '各组CV均不可计算。'
|
||||
interpretation = (
|
||||
f"在至少3个样本的组内,当前均值最高的是{CATEGORIES[highest_key[0]]} · {highest_key[2]}"
|
||||
f"({highest_key[1]},{money(highest['mean'])}美元每月)。{cv_text}"
|
||||
f"剔除最大值后均值下降最多的是{CATEGORIES[sensitive_key[0]]} · {sensitive_key[2]}"
|
||||
f"({sensitive_key[1]},下降{sensitive['largestRemovalDrop'] or 0:.1%})。"
|
||||
'这些只是当前采集点的描述,不据微小CV差异宣称某领域更稳定。'
|
||||
)
|
||||
size_text = f"各组目前只有{min(sizes)}至{max(sizes)}个产品" if sizes else '当前没有可比较的产品'
|
||||
lines = [
|
||||
'# AI 公司收入样本分布快照', '',
|
||||
f"截至 {DATA['asOf']},7日窗口内有 {len(points)} 个产品的美元平台MRR可纳入分组描述。创意软件订阅组的样本均值较高、CV也较大;营销软件组与消费者个人应用组的CV接近。各组只有1至5个产品,尚不足以估计行业总体或造假概率。", '',
|
||||
f"截至 {DATA['asOf']},7日窗口内有 {len(points)} 个产品的美元平台MRR可纳入分组描述。按业务、支付平台和收费模式分别计算均值、中位数与离散度。{size_text},尚不足以估计行业总体或造假概率。", '',
|
||||
f"这是已采集资料的固定快照。[动态工作台]({BASE})提供筛选与原始证据。窗口按最后同步日计算,不保证所有点同日,也不是{len(DATA['companies']):,}条公司/产品记录的收入普查。", '',
|
||||
'## 7日窗口的分组结果', '',
|
||||
'MRR单位为美元每月。CV为样本标准差除以均值;最大值剔除降幅表示移除最高收入产品后均值下降的比例。单个样本不计算离散度。', '',
|
||||
'| 业务及收费模式 | 平台 | 样本数 | 均值 | 中位数 | CV | 最大值剔除降幅 |',
|
||||
'| --- | --- | --- | --- | --- | --- | --- |',
|
||||
]
|
||||
for key, values in sorted(groups(points).items()):
|
||||
for key, values in sorted(grouped.items()):
|
||||
cat, provider, model = key
|
||||
v = stats(values)
|
||||
cv = f"{v['cv']:.3f}" if v['cv'] is not None else '不计算'
|
||||
drop = f"{v['largestRemovalDrop']:.1%}" if v['largestRemovalDrop'] is not None else '不计算'
|
||||
lines.append(f"| {CATEGORIES[cat]} · {model} | {provider} | {v['n']} | {money(v['mean'])} | {money(v['median'])} | {cv} | {drop} |")
|
||||
lines += ['',
|
||||
'在至少3个样本的组内,内容创意软件订阅的均值较高,但移除最大样本后的下降也最大。营销软件与个人应用的CV仅有微小差别,不据此宣称哪个领域更稳定。两个内容类单样本组虽有更高数值,不能作为典型收入或领域排名。', '',
|
||||
interpretation, '',
|
||||
'单个样本不计算离散度,也不能作为典型收入或领域排名;不同收费模式和支付平台的分组保留各自含义。', '',
|
||||
'均值和中位数接近只描述当前采集点,并不证明样本具有代表性。平台公开者、成立年、规模、获客渠道与收费方式均可能影响结果。', '',
|
||||
'## 样本与证据入口', '',
|
||||
'点击产品名称可打开公司明细,再查看平台记录及其限制。连接已过期的近期快照仍按所标日期保留,并不表示连接当前有效。', '',
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Lightweight client tests: no HTTP server, compilation or real credentials."""
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
spec = importlib.util.spec_from_file_location('documents_client', Path(__file__).with_name('documents-client.py'))
|
||||
client = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(client)
|
||||
|
||||
|
||||
class ClientTests(unittest.TestCase):
|
||||
def test_section_reads_select_bodies_and_revision_without_full_document_fetch(self):
|
||||
argv = ['client', '--url', 'https://docs.example', '--key-file', '/private/key',
|
||||
'sections', 'endor/example', 'preamble', 's_one', '--revision', '3']
|
||||
with patch.object(sys, 'argv', argv), patch.object(client, 'read_token', return_value='test-token'), \
|
||||
patch.object(client, 'request', return_value={'revision': 3, 'sections': []}) as request, \
|
||||
patch.object(sys, 'stdout', io.StringIO()):
|
||||
client.main()
|
||||
request.assert_called_once_with('https://docs.example', 'test-token', 'GET',
|
||||
'/api/documents/sections/endor/example?revision=3§ion=preamble§ion=s_one')
|
||||
|
||||
def test_section_edit_keeps_payload_and_retry_id(self):
|
||||
payload = {'revision': 3, 'operations': [{'op': 'update', 'id': 's_one', 'summary': 'Internal summary'}]}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'edit.json'
|
||||
path.write_text(json.dumps(payload))
|
||||
argv = ['client', '--url', 'https://docs.example', '--key-file', '/private/key',
|
||||
'edit-sections', 'endor/example', str(path), '--request-id', 'edit-1']
|
||||
with patch.object(sys, 'argv', argv), patch.object(client, 'read_token', return_value='test-token'), \
|
||||
patch.object(client, 'request', return_value={'revision': 4}) as request, \
|
||||
patch.object(sys, 'stdout', io.StringIO()), patch.object(sys, 'stderr', io.StringIO()):
|
||||
client.main()
|
||||
request.assert_called_once_with('https://docs.example', 'test-token', 'POST',
|
||||
'/api/documents/sections/endor/example', payload, 'edit-1')
|
||||
|
||||
def test_only_tls_or_loopback_origins_are_accepted(self):
|
||||
self.assertEqual(client.server_url('https://docs.example/'), 'https://docs.example')
|
||||
self.assertEqual(client.server_url('http://127.0.0.1:18135'), 'http://127.0.0.1:18135')
|
||||
for url in ['http://docs.example', 'https://user:secret@docs.example', 'file:///tmp/data',
|
||||
'https://docs.example/api', 'https://docs.example/?token=secret']:
|
||||
with self.assertRaises(ValueError):
|
||||
client.server_url(url)
|
||||
|
||||
def test_key_file_permissions_and_formats(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'key.json'
|
||||
token = 'tdoc_' + 'x' * 43
|
||||
path.write_text(json.dumps({'token': token}))
|
||||
path.chmod(0o600)
|
||||
self.assertEqual(client.read_token(path), token)
|
||||
path.chmod(0o644)
|
||||
with self.assertRaises(ValueError):
|
||||
client.read_token(path)
|
||||
|
||||
def test_redirects_never_forward_bearer_credentials(self):
|
||||
request = urllib.request.Request('https://docs.example', headers={'Authorization': 'Bearer test-only'})
|
||||
self.assertIsNone(client.NoRedirect().redirect_request(request, None, 302, '', {}, 'https://other.example'))
|
||||
|
||||
def test_network_retry_preserves_idempotency_and_body(self):
|
||||
calls = []
|
||||
|
||||
class Opener:
|
||||
def open(self, request, timeout):
|
||||
calls.append(request)
|
||||
if len(calls) == 1:
|
||||
raise urllib.error.URLError('temporary network failure')
|
||||
return io.BytesIO(b'{"replayed":true}')
|
||||
|
||||
with patch.object(client.urllib.request, 'build_opener', return_value=Opener()), patch.object(client.time, 'sleep'):
|
||||
result = client.request('https://docs.example', 'test-token', 'POST', '/api/documents', {'revision': 0}, 'request-1')
|
||||
self.assertTrue(result['replayed'])
|
||||
self.assertEqual(calls[0].data, calls[1].data)
|
||||
self.assertEqual(calls[0].get_header('Idempotency-key'), 'request-1')
|
||||
self.assertEqual(calls[1].get_header('Idempotency-key'), 'request-1')
|
||||
|
||||
def test_large_uploads_are_split_by_count_and_bytes(self):
|
||||
documents = [{'sourceSlug': str(i), 'content': 'a' * 20} for i in range(201)]
|
||||
batches = client.document_batches(documents)
|
||||
self.assertEqual([len(batch) for batch in batches], [100, 100, 1])
|
||||
self.assertEqual([doc for batch in batches for doc in batch], documents)
|
||||
bounded = client.document_batches(documents[:5], max_bytes=150)
|
||||
self.assertTrue(all(len(json.dumps({'documents': batch}, ensure_ascii=False).encode()) <= 150 for batch in bounded))
|
||||
|
||||
def test_duplicate_identifiers_and_oversized_items_fail_before_upload(self):
|
||||
with self.assertRaises(ValueError):
|
||||
client.document_batches([{'sourceSlug': 'same'}, {'sourceSlug': 'same'}])
|
||||
with self.assertRaises(ValueError):
|
||||
client.document_batches([{'sourceSlug': 'one', 'content': 'a' * 500}], max_bytes=100)
|
||||
|
||||
def test_conflict_is_reported_without_printing_server_content_or_secret(self):
|
||||
class Opener:
|
||||
def open(self, request, timeout):
|
||||
raise urllib.error.HTTPError(request.full_url, 409, 'Conflict', {}, io.BytesIO(b'{"error":"private-content"}'))
|
||||
|
||||
with patch.object(client.urllib.request, 'build_opener', return_value=Opener()):
|
||||
with self.assertRaisesRegex(client.DocumentClientError, 'HTTP 409') as result:
|
||||
client.request('https://docs.example', 'private-token', 'POST', '/api/documents', {}, 'request-1')
|
||||
self.assertNotIn('private-token', str(result.exception))
|
||||
self.assertNotIn('private-content', str(result.exception))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user