Compare commits

..

228 Commits

Author SHA1 Message Date
codex-bot a4c9349fce Keep retired document pages readable outside the catalog 2026-11-05 22:27:12 +08:00
Zayden-Jung 4c4471c2bd Merge pull request '优先展示 Endorphin,撤下重复的 Endor 导航' (#52) from codex/endorphin-catalog into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 10m31s
Reviewed-on: #52
2026-11-05 14:15:15 +00:00
codex-bot 53aef1eb64 Prioritize Endorphin and retire Endor from the catalog 2026-11-05 22:13:19 +08:00
Zayden-Jung 18af3802c6 Merge pull request 'Verify canonical document source URLs without redirects' (#50) from codex/verify-canonical-doc-routes into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 13m16s
Reviewed-on: #50
2026-11-04 13:54:18 +00:00
codex-bot 4e9ff80828 Verify canonical document source URLs without redirects 2026-11-04 21:46:13 +08:00
Zayden-Jung 0e1f99639f Merge pull request 'Upload document images through the API' (#49) from codex/document-image-api into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 19m23s
Reviewed-on: #49
2026-11-04 13:23:36 +00:00
codex-bot f8a5551215 Store document image uploads in SQLite 2026-11-04 17:04:20 +08:00
Zayden-Jung 45edbeae1f Merge pull request '收尾 AI 公司公开来源核对,补入遗漏批次并明确收入与身份缺项' (#47) from codex/ai-research-closeout into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 7m53s
Reviewed-on: #47
2026-10-31 19:46:21 +00:00
codex-bot a9af14ac63 Close finite source review with explicit unresolved identity and income gaps 2026-11-01 03:42:47 +08:00
codex-bot c1f4366379 Classify consumer learning and health products with preorder and revenue-scope exclusions 2026-11-01 03:32:56 +08:00
Zayden-Jung c0d6b4a8e1 Merge pull request '分类九家 AI 公司,区分金融工作流、机器人、评估与模型训练的交付和计费' (#46) from codex/ai-research-finance into main
Verify and deploy Tabularium / verify-and-deploy (push) Waiting to run
Reviewed-on: #46
2026-10-31 19:30:40 +00:00
codex-bot db70ef32da Classify robotics and evaluation infrastructure with historical-test and future-pricing limits 2026-11-01 03:24:07 +08:00
codex-bot 55b8fea7a8 Classify financial workflow AI and preserve payment-volume and current-product gaps 2026-11-01 03:18:23 +08:00
Zayden-Jung 701ef632ff Merge pull request '核对 Speedrun 七批与226档案,分类四十家公司并分离签约及非软件收入' (#45) from codex/ai-research-sr-rosters into main
Verify and deploy Tabularium / verify-and-deploy (push) Has been cancelled
Reviewed-on: #45
2026-10-31 19:13:52 +00:00
codex-bot b6e44625b6 Classify enterprise memory and industry workflows while excluding contracted revenue from ARR 2026-11-01 03:12:52 +08:00
codex-bot c22739d4f8 Classify legal and eldercare AI delivery with separate cost and revenue boundaries 2026-11-01 03:06:02 +08:00
codex-bot 46bff91af9 Classify three SR006 businesses and preserve rebrand and revenue ambiguities 2026-11-01 02:55:28 +08:00
codex-bot fe6b3b5951 Classify six SR006 AI businesses and separate research scores from product validation 2026-11-01 02:47:53 +08:00
codex-bot e0806362f0 Classify five SR005 businesses and distinguish physical supply from SaaS 2026-11-01 02:41:25 +08:00
codex-bot bef80fba4d Classify six SR005 AI businesses and inspect Dex official accounts 2026-11-01 02:35:01 +08:00
codex-bot 9c32f77722 Classify two SR004 businesses and preserve unresolved brand founding dates 2026-11-01 02:28:52 +08:00
codex-bot d631be2617 Audit 226 Speedrun profiles and classify six current AI businesses 2026-11-01 02:16:52 +08:00
codex-bot 51d6364ddd Register all seven public Speedrun cohorts with verifiable boundaries 2026-11-01 01:55:27 +08:00
Zayden-Jung 17e9306c97 Merge pull request '完成 SR007 名单逐项档案读取,补充二十一家公司并区分历史收入归属' (#44) from codex/ai-research-sr007-more into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 7m36s
Reviewed-on: #44
2026-10-31 17:48:52 +00:00
codex-bot 6e3a505d24 Complete SR007 profile reconciliation and qualify historical revenue categories 2026-11-01 01:44:35 +08:00
codex-bot cd3e0c695c Reconcile remaining SR007 profiles and separate contracts from revenue 2026-11-01 01:36:14 +08:00
codex-bot 5514875213 Separate SR007 company years, managed services and legacy revenue claims 2026-11-01 01:23:00 +08:00
codex-bot ec50b3302e Reconcile SR007 contracts, free SDK terms and provider reach 2026-11-01 01:16:48 +08:00
Zayden-Jung 68780f1f2b Merge pull request '补核 SR007 三十六项候选及三十家公司,分类收入并增加两项商业模式初评' (#43) from codex/ai-research-sr007-followup into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 7m58s
Reviewed-on: #43
2026-10-31 17:11:28 +00:00
codex-bot 34f7cae0fe Distinguish live ARR, contracted demand and professional service run rates 2026-11-01 01:09:45 +08:00
codex-bot 76aa80fedc Classify SR007 deployments and assess local free to cloud subscription economics 2026-11-01 01:03:48 +08:00
codex-bot 7901ab940e Reconcile SR007 AI scope and separate travel revenue from ARR claims 2026-11-01 00:56:54 +08:00
codex-bot d52dac64e5 Classify six SR007 companies and exclude projected revenue and client assets 2026-11-01 00:50:40 +08:00
codex-bot b3fcb9ad3a Separate contracted pipeline, pilot demand and cloud usage in SR007 research 2026-11-01 00:40:39 +08:00
codex-bot 91d6c22e6b Classify six SR007 follow-ups and preserve revenue attribution limits 2026-11-01 00:31:49 +08:00
Zayden-Jung e04ab96187 Merge pull request '统一 AI 公司研究:ARR 补证、商业模式初评与来源核查' (#42) from codex/ai-research-ph-all26 into main
Verify and deploy Tabularium / verify-and-deploy (push) Has been cancelled
Reviewed-on: #42
2026-10-31 16:30:24 +00:00
codex-bot eb7e758570 Register public SR007 roster and verify three new AI companies 2026-11-01 00:25:46 +08:00
codex-bot 5ba790eec2 Reconcile current YC coverage accounting with source boundaries 2026-11-01 00:14:51 +08:00
codex-bot ac9ddd9c27 Expose source candidates and unresolved classification gaps 2026-11-01 00:10:23 +08:00
codex-bot ea31287924 Keep webpage maintenance dates out of ARR financial cutoffs 2026-11-01 00:06:41 +08:00
codex-bot 5b31fa5b77 Connect evidence-backed business model assessments to company categories 2026-11-01 00:00:35 +08:00
codex-bot c833aa56c1 Trace DUIX and EZsite legacy businesses and licensing boundaries 2026-10-31 23:55:03 +08:00
codex-bot 0a640d75d4 Resolve Ada company registration and shared ChartGen identity 2026-10-31 23:50:49 +08:00
codex-bot 0bd2ce6b02 Cross-check Sacra candidates and classify General Intuition 2026-10-31 23:45:58 +08:00
codex-bot cce2fd8581 Expose ARR disclosure gaps before statistical comparison 2026-10-31 23:36:33 +08:00
codex-bot ae84304e16 Keep explicit ARR labels while guarding ambiguous income samples 2026-10-31 23:31:29 +08:00
codex-bot c6e5a7b05f Complete Product Hunt All snapshot and classify StoreClaw 2026-10-31 23:28:03 +08:00
Zayden-Jung 9bfd6891c3 Merge pull request '统一 AI 公司研究:ARR 分类表、名单补查与 CI 统计修复' (#41) from codex/ai-research-consolidated into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 4m0s
Reviewed-on: #41
2026-10-31 15:25:36 +00:00
codex-bot 3557ee7e87 Audit Product Hunt All archive and preserve company boundaries 2026-10-31 23:18:18 +08:00
codex-bot cc6993de2a Separate asset returns, funding and material pilot evidence 2026-10-31 23:10:05 +08:00
codex-bot 462fe01df0 Clarify game tooling, wallet fees and hardware evidence 2026-10-31 23:05:52 +08:00
codex-bot 838c9f1bad Audit teleoperation, product continuity and trading fee boundaries 2026-10-31 22:56:48 +08:00
codex-bot 0d7c24c529 Separate research, prototypes and current AI product evidence 2026-10-31 22:52:07 +08:00
codex-bot 74cd21c6fa Verify HEVN AI assistant and separate mixed service revenue 2026-10-31 22:46:33 +08:00
codex-bot 3c14c6fdca Audit Andustry sourcing AI and distinguish transaction fee models 2026-10-31 22:36:42 +08:00
codex-bot a1587e86bb Audit physical product stages and SpotPay entity boundaries 2026-10-31 22:29:58 +08:00
codex-bot 17d6589db1 Classify Fort and Sequence with training and revenue boundaries 2026-10-31 22:25:18 +08:00
codex-bot 708f136163 Verify ZeroSettle hosted AI and separate mixed billing economics 2026-10-31 22:20:06 +08:00
codex-bot f74b268da4 research: retain unqualified MRR claims and robotics product-stage gaps 2026-10-31 22:10:17 +08:00
codex-bot 2f525f97e3 research: link Voltair inspection service and review post-acquisition billing 2026-10-31 22:05:44 +08:00
codex-bot 92f7bcffbd research: link Sava trust workflows and review service revenue boundaries 2026-10-31 21:59:37 +08:00
codex-bot 1ca8ab72e8 research: separate trial evidence capacity and payment pricing from ARR 2026-10-31 21:54:50 +08:00
codex-bot 2c1f35f000 research: link DeepGrove model artifacts and separate candidate revenue boundaries 2026-10-31 21:51:18 +08:00
codex-bot 5cbaa013a7 research: distinguish infrastructure delivery and customer earnings 2026-10-31 21:46:35 +08:00
codex-bot f72043a4ed research: revisit AI scope and corporate lineage for YC candidates 2026-10-31 21:43:16 +08:00
codex-bot 21fe8ded32 feat: explain income dispersion within comparable sample groups 2026-10-31 21:34:38 +08:00
codex-bot 3c631a43a6 research: distinguish Chinese startup and institution evidence 2026-10-31 21:31:41 +08:00
codex-bot 53df8a4a2b research: link ARR definitions to subscription economics 2026-10-31 21:27:13 +08:00
codex-bot 5b86f08959 research: verify ARR currency and current contract models 2026-10-31 21:23:02 +08:00
codex-bot 4330817138 feat: show ARR disclosure coverage by business category 2026-10-31 21:18:11 +08:00
codex-bot 72f0928efa research: verify Perfingo historical roster identity 2026-10-31 21:15:24 +08:00
codex-bot e113b74c41 research: resolve Nunatak unlabeled roster link 2026-10-31 21:11:24 +08:00
codex-bot a1184b31b8 research: reconcile all 2026 named roster URLs 2026-10-31 21:08:50 +08:00
codex-bot 14e0c8ac76 research: distinguish treatment, diagnostic and audio revenue 2026-10-31 21:05:14 +08:00
codex-bot ea3433bd60 research: separate health analysis fees and clinic collections 2026-10-31 21:01:57 +08:00
codex-bot dc17a4d802 research: verify health devices and bereavement AI boundaries 2026-10-31 20:59:22 +08:00
codex-bot f49b5b96e7 research: audit first four 2026 health roster entries 2026-10-31 20:56:05 +08:00
codex-bot f3d83901a1 research: audit 2026 biotech roster and evidence boundaries 2026-10-31 20:47:46 +08:00
codex-bot cb803662c3 research: audit 2026 manufacturing roster and delivery gaps 2026-10-31 20:39:27 +08:00
codex-bot 7fc6742129 research: audit 2026 space roster and delivery evidence 2026-10-31 20:33:14 +08:00
codex-bot 91d4734763 research: audit 2026 energy roster and industrial revenue scope 2026-10-31 20:27:48 +08:00
codex-bot 4269d05b2e research: audit 2026 consumer roster and hardware revenue scope 2026-10-31 20:23:38 +08:00
codex-bot 4be984fa0a research: audit 2026 education roster and revenue boundaries 2026-10-31 20:18:17 +08:00
codex-bot 643c0df7aa research: audit 2026 infrastructure and social impact roster 2026-10-31 20:11:19 +08:00
codex-bot 8eb3b888d1 补核2026零售媒体八项并隔离实物佣金与按篇服务收入 2026-10-31 20:02:39 +08:00
codex-bot e9f48d6d9c 补核2026营销八项并拆分公关合作成果与软件净收入 2026-10-31 19:57:28 +08:00
codex-bot a67a3b3b81 补核2026房地产六项并保留成立冲突与机器人交付边界 2026-10-31 19:51:15 +08:00
codex-bot d7251e892b 补核2026物流六项并拆分运输硬件与软件净收入 2026-10-31 19:45:30 +08:00
codex-bot c0f9ee50c1 Merge remote-tracking branch 'origin/main' into codex/ai-research-consolidated 2026-10-31 19:39:46 +08:00
codex-bot ad682df1cc 补核2026人才招聘五项并区分按次评估与订阅收入 2026-10-31 19:39:46 +08:00
codex-bot cf47c1af31 补核2026法律公共事务七项并保留主体与签名验证缺项 2026-10-31 19:34:59 +08:00
codex-bot 06a6883be3 补核2026金融十项并拆分客户资产与软件净收入 2026-10-31 19:24:13 +08:00
codex-bot 9c08d9dc0f 补核2026安全组13项并核实 HODOR 法国登记 2026-10-31 19:17:04 +08:00
codex-bot c0924df7c4 补齐2026企业组候选并核实 Optise 登记与收入边界 2026-10-31 19:09:34 +08:00
codex-bot 0e1e127d4b 补核2026企业组第二批并区分成果合同与平台净费 2026-10-31 19:00:57 +08:00
codex-bot a9e5ac6a87 补核2026企业组八项并拆分任务交易与软件收入 2026-10-31 18:54:23 +08:00
codex-bot b81e1ad76e 补核2026汽车交通六项及软硬件与租赁收入边界 2026-10-31 18:46:19 +08:00
codex-bot 1f87628076 建立2026竞赛名单对账并补核农业食品七项 2026-10-31 18:42:59 +08:00
codex-bot a6a1860ef1 核实七项原网址对应并保留 SaveFirst 沿革缺项 2026-10-31 18:37:54 +08:00
codex-bot 94dcac4e95 补齐健康最后八项并对账2025总入口候选 2026-10-31 18:33:58 +08:00
codex-bot ec0d9abb34 补核健康七项及设备会员与原始验证边界 2026-10-31 18:29:50 +08:00
codex-bot d2e83cd33b 补核健康第三批及研究验证与收购边界 2026-10-31 18:25:30 +08:00
codex-bot 69c3cc8e64 补核健康名单第二批及 Eos 既有主体关联 2026-10-31 18:21:57 +08:00
codex-bot cdb84376cb 补核健康名单首六项及软硬件收入口径 2026-10-31 18:17:42 +08:00
codex-bot fabfce1f4c 补核娱乐物流制造24项并收录 CosmicBrain 2026-10-31 18:10:52 +08:00
codex-bot 142fa62d10 补核清洁能源22项并对账2025竞赛总名单 2026-10-31 18:00:37 +08:00
codex-bot 868594c79a 补核航天防务7项与 HANCE 后继 Rapidly 主体 2026-10-31 17:50:19 +08:00
codex-bot 83f91f1b1e 补核农业食品14项并收录按店计费的 Clave 2026-10-31 17:42:18 +08:00
codex-bot f99a354db2 增加按分类与成立年份浏览的 ARR 披露表 2026-10-31 17:36:03 +08:00
codex-bot 6410535ed9 补核TechCrunch消费教育26项并拆分交易硬件与软件收入 2026-10-31 17:27:07 +08:00
codex-bot db3b21bf54 补核TechCrunch安全名单9项并区分旧主体与收入口径 2026-10-31 17:14:55 +08:00
codex-bot 7140ea0469 完成54项缺年池补查并按最新官方字段纳入Graphide 2026-10-31 17:07:43 +08:00
codex-bot 241c82aa88 同步研究快照的核查与技术统计测试断言 2026-10-31 16:47:31 +08:00
codex-bot 5d62dee4e9 补核6项缺年候选并拆分推理代收与客户价值 2026-10-31 16:44:22 +08:00
Zayden-Jung 543d6ed527 Merge pull request '补核18项AI公司缺年候选、主体沿革与收费边界' (#40) from codex/ai-research-consolidated into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 4m1s
Reviewed-on: #40
2026-10-31 08:43:48 +00:00
codex-bot 445bfb37d0 补核6项缺年候选并区分旧业务与资金示例 2026-10-31 16:41:30 +08:00
codex-bot baaea4fc61 补核6项缺年候选并以登记证据纳入Zymbly 2026-10-31 16:38:01 +08:00
codex-bot ac04839571 补核6项缺年候选并保留旧品牌与服务收费边界 2026-10-31 16:34:12 +08:00
Zayden-Jung 7cecb9f753 Merge pull request '补核12项缺年AI候选,区分旧主体与成立缺项' (#39) from codex/ai-research-consolidated into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 4m15s
Reviewed-on: #39
2026-10-31 08:33:16 +00:00
codex-bot a2a5958fc4 补核12项缺年候选并关联官方主体登记 2026-10-31 16:29:35 +08:00
Zayden-Jung 7cc2397103 Merge pull request '统一 AI 公司研究:504项主营、192项AI候选与6项成立冲突补核' (#38) from codex/ai-research-consolidated into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 5m35s
Reviewed-on: #38
2026-10-31 08:27:07 +00:00
codex-bot feb4de220d 补核6项成立冲突并拆分旧年份与缺年候选 2026-10-31 04:48:47 +08:00
codex-bot 652b0fd539 完成192项AI不明名单补核并核对预订与交付口径 2026-10-31 04:37:52 +08:00
codex-bot 5ee04f26ae 补核24项AI候选并区分实体销售与预付收入 2026-10-31 04:26:29 +08:00
codex-bot 812749823b 补核24项AI不明候选并区分同名主体与流失收入 2026-10-31 04:16:25 +08:00
codex-bot 8a4df5c7ba Review fifth uncertain AI cohort and distinguish ARR from service and contract claims 2026-10-31 04:03:33 +08:00
codex-bot 8d886ad3cc Review fourth uncertain AI cohort and separate managed services from transaction flows 2026-10-31 03:51:44 +08:00
codex-bot 790dda09de Review third uncertain AI cohort and retain shutdown and mixed service evidence 2026-10-31 03:44:51 +08:00
codex-bot 5182eca961 Review second uncertain AI cohort and separate client and legacy financials 2026-10-31 03:38:16 +08:00
codex-bot 5cd8f831be Review uncertain AI candidates and preserve unresolved business scope 2026-10-31 03:31:34 +08:00
codex-bot a520694f9e Merge developer tag review into consolidated AI research 2026-10-31 03:17:15 +08:00
codex-bot dc373bad09 Merge model tag review into consolidated AI research 2026-10-31 03:17:08 +08:00
codex-bot dc2c3a22f3 Merge industry review into consolidated AI research 2026-10-31 03:17:02 +08:00
codex-bot fefa7c5ebd Merge enterprise review into consolidated AI research 2026-10-31 03:16:57 +08:00
codex-bot 5c2d9fd06c Complete remaining tag review and distinguish intent from revenue 2026-10-31 03:13:45 +08:00
codex-bot 85a0c534aa Review mixed directory tags and separate historical Mixy income 2026-10-31 03:06:48 +08:00
codex-bot d4736f7477 Complete enterprise-tag review and exclude prospective income claims 2026-10-31 02:59:56 +08:00
codex-bot f41d9e0d53 Review first enterprise-tag cohort and separate client metrics 2026-10-31 02:51:26 +08:00
codex-bot 1736912ecb Complete developer tag review and distinguish agent hosting cost models 2026-10-31 02:39:58 +08:00
codex-bot 45f1d1dba3 Review developer tag candidates and separate runtime and compute businesses 2026-10-31 02:32:24 +08:00
codex-bot 8d7b10df19 Review model tag candidates and separate licensing and revenue evidence 2026-10-31 02:24:38 +08:00
codex-bot d9d675ce56 Complete industry tag review and distinguish service revenue run rates 2026-10-31 02:17:19 +08:00
codex-bot b8a82359e3 Review 20 industry tag candidates and Litmus historical ARR 2026-10-31 02:05:45 +08:00
codex-bot 4682e3513e Review 20 tag-classified industry candidates and current AI relevance 2026-10-31 02:00:16 +08:00
codex-bot c0ff99e640 Complete remaining 39 industry classifications and revenue boundary checks 2026-10-31 01:53:42 +08:00
codex-bot b5e4f817fb Review 20 industry companies and separate services from model claims 2026-10-31 01:44:23 +08:00
codex-bot df227df18c Review 30 industry companies and distinguish revenue commitments 2026-10-31 01:37:48 +08:00
codex-bot 1e81887682 research: review regulated services and separate client assets from income 2026-10-31 01:23:41 +08:00
codex-bot c25fb79eee research: separate industry software from brokerage and operations services 2026-10-31 01:18:08 +08:00
codex-bot a8b054381f research: distinguish industry services and model training inputs 2026-10-31 01:12:51 +08:00
codex-bot 8e5df06fdd research: review industry workflows and historical ARR boundaries 2026-10-31 01:07:22 +08:00
codex-bot 4942b86a4a research: finish enterprise classification and separate Tasklet product history 2026-10-31 00:57:16 +08:00
codex-bot 7e4f4fe3e6 research: separate enterprise workflows and speech model evidence 2026-10-31 00:37:13 +08:00
codex-bot 1e716c36b2 research: review enterprise workflows and product pivots 2026-10-31 00:29:52 +08:00
Zayden-Jung ba042ed5ae Merge pull request '扩展 AI 公司研究并修复收入统计表布局' (#31) from codex/ai-research-cn-sme into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 9m0s
Reviewed-on: #31
2026-10-30 16:11:48 +00:00
codex-bot 0cc3815bd8 Review sales delivery models and Native founding conflict 2026-10-31 00:11:28 +08:00
codex-bot f49aac2778 Review creative and consumer classifications and historical income 2026-10-31 00:00:28 +08:00
codex-bot d588f2e1fb Review remaining robotics classifications and pilot contract boundaries 2026-10-30 23:49:14 +08:00
codex-bot ad1f351a6b Review software development classifications and delivery models 2026-10-30 23:40:12 +08:00
codex-bot f73d2631fb Review remaining model infrastructure classifications 2026-10-30 23:31:15 +08:00
codex-bot 6dca32dc66 Review compute infrastructure category boundaries 2026-10-30 23:21:43 +08:00
codex-bot 992275560f Resolve candidate years and preserve seller and legacy business boundaries 2026-10-30 23:12:15 +08:00
codex-bot 74c74ddda9 Expand platform income samples and derive report conclusions from current data 2026-10-30 22:58:47 +08:00
codex-bot 61201c1bb0 Review agent infrastructure classifications and financial service boundaries 2026-10-30 22:47:04 +08:00
codex-bot 4eb08c0857 Research 2025 Speedrun companies and separate pilot and client revenue 2026-10-30 22:35:50 +08:00
codex-bot 3e7792191e Audit biotech candidates and separate research milestones from revenue 2026-10-30 22:19:52 +08:00
codex-bot fa09a01421 Audit financial and real-estate candidates without treating asset values as ARR 2026-10-30 22:12:07 +08:00
Zayden-Jung 87987f6cc6 Merge pull request '支持基于内部摘要的章节级文章编辑' (#30) from codex/document-sections into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 8m39s
Reviewed-on: #30
2026-10-30 14:05:53 +00:00
codex-bot c9497ccc6b Audit visible government and legal startup candidates with source gaps 2026-10-30 22:04:11 +08:00
codex-bot 05b500e911 Refine physical industry classifications and preserve pivot revenue limits 2026-10-30 21:54:16 +08:00
codex-bot ea09b414a2 Merge income statistics layout fix into AI research delivery 2026-10-30 21:40:21 +08:00
codex-bot 945e7eadb2 Keep income statistics columns readable with grouped rows 2026-10-30 21:36:38 +08:00
codex-bot f86a4a75a0 Expand Speedrun companies and separate contract revenue claims 2026-10-30 21:20:12 +08:00
codex-bot ccba2c546a Expand Chinese SME AI delivery models and physical-AI candidates 2026-10-30 21:11:14 +08:00
Zayden-Jung 56b0ee60c0 Merge pull request '扩展中英文 AI 公司分类与收入核查' (#29) from codex/ai-research-source-lineage into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 6m33s
Reviewed-on: #29
2026-10-30 13:05:46 +00:00
codex-bot f7cdf16933 Audit four further TechCrunch candidates and bundled-service boundaries 2026-10-30 20:58:01 +08:00
codex-bot deadd24dce Verify AiMOGA founding and preserve interim revenue disclosure 2026-10-30 20:50:58 +08:00
codex-bot 4f1d3caafd Expand Chinese candidates and audit data-vendor revenue evidence 2026-10-30 20:40:17 +08:00
codex-bot 28ced713dc Preserve unspecified period of the AfterQuery run-rate claim 2026-10-30 20:22:00 +08:00
codex-bot c8c8bad563 Separate revenue run-rate claims from recurring contract evidence 2026-10-30 20:19:03 +08:00
codex-bot 13f2b31e8a Review model-category business boundaries and reconcile Eka history 2026-10-30 20:08:28 +08:00
codex-bot 9ee2bd219a Add chapter editing API with internal summaries and revision control 2026-10-30 19:58:40 +08:00
codex-bot 20e5097783 Record Nirva founder-reported team size and planned price 2026-10-30 19:55:45 +08:00
codex-bot ae4bd203a1 Add three model and wearable companies from archive discovery 2026-10-30 19:50:05 +08:00
codex-bot 5b6d3d9bfd Reconcile four pre-2025 businesses with original source evidence 2026-10-30 19:36:51 +08:00
Zayden-Jung 6b4fa711c1 Merge pull request '补核六项既有业务并保留 Sponstar 年份冲突' (#28) from codex/ai-research-year-proofs into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 8m34s
Reviewed-on: #28
2026-10-30 11:32:34 +00:00
codex-bot 76e32490e9 Verify six company histories and retain conflicting founding dates 2026-10-30 19:27:55 +08:00
Zayden-Jung fa0bcb15fb Merge pull request '补齐32项企业竞赛登记并核查19项既有业务' (#26) from codex/ai-research-2025-coverage into main
Verify and deploy Tabularium / verify-and-deploy (push) Has been cancelled
Reviewed-on: #26
2026-10-30 11:20:47 +00:00
codex-bot 12123c278f Resolve fourteen older Battlefield businesses with source evidence 2026-10-30 19:17:43 +08:00
codex-bot 4273dd77e0 Register all 32 enterprise Battlefield discovery entries 2026-10-30 19:09:13 +08:00
Zayden-Jung 3ab2f8e122 Merge pull request '汇总 AI 公司核查更新与文档上传 API' (#25) from codex/research-api-integration into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 7m33s
Reviewed-on: #25
2026-10-30 10:28:45 +00:00
codex-bot 15024c3908 merge: include latest company research updates from PR #23 2026-10-30 18:20:34 +08:00
codex-bot 770be83692 Backfill 2025 discovery ledger and verify public archive scope 2026-10-30 18:19:29 +08:00
codex-bot 083f532609 merge: integrate document upload API from PR #24 2026-10-30 18:17:53 +08:00
codex-bot 2b3706c90d merge: integrate company categorization and deployment fixes from PR #23 2026-10-30 18:17:53 +08:00
codex-bot 6bfeea4e7e feat: add scoped document API and resumable batch uploader 2026-10-30 18:14:33 +08:00
codex-bot f156406265 Classify Thrive services rollup and exclude Brila existing operator 2026-10-30 18:12:52 +08:00
codex-bot e65d90c78b Resolve Utopai founding history and preserve Fundraisly entity gaps 2026-10-30 18:10:35 +08:00
codex-bot 96f1417cda Add Goldfish with investor-backed founding-year evidence 2026-10-30 18:08:01 +08:00
codex-bot 347b9587c6 Reconcile Tapflow history and Atoms company-year evidence 2026-10-30 18:04:11 +08:00
codex-bot 677cd73945 Fix Peec team bounds regression assertion 2026-10-30 17:54:08 +08:00
codex-bot e075eee8a5 Separate legacy launches from unresolved Happycapy entity lineage 2026-10-30 17:41:15 +08:00
codex-bot d971362a97 Resolve four launch candidates through operator and app history evidence 2026-10-30 17:31:44 +08:00
codex-bot 0e5ccafa30 Classify remaining directory profiles using official business evidence 2026-10-30 17:21:06 +08:00
Zayden-Jung 0c2a909521 Merge pull request '汇总 README 整理与 Product Hunt 候选历史核查' (#22) from codex/readme-research-integration into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 3m51s
Reviewed-on: #22
2026-10-30 09:17:21 +00:00
codex-bot a58a409c38 merge: combine README cleanup and Product Hunt history research 2026-10-30 17:13:48 +08:00
codex-bot c4ab5caac0 merge: integrate Product Hunt candidate history from PR #21 2026-10-30 17:13:13 +08:00
codex-bot 4cbb88181a Exclude four pre-2025 businesses from launch candidates 2026-10-30 17:10:51 +08:00
codex-bot ce87abd1cb docs: streamline README around content management and operations 2026-10-30 17:10:00 +08:00
Zayden-Jung dc7ec078e1 Merge pull request '补充行业基准适用性与收费研究,适配数据库文档' (#19) from codex/ai-research-context into main
Verify and deploy Tabularium / verify-and-deploy (push) Waiting to run
Reviewed-on: #19
2026-10-30 09:05:44 +00:00
codex-bot bba9fddef1 Add benchmark methodology and pricing research after document migration 2026-10-30 17:01:09 +08:00
Zayden-Jung 1302287205 Merge pull request '汇总研究更新、奇思妙想与文档数据库迁移(main-only CI)' (#18) from codex/docs-integration into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 7m37s
Reviewed-on: #18
2026-10-30 08:48:28 +00:00
codex-bot 59a1e4608b merge: preserve the latest Wabi research in the document database migration 2026-10-30 16:45:16 +08:00
codex-bot ed99743730 merge: integrate database documents, preserve all proposals and keep CI main-only 2026-10-30 16:40:53 +08:00
codex-bot 74af205b29 Add Wabi company case with attributed founding and free-service limits 2026-10-30 16:40:38 +08:00
codex-bot cffca6942b merge: retain the traffic platform proposal from PR #15 2026-10-30 16:39:03 +08:00
codex-bot bf58a27779 merge: integrate income group labels from PR #16 2026-10-30 16:39:03 +08:00
codex-bot 922835704b merge: integrate AI research sources from PR #14 2026-10-30 16:39:03 +08:00
codex-bot 864a30356d Record bounded TechCrunch candidates and original-source checks 2026-10-30 16:35:52 +08:00
codex-bot 9a026a08a5 登记中文来源线索并补Agent与基础设施公司个案 2026-10-30 16:22:55 +08:00
codex-bot 62d3d498bb 复核EveryApp入表并保留原候选初筛审计 2026-10-30 16:09:43 +08:00
codex-bot 62c015fa9b 核对产品运营主体并补Lightfield转型对照 2026-10-30 16:00:13 +08:00
codex-bot ec95f3be31 明确收入图表的业务类别与统计分组层级 2026-10-30 15:51:57 +08:00
codex-bot 744e3314b8 核对七项候选年份并隔离第三方财务字段异常 2026-10-30 15:44:50 +08:00
codex-bot 79a6a8dbd7 补2025产品候选并核对成立年与Trace主体 2026-10-30 15:21:28 +08:00
codex-bot 0dcd178394 docs: explore Endor as a coding agent traffic platform 2026-10-30 15:16:35 +08:00
codex-bot dca7c81889 登记Product Hunt候选并补充Kilo公司个案 2026-10-30 15:11:59 +08:00
codex-bot f27b82b6a5 登记AI研究来源边界并列明覆盖缺项 2026-10-30 14:24:12 +08:00
42 changed files with 109779 additions and 6216 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
- 本仓库专门维护 Endor 文档与制图脚本。支持本地开发;CI/CD 仅在 main 使用现有 ces-control Runner,验证通过后自动部署;工作分支与 PR 不运行 CI,确认 Runner 不构成扩大触发范围的授权。不得修改其他项目、共享 Runner 配置或清理共享 Docker 缓存。 - 本仓库专门维护 Endor 文档与制图脚本。支持本地开发;CI/CD 仅在 main 使用现有 ces-control Runner,验证通过后自动部署;工作分支与 PR 不运行 CI,确认 Runner 不构成扩大触发范围的授权。不得修改其他项目、共享 Runner 配置或清理共享 Docker 缓存。
- Web 技术栈为 TypeScript、React、Tailwind CSS、Next.js Pages Router;不要切换到 App Router。 - Web 技术栈为 TypeScript、React、Tailwind CSS、Next.js Pages Router;不要切换到 App Router。
- 使用 pnpm 管理 JavaScript workspace,使用 uv 管理 `tools/figures` 的 Python 项目,保留两套锁文件。 - 使用 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 配置。 - 本地开发与预览只监听 `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`。 - 对 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`。
+221 -130
View File
@@ -1,18 +1,38 @@
# Endor · Tabularium # 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 ```sh
sudo /opt/endorphin-tabularium-docs/docs list sudo /opt/endorphin-tabularium-docs/docs list
@@ -20,168 +40,239 @@ sudo /opt/endorphin-tabularium-docs/docs get endor/endor-purpose-draft
sudo /opt/endorphin-tabularium-docs/docs history 04-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 ```sh
sudo /opt/endorphin-tabularium-docs/docs get endor/endor-purpose-draft > /private/work/purpose.json umask 077
# 在私有文件中修改正文或目录字段,保留读到的 revision。 doc_work_dir=$(mktemp -d)
sudo /opt/endorphin-tabularium-docs/docs save < /private/work/purpose.json 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。奇思妙想这类内容同样通过数据库写入;代码分支与数据发布是不同动作,不能因为迁移程序合并就自动发布其他未合并分支的内容。 ## 文档上传 API
## 首次迁移与备份 文档 API 直接读写同一个数据库,供自动化整理和大量文档上传使用。它只接受独立的文档 API Key,不接受网页 Cookie 或通用网页会话令牌;Key 不能用于研究、登录或其他站点 API。
迁移前主线的 50 篇正文及目录可从固定历史提交 `25c5e83d08eae4277fe429a692955bd3bb2e717f` 完整导出: | 方法与地址 | 用途 |
| --- | --- |
| `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 ```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/documents/images/<id>)`,然后通过章节 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 ```sh
sudo /opt/endorphin-tabularium-docs/docs backup /auth/manual-backup.sqlite sudo /opt/endorphin-tabularium-docs/docs backup /auth/manual-backup.sqlite
sudo /opt/endorphin-tabularium-docs/docs export /auth/current-documents.json 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` 仍保存图形源码及可复现的静态资源。图形构建也在远程执行。 站点使用共享验证器的 6 位 TOTP 动态码,每 30 秒更新。会话使用 HttpOnly、SameSite=Lax Cookie,有效期 7 天;共享尝试上限为每 5 分钟 20 次,重放与限流状态在数据库中持久保存。缺少或损坏认证配置时拒绝私有访问。
## 动态码登录与二维码
与 PROMPT 的参考流程一致:共享验证器的6位TOTP动态码,每30秒更新;成功登录的HttpOnly、SameSite=Lax会话有效7天。此方案是动态码单因素登录,不是密码加动态码的双因素组合。
正文、SVG、文档数据与API都经过认证,仅登录入口、登录所需代码资源和最小健康检查公开。20次/5分钟的共享尝试上限与已用时间步持久保存在SQLite中,跨进程和重启继续生效。缺失或损坏配置时拒绝访问,不自动降为公开站点。
生产认证文件位于服务器 `/opt/endorphin-tabularium-docs/private-auth`,目录0700、文件0600,只挂载进本项目容器。部署只初始化一次,后续更新保留同一验证器密钥与会话签名密钥。生产镜像、Git、CI日志和网页不包含密钥。
管理员通过SSH管理:
```sh ```sh
sudo /opt/endorphin-tabularium-docs/auth qr sudo /opt/endorphin-tabularium-docs/auth qr
sudo /opt/endorphin-tabularium-docs/auth verify 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 公司研究**,页面路径是 主线通过 `.gitea/workflows/ci-deploy.yml` 执行 `scripts/ci.py`:
`/docs/other-research/ai-company-research/overview`。工作台提供业务分类、公司搜索与筛选、
公司明细、收入观测、原始证据和数据核查记录。所有页面与
`GET /api/research/ai-companies` 均使用文档站现有的登录认证。
研究业务数据本轮暂不迁移,仍沿用版本化 JSON 导入机制。研究数据、文档与登录状态**共用文档站现有的 SQLite 数据库文件**: 1. 检查研究统计、审计与 Python 测试,生成首次迁移所需的历史导出。
`AUTH_CONFIG_FILE` 同目录下的 `login-state.sqlite`。该文件名是历史名称,未创建第二个数据库。 2. 根据程序、研究快照、图形、锁文件和构建脚本计算内容指纹。命中 verified 镜像时复用,否则安装依赖并执行 lint、类型检查、数据库测试、图形检查与构建。
`login_state` 保留登录限流与重放状态,新增的 `research_*` 表保存数据版本、分类、 3. 使用隔离数据库卷核对完整迁移、认证、全部文档与资源,以及不重建内容更新和重启持久化;通过后标记镜像为 verified。
公司、经营指标、证据与核查记录。沿用现有 `/auth` 持久挂载,数据库不会写入镜像或公开目录。 4. `scripts/deploy.py` 确认仍为最新 main,备份并初始化文档数据,使用 `--no-build` 部署;失败时回退上一版镜像。
`content/research/ai-companies.seed.json` 是可评审的研究导入快照, `pnpm check`、`pnpm typecheck`、`pnpm test:auth`、`pnpm test:research`、`pnpm test:docs`、`pnpm figures`、`pnpm build` 和 `pnpm dev` 等命令涉及编译、构建或产物生成,不在本机运行。图形源码与生成资源保持可复现,前端继续使用 Pages Router。
服务端首次读取该版本时将它以事务导入同一数据库,之后从数据库查询记录。
导入使用外键校验,不删除登录数据,也不覆盖旧研究版本。每次更新快照必须修改 `version`;
同一版本内容变化会拒绝导入,避免静默读取旧数据。回退代码时继续读取对应旧版本。
所有修改都经过工作分支和 PR,部署仍等待人工合并主线。
当前资料包含 1,015 条公司/产品记录、10 类业务及待分类组、1,091 条证据记录和 183 条经营与技术观测。 根 README 等非构建输入变更不要求重新构建;数据库正文和目录更新也不触发构建。保留 pnpm、uv、Next.js 与 SWC 缓存,不清理共享 Docker 缓存或卷,不修改共享 Runner 或其他项目。
其中保留 86 家个案资料、913 条目录基础资料,并新增 16 个平台产品/业务样本;目录项不等于完成财务和技术核验。
补充ThinkWheel与中国NaiveAI;MiroMind成立年未核,公开研发年份单列。保留灵初智能成立年排除、深度原理日期待核和Naive近名实体复核记录。
新增7条Speedrun个案,保留标题/正文冲突、七位数数量级、币种不明、合同管线与履历归属排除记录;相对日期不充当同期收入时点。核查页优先按人工确认的公司ID关联明细,旧版本保留按证据归属回退;54条核查中43条指向具体公司、3条属于全局方法、8条是未纳入候选。明细反向列出关联核查;币种不明的声明保留原值,不绘制美元收入长度条。
Bud保留为2025成立且YC标记已收购的案例;Rocket.new按前身2021成立、2025产品上线的转型对照记录,历史ARR不混入新成立公司同期统计。
验收按2026-10-30确认的公开来源清单边界逐条核对并标明缺项;YC候选池有完整读取台账,其他来源目前为定向补充,不能把来源出现次数当作覆盖完成。收入披露不足且统计时点不统一,不代表全部 AI 公司。
界面将公司自述、第三方估算、融资、客户收入和履历收入分开,未知收入不记为零。
保留被撤回的声明作为核查记录,并排除收入比较;并购后业务、管理层预测与历史里程碑单列。
团队规模下界在列表中显示比较符,商业成熟度展示公开交付证据,不等于技术能力排名。
Emergent的1亿美元ARR里程碑补足至2026年2月;Peec采用规模和Wonderful团队规模各保留其公告日期,不将较新非收入指标当作ARR更新。Wonderful按2026年9月官方综合平台定位归C05。
公司披露的 ARR 尚未建立同期同口径比较组。新增平台 MRR 单独计算探索性样本统计,
不能称行业平均 ARR、总体离散排名或财务造假概率。
收入分布:`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运行此检查。 | Compose 项目 | `endorphin-tabularium-docs` |
收入快照报告位于 `/docs/other-research/ai-company-research/income-snapshot`,样本名称链接 `overview?company=<id>` 的公司明细。 | 容器监听 | `0.0.0.0:3000` |
`python3 scripts/summarize-ai-income.py --check` 独立复核统计与参考快照一致性;不触发编译或构建。数据变更后先审查方法,再运行不带参数的命令生成 `.artifacts/reports` 下的候选正文,使用 docs 管理工具显式更新数据库;不再生成受 Git 管理的正文。 | 服务器入口 | 默认端口 `18135`,由生产配置决定 |
默认7日样本14个产品;90日窗口新增Gojiberry的8月历史点,营销样本的均值和CV随之明显变化。 | 生产配置 | `/etc/endorphin-tabularium-docs/production.env` |
这提示先核查时间与样本构成,不能将高离散度直接解释为造假。两个近名营养应用按不同App Store ID分别建档。 | 发布记录与当前版本 | `/opt/endorphin-tabularium-docs/releases/<commit>` 与 `current` |
| 健康检查 | `/api/health` |
覆盖核查:从 yc-oss 非官方镜像发现 2025—2026 八个 YC 批次的 1,428 条候选, 服务入口使用 `TABULARIUM_BIND`、`TABULARIUM_PORT` 配置。部署前检查端口是否空闲或属于本服务,不停止其他服务抢占端口;本项目不自动修改防火墙、域名或反向代理。
逐条读取对应 YC 官方公开页面,以页面中的成立年份、团队字段和业务描述筛选。
962 条初筛匹配,其中 34 条与原表 URL 重合,实际新增 928 条;268 条年份不符或缺失、
192 条 AI 业务待确认、6 条成立年份晚于批次年份未纳入本批新增。
`content/research/ai-companies.coverage.json` 保留逐条筛选结果、来源与响应摘要,
数据快照中的 `coverage` 也随版本进入同一 SQLite 数据库。只覆盖该候选池,不能称全球全量。
新增业务分类优先匹配主营简介,再参考目录标签;未匹配者进入待分类组。 仓库:[endorphin-tabularium-docs](https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs)。Git 操作仅使用 `codex-bot` 专用 SSH 身份与仓库级提交配置;Actions 检出使用 Gitea 内置任务令牌且不保存凭据。
初分规则位于 `scripts/classify-ai-profiles.py`,只生成待评审标签,不执行抓取或构建。
目录基础项保留 `classificationBasis` 和 `directoryTags` 供复核。
当前目录基础项中160条已人工复核主营与买家,Contrario及14条收入声明/排除案例已深化为个案。`directoryOrigin`保留原发现来源,`profileOnly`只表示当前资料深度;深化不改变原候选池新增928条的审计分母。
分类按主要工作任务和买家判断,例如Paragon归客户与收入运营,Oki归消费者应用,Lucidic归Agent设施。市场支出、采用规模、性能主张及客户收益均不作为公司收入。
其中121条原待分类记录已按YC官方业务介绍人工复核主营业务,分类理由保存在公司字段与对应证据摘要中;
仍有3条因简介不足保留待分类。人工复核分类不代表独立验证技术水平、客户效果或财务数据。
AI 关键词可能出现在业务背景中,收入、买家和交付状态仍须逐家深查,不能把目录项的未知收入当作未披露。
技术专项评审用独立字段记录层级、日期、摘要与证据:未评审、已读技术自述、公开代码/模型材料可访问、有第三方独立评测记录。
当前7条完成该字段的专项记录,其中3条确认材料可访问、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 内置任务令牌,不保存检出凭据。
+2 -2
View File
@@ -20,7 +20,8 @@
"react": "19.3.1", "react": "19.3.1",
"react-dom": "19.3.1", "react-dom": "19.3.1",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"remark-gfm": "4.0.1" "remark-gfm": "4.0.1",
"sharp": "0.35.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "4.3.3", "@tailwindcss/postcss": "4.3.3",
@@ -33,7 +34,6 @@
"eslint": "^9.39.5", "eslint": "^9.39.5",
"eslint-config-next": "16.4.3", "eslint-config-next": "16.4.3",
"postcss": "8.5.31", "postcss": "8.5.31",
"sharp": "0.35.5",
"tailwindcss": "4.3.3", "tailwindcss": "4.3.3",
"typescript": "~5.9.3" "typescript": "~5.9.3"
} }
+65 -8
View File
@@ -18,6 +18,7 @@ import {
totp, totp,
isLoginRedirect, isLoginRedirect,
} from "../src/lib/auth"; } from "../src/lib/auth";
import { createDocumentKey, revokeDocumentKey } from "../src/lib/document-keys";
import { readDocument, saveDocument } from "../src/lib/docs"; import { readDocument, saveDocument } from "../src/lib/docs";
import { consumeCode, initializeStore } from "../src/lib/auth-store"; import { consumeCode, initializeStore } from "../src/lib/auth-store";
@@ -181,10 +182,10 @@ async function main() {
assert.equal(researchResponse.status, 200, "authenticated research API"); assert.equal(researchResponse.status, 200, "authenticated research API");
assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store")); assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store"));
const research = await researchResponse.json(); const research = await researchResponse.json();
assert.equal(research.companies.length, 1015); assert.equal(research.companies.length, 1312);
assert.equal(research.categories.length, 11); assert.equal(research.categories.length, 11);
assert.equal(research.evidence.length, 1091); assert.equal(research.evidence.length, 5354);
assert.equal(research.observations.length, 183); assert.equal(research.observations.length, 495);
assert.equal((await request("/api/research/ai-companies", { method: "POST", headers })).status, 405); 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 }); const researchPage = await request("/docs/other-research/ai-company-research/overview", { headers });
assert.equal(researchPage.status, 200); assert.equal(researchPage.status, 200);
@@ -199,17 +200,22 @@ async function main() {
for (const doc of searchIndex as { slug: string; sourceSlug: string }[]) { for (const doc of searchIndex as { slug: string; sourceSlug: string }[]) {
assert.ok(!doc.slug.split("/").some((part) => /^\d+-/.test(part)), doc.slug); assert.ok(!doc.slug.split("/").some((part) => /^\d+-/.test(part)), doc.slug);
const legacy = await request(`/docs/${doc.sourceSlug}?from=legacy`, { headers }); const legacy = await request(`/docs/${doc.sourceSlug}?from=legacy`, { headers });
assert.equal(legacy.status, 308, doc.sourceSlug);
const destination = new URL(legacy.headers.get("location")!, base);
assert.equal(destination.pathname, `/docs/${doc.slug}`);
assert.equal(destination.searchParams.get("from"), "legacy");
assert.ok(legacy.headers.get("cache-control")?.includes("no-store")); assert.ok(legacy.headers.get("cache-control")?.includes("no-store"));
if (doc.sourceSlug === doc.slug) {
assert.equal(legacy.status, 200, doc.sourceSlug);
} else {
assert.equal(legacy.status, 308, doc.sourceSlug);
const destination = new URL(legacy.headers.get("location")!, base);
assert.equal(destination.pathname, `/docs/${doc.slug}`);
assert.equal(destination.searchParams.get("from"), "legacy");
}
} }
const routes = ["/", ...searchIndex.map((doc: { slug: string }) => `/docs/${doc.slug}`)]; const routes = ["/", ...searchIndex.map((doc: { slug: string }) => `/docs/${doc.slug}`)];
const raw = await request("/api/documents/source?slug=endor/endor-purpose-draft", { headers }); const raw = await request("/api/documents/source?slug=endor/endor-purpose-draft", { headers });
assert.equal(raw.status, 200); assert.equal(raw.status, 200);
assert.ok((await raw.text()).startsWith("# Endor")); assert.ok((await raw.text()).startsWith("# Endor"));
if (process.env.DOCUMENT_SMOKE_TEST) { if (process.env.DOCUMENT_SMOKE_TEST) {
if (command === "exercise") await exerciseDocumentApi(request, headers);
const sourceSlug = "ci-live-document"; const sourceSlug = "ci-live-document";
const previous = readDocument(sourceSlug); const previous = readDocument(sourceSlug);
if (command === "exercise") { if (command === "exercise") {
@@ -221,7 +227,8 @@ async function main() {
saveDocument({ ...first, slug: "endor/ci-live-document-renamed", content: "# Runtime document\n\nUpdated without rebuilding" }, first.revision); saveDocument({ ...first, slug: "endor/ci-live-document-renamed", content: "# Runtime document\n\nUpdated without rebuilding" }, first.revision);
} }
const stored = readDocument(sourceSlug); const stored = readDocument(sourceSlug);
assert.ok(stored?.content.includes("Updated without rebuilding"), "DB content survives restart"); assert.ok(stored, "Smoke document must exist after creation or restart");
assert.ok(stored.content.includes("Updated without rebuilding"), "DB content survives restart");
const live = await request(`/docs/${stored.slug}`, { headers }); const live = await request(`/docs/${stored.slug}`, { headers });
assert.equal(live.status, 200); assert.equal(live.status, 200);
assert.ok((await live.text()).includes("Updated without rebuilding")); assert.ok((await live.text()).includes("Updated without rebuilding"));
@@ -251,6 +258,17 @@ async function main() {
for (const id of ids) assert.ok(html.includes(`href="#${id}"`), `TOC anchor: ${route}#${id}`); for (const id of ids) assert.ok(html.includes(`href="#${id}"`), `TOC anchor: ${route}#${id}`);
} }
} }
assert.ok(!home.includes('data-category="Endor"'), "Superseded Endor collection is absent from navigation");
if (searchIndex.some((doc: { collection: string }) => doc.collection === "Endorphin")) {
assert.equal([...home.matchAll(/data-category="([^"]+)"/g)][0]?.[1], "Endorphin", "Endorphin is the first category");
assert.ok(home.includes('/docs/endorphin/value-proposition'), "Endorphin opens at its value proposition");
}
// Retired documents remain reachable for historical links and gallery checks.
if (!purpose) {
const archived = await request("/docs/endor/endor-purpose-draft", { headers });
assert.equal(archived.status, 200);
purpose = await archived.text();
}
assert.ok(home.includes("资料库")); assert.ok(home.includes("资料库"));
assert.ok(home.includes("/docs/source-studies/pretext/overview")); assert.ok(home.includes("/docs/source-studies/pretext/overview"));
// Inline preview and zoom dialog must both use the compressed browser asset. // Inline preview and zoom dialog must both use the compressed browser asset.
@@ -355,3 +373,42 @@ main().catch((error) => {
); );
process.exitCode = 1; 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");
}
+29 -3
View File
@@ -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 { openSiteDatabase } from "../src/lib/site-database";
import { allDocuments, readDocument, saveDocument, importDocuments, documentHistory } from "../src/lib/docs"; import { allDocuments, readDocument, saveDocument, importDocuments, documentHistory } from "../src/lib/docs";
process.umask(0o077); process.umask(0o077);
const [command, argument] = process.argv.slice(2); const [command, argument, ...extra] = process.argv.slice(2);
try { try {
switch (command) { 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": { case "backup": {
if (!argument) throw Error("Provide a private backup filename"); if (!argument) throw Error("Provide a private backup filename");
const db = openSiteDatabase(); const db = openSiteDatabase();
@@ -36,7 +62,7 @@ try {
{ flag: "wx", mode: 0o600 }); { flag: "wx", mode: 0o600 });
console.log("Document export created; existing files are never overwritten"); break; 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) { } catch (error) {
console.error(error instanceof Error ? error.message : "Document operation failed"); console.error(error instanceof Error ? error.message : "Document operation failed");
+3 -4
View File
@@ -6,9 +6,8 @@ import { spawnSync } from "node:child_process";
const dir = await mkdtemp(join(tmpdir(), "tabularium-docs-tests-")); const dir = await mkdtemp(join(tmpdir(), "tabularium-docs-tests-"));
try { try {
const file = join(dir, "docs.test.cjs"); await build({ entryPoints: ["tests/docs.test.ts", "tests/documents-api.test.ts"], bundle: true,
await build({ entryPoints: ["tests/docs.test.ts"], bundle: true, platform: "node", target: "node22", format: "cjs", outdir: dir, outExtension: { ".js": ".cjs" } });
platform: "node", target: "node22", format: "cjs", outfile: file }); const result = spawnSync(process.execPath, ["--test", join(dir, "docs.test.cjs"), join(dir, "documents-api.test.cjs")], { stdio: "inherit" });
const result = spawnSync(process.execPath, ["--test", file], { stdio: "inherit" });
process.exitCode = result.status ?? 1; process.exitCode = result.status ?? 1;
} finally { await rm(dir, { recursive: true, force: true }); } } finally { await rm(dir, { recursive: true, force: true }); }
+14 -8
View File
@@ -1,15 +1,18 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import IncomeDistribution from "./IncomeDistribution"; import IncomeDistribution from "./IncomeDistribution";
import ArrDisclosures from "./ArrDisclosures";
import BusinessModelAssessment from "./BusinessModelAssessment";
import ResearchCandidates from "./ResearchCandidates";
import { import {
categorySummary, evidenceUrl, technicalReviewLabels, reviewCompanyIds, type ResearchEvidence, type ResearchReview, type ResearchSnapshot, categorySummary, evidenceUrl, technicalReviewLabels, reviewCompanyIds, type ResearchEvidence, type ResearchReview, type ResearchSnapshot,
} from "@/lib/ai-research"; } from "@/lib/ai-research";
const pageSize = 12; const pageSize = 12;
const evidencePageSize = 30; const evidencePageSize = 30;
const revenueLabels = { self: "公司披露,待核口径", estimate: "仅第三方估算", platform: "仅支付平台记录", unknown: "暂无可用收入数字" }; const revenueLabels = { self: "企业披露,待核口径", estimate: "仅第三方估算", platform: "仅支付平台记录", unknown: "暂无可用收入数字" };
const useLabels: Record<string, string> = { const useLabels: Record<string, string> = {
platform: "支付平台记录", signal: "公司披露信号", estimate: "第三方估算", historical: "历史里程碑", platform: "支付平台记录", signal: "企业披露信号", estimate: "第三方估算", historical: "历史里程碑",
"historical-estimate": "其他时点估算", excluded: "排除收入比较", scale: "团队规模", adoption: "采用规模", "historical-estimate": "其他时点估算", excluded: "排除收入比较", scale: "团队规模", adoption: "采用规模",
funding: "融资", technical: "第三方技术评测", crosscheck: "同一里程碑的其他资料", funding: "融资", technical: "第三方技术评测", crosscheck: "同一里程碑的其他资料",
}; };
@@ -55,7 +58,7 @@ export default function AICompanyResearch() {
: router.isReady && typeof linkedCompany === "string" && data?.companies.some((c) => c.id === linkedCompany) : router.isReady && typeof linkedCompany === "string" && data?.companies.some((c) => c.id === linkedCompany)
? linkedCompany : null; ? linkedCompany : null;
function setSelected(id: string | null) { setSelection({ route: router.asPath, id }); } 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 companySection = useRef<HTMLElement>(null);
const detailSection = useRef<HTMLElement>(null); const detailSection = useRef<HTMLElement>(null);
useEffect(() => { useEffect(() => {
@@ -127,7 +130,7 @@ export default function AICompanyResearch() {
<section ref={companySection} className="research-section" aria-labelledby="research-records-title"> <section ref={companySection} className="research-section" aria-labelledby="research-records-title">
<h2 id="research-records-title">研究数据</h2> <h2 id="research-records-title">研究数据</h2>
<div className="research-tabs" role="group" aria-label="研究数据视图"> <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>)} <button key={id} aria-pressed={tab === id} onClick={() => setTab(id)}>{label}</button>)}
</div> </div>
{tab === "companies" && <> {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={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={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={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> </div>
<p className="research-muted">技术验证层级记录我们查到了什么,不是能力强弱评分。技术专项评审与业务分类复核分别记录。</p> <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> <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 && <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>} {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" && <> {tab === "revenue" && <>
<IncomeDistribution data={data} onSelect={setSelected} /> <IncomeDistribution data={data} onSelect={setSelected} />
<h3>公司披露与估算的历史里程碑</h3> <ArrDisclosures data={data} onSelect={setSelected} />
<p className="research-notice">这一组包含 {disclosedCompanies} 家公司的披露或估算信号,多为异时点历史里程碑,ARR 与年化收入口径仍待核对,暂不计算其同期领域均值。币种未确认的记录仅显示原值,不绘制美元长度条。上面的平台MRR统计与这一组分开。</p> <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 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> <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>} {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> <small>{o.kind} · {useLabels[o.selection]} · {o.timing}</small>
</div>)}</div> </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> <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>}</>} {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 { useMemo, useState } from "react";
import type { ResearchSnapshot } from "@/lib/ai-research"; 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", { const usd = (value: number | null) => value === null ? "—" : new Intl.NumberFormat("zh-CN", {
style: "currency", currency: "USD", maximumFractionDigits: 0, style: "currency", currency: "USD", maximumFractionDigits: 0,
@@ -20,6 +20,10 @@ export default function IncomeDistribution({ data, onSelect }: {
}), [data, dateWindow, year, annualize]); }), [data, dateWindow, year, annualize]);
const groups = useMemo(() => groupIncome(sample.points, splitModels).sort((a, b) => const groups = useMemo(() => groupIncome(sample.points, splitModels).sort((a, b) =>
a.category.localeCompare(b.category) || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model)), [sample.points, splitModels]); a.category.localeCompare(b.category) || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model)), [sample.points, splitModels]);
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 categories = new Map(data.categories.map((category) => [category.id, category.name]));
const metric = annualize ? "MRR×12参考年化" : "平台MRR"; const metric = annualize ? "MRR×12参考年化" : "平台MRR";
const values = sample.points.map((point) => point.value); const values = sample.points.map((point) => point.value);
@@ -41,16 +45,38 @@ export default function IncomeDistribution({ data, onSelect }: {
<label>显示单位<select value={annualize ? "annualized" : "mrr"} onChange={(event) => setAnnualize(event.target.value === "annualized")}><option value="mrr">MRR(美元/月)</option><option value="annualized">MRR×12参考年化(美元/年)</option></select></label> <label>显示单位<select value={annualize ? "annualized" : "mrr"} onChange={(event) => setAnnualize(event.target.value === "annualized")}><option value="mrr">MRR(美元/月)</option><option value="annualized">MRR×12参考年化(美元/年)</option></select></label>
</div> </div>
<div className="income-options"><label><input type="checkbox" checked={splitModels} onChange={(event) => setSplitModels(event.target.checked)} /> 按收费与交付模式细分</label><label><input type="checkbox" checked={logScale} onChange={(event) => setLogScale(event.target.checked)} /> 对数横轴</label></div> <div className="income-options"><label><input type="checkbox" checked={splitModels} onChange={(event) => setSplitModels(event.target.checked)} /> 按收费与交付模式细分</label><label><input type="checkbox" checked={logScale} onChange={(event) => setLogScale(event.target.checked)} /> 对数横轴</label></div>
<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> <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 ? <> {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> <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 ? "对数" : "线性"}横轴;圆点为产品,竖线为均值,菱形为中位数`}> <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>)} {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>)}
{groups.map((group, index) => { {groups.map((group, index) => {
const y = index * 70 + 40; const y = index * 70 + 40;
return <g key={group.key}> const firstInCategory = index === 0 || groups[index - 1].category !== group.category;
<text x={8} y={y - 8} fontSize="12" fill="#202724">{categories.get(group.category)}</text> const categoryRowCount = categoryGroups.find((entry) => entry.category === group.category)!.rows.length;
<text x={8} y={y + 10} fontSize="10" fill="#637066">{group.provider} · {group.model} · n={group.stats.n}</text> return <g key={group.key} role="group" aria-label={`${categories.get(group.category)},${group.provider},${group.model},${group.stats.n}个产品`}>
{firstInCategory && <>
{index > 0 && <line x1={8} x2={right} y1={y - 34} y2={y - 34} stroke="#dfe6e0" />}
<text x={8} y={y - 8} fontSize="12" fontWeight="600" fill="#202724">{categories.get(group.category)}({categoryRowCount}组)</text>
{categoryRowCount > 1 && <line x1={10} x2={10} y1={y + 3} y2={y + (categoryRowCount - 1) * 70 + 12} stroke="#95b4a6" strokeWidth={2} />}
</>}
<text x={20} y={y + 10} fontSize="10" fill="#637066">{group.provider} · {group.model} · n={group.stats.n}</text>
{group.stats.n < 3 && <text x={20} y={y + 26} fontSize="9" fill="#637066">{group.stats.n === 1 ? "仅1个产品,无法判断组内离散度" : "仅2个产品,不代表领域典型收入"}</text>}
<line x1={x(group.stats.min)} x2={x(group.stats.max)} y1={y} y2={y} stroke="#95b4a6" strokeWidth={2} /> <line x1={x(group.stats.min)} x2={x(group.stats.max)} y1={y} y2={y} stroke="#95b4a6" strokeWidth={2} />
<line x1={x(group.stats.mean)} x2={x(group.stats.mean)} y1={y - 17} y2={y + 17} stroke="#b17725" strokeWidth={3}><title>样本均值 {usd(group.stats.mean)}</title></line> <line x1={x(group.stats.mean)} x2={x(group.stats.mean)} y1={y - 17} y2={y + 17} stroke="#b17725" strokeWidth={3}><title>样本均值 {usd(group.stats.mean)}</title></line>
<path d={`M${x(group.stats.median)},${y - 7} l7,7 l-7,7 l-7,-7 Z`} fill="#8a78ad"><title>样本中位数 {usd(group.stats.median)}</title></path> <path d={`M${x(group.stats.median)},${y - 7} l7,7 l-7,7 l-7,-7 Z`} fill="#8a78ad"><title>样本中位数 {usd(group.stats.median)}</title></path>
@@ -62,12 +88,13 @@ export default function IncomeDistribution({ data, onSelect }: {
</g>; </g>;
})} })}
</svg></div> </svg></div>
<figcaption>圆点:产品观测 竖线:样本均值 菱形:样本中位数。按业务类别与支付来源分组,点击点或下表产品名可看原始证据。</figcaption> <figcaption>圆点:产品观测 竖线:样本均值 菱形:样本中位数。业务类别标题下各行按支付平台{splitModels && "及收费与交付模式"}分别统计;n为该组产品数。点击点或下表产品名可看原始证据。</figcaption>
</figure> </figure>
<div className="research-table-wrap"><table><caption>{metric}样本统计;金额按显示值计算,CV使用样本标准差</caption><thead><tr><th>类别 / 来源 / 模式</th><th>n</th><th>均值</th><th>中位数</th><th>均值/中位数</th><th>CV</th><th>去除最大值后的均值 / 下降幅度</th><th>产品与限制</th></tr></thead><tbody>{groups.map((group) => <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}>
<td>{categories.get(group.category)}<small>{group.provider} · {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> {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> <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>
</tr>)}</tbody></table></div> </tr>)}</tbody>)}</table></div>
</> : <p className="research-message">当前窗口和创立年份没有符合条件的 MRR 记录。</p>} </> : <p className="research-message">当前窗口和创立年份没有符合条件的 MRR 记录。</p>}
<details><summary>统计规则与解释</summary><p>每个产品只取符合条件的最新一条平台 MRR;币种为美元,要求有效数据日期、确定数值及支付来源。公司 ARR、估算、融资、目标和近30日收入均不进入这里。未知值不补零。</p><p>CV=样本标准差÷均值,标准差分母为 n−1;n=1 不算 CV。n=2 时均值必然等于中位数,不能据此认为均值有代表性。较大的均值/中位数差距、CV或移除最大值的变化,仅提示进一步核查样本构成。</p><p>支付商与平台的退款、试用、年付、币种及指标计算可能不同(<a href="https://trustmrr.com/faq" target="_blank" rel="noopener noreferrer">平台方法说明</a>)。不能将 MRR 与收款差异、单个高值或高离散度直接解释为财务造假。比较商业模式还需要毛利、获客成本、留存、人工交付和客户集中度。</p></details> <details><summary>统计规则与解释</summary><p>每个产品只取符合条件的最新一条平台 MRR;币种为美元,要求有效数据日期、确定数值及支付来源。公司 ARR、估算、融资、目标和近30日收入均不进入这里。未知值不补零。</p><p>CV=样本标准差÷均值,标准差分母为 n−1;n=1 不算 CV。n=2 时均值必然等于中位数,不能据此认为均值有代表性。较大的均值/中位数差距、CV或移除最大值的变化,仅提示进一步核查样本构成。</p><p>支付商与平台的退款、试用、年付、币种及指标计算可能不同(<a href="https://trustmrr.com/faq" target="_blank" rel="noopener noreferrer">平台方法说明</a>)。不能将 MRR 与收款差异、单个高值或高离散度直接解释为财务造假。比较商业模式还需要毛利、获客成本、留存、人工交付和客户集中度。</p></details>
{sample.excluded.length > 0 && <details><summary>未纳入的 MRR 记录({sample.excluded.length})</summary><ul>{sample.excluded.map(({ observation, reason }) => <li key={observation.id}><button onClick={() => onSelect(observation.company)}>{observation.name}</button>:{usd(observation.value)} / 月,{observation.observedAt ?? "日期未知"};{reason}</li>)}</ul></details>} {sample.excluded.length > 0 && <details><summary>未纳入的 MRR 记录({sample.excluded.length})</summary><ul>{sample.excluded.map(({ observation, reason }) => <li key={observation.id}><button onClick={() => onSelect(observation.company)}>{observation.name}</button>:{usd(observation.value)} / 月,{observation.observedAt ?? "日期未知"};{reason}</li>)}</ul></details>}
@@ -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>;
}
+48 -1
View File
@@ -1,4 +1,5 @@
export type ResearchCategory = { id: string; name: string; description: string }; export type ResearchCategory = { id: string; name: string; description: string };
export const businessModelReviewType = "商业模式初评";
export const technicalReviewLabels = { export const technicalReviewLabels = {
unreviewed: "技术证据尚未专项评审", unreviewed: "技术证据尚未专项评审",
description: "已读技术自述", description: "已读技术自述",
@@ -43,11 +44,24 @@ export type ResearchCoverage = {
otherOrMissingYear: number; unclearAI: number; dateConflicts: number; failedProfiles: number; otherOrMissingYear: number; unclearAI: number; dateConflicts: number; failedProfiles: number;
discoverySha256: string; discoverySha256: string;
}; };
export type ResearchCandidateBatch = {
id: string; sourceId: string; url: string; accessedAt: string; scope: string; complete: boolean;
records: {
id: string; name: string; url: string | null; categoryId: string;
kind: "product" | "company" | "promotion"; profileStatus: "readable" | "unavailable" | "archive-only";
status: "pending-year" | "pending-ai" | "linked" | "existing-outside-cohort" | "outside-cohort" | "promotion";
companyId?: string; evidenceIds?: string[]; note: string;
}[];
};
export type ResearchSnapshot = { export type ResearchSnapshot = {
version: string; asOf: string; scope: string; limitations: string[]; version: string; asOf: string; scope: string; limitations: string[];
categories: ResearchCategory[]; companies: ResearchCompany[]; categories: ResearchCategory[]; companies: ResearchCompany[];
observations: ResearchObservation[]; evidence: ResearchEvidence[]; observations: ResearchObservation[]; evidence: ResearchEvidence[];
reviews: ResearchReview[]; coverage?: ResearchCoverage; reviews: ResearchReview[]; coverage?: ResearchCoverage; candidateBatches?: ResearchCandidateBatch[];
directoryRechecks?: {
ycId: number; originalStatus: "ai-unclear" | "year-outside-or-missing" | "date-conflict";
companyId: string; reviewedAt: string; evidenceIds: string[]; reason: string;
}[];
}; };
export function evidenceUrl(value: string): string | undefined { export function evidenceUrl(value: string): string | undefined {
@@ -98,7 +112,40 @@ export function validateResearch(snapshot: ResearchSnapshot) {
!Number.isFinite(o.value)) !Number.isFinite(o.value))
throw new Error(`Invalid research observation: ${o.id}`); throw new Error(`Invalid research observation: ${o.id}`);
} }
unique((snapshot.candidateBatches ?? []).map((b) => b.id), "candidate batches");
unique((snapshot.candidateBatches ?? []).flatMap((b) => b.records.map((r) => r.id)), "candidates");
for (const batch of snapshot.candidateBatches ?? []) {
if (!/^S\d{2}$/.test(batch.sourceId) || !evidenceUrl(batch.url) || !batch.scope.trim() ||
typeof batch.complete !== "boolean" || !/^\d{4}-\d{2}-\d{2}$/.test(batch.accessedAt) ||
Number.isNaN(Date.parse(batch.accessedAt)) ||
new Date(batch.accessedAt).toISOString().slice(0, 10) !== batch.accessedAt ||
batch.accessedAt > snapshot.asOf)
throw new Error(`Invalid research candidate batch: ${batch.id}`);
for (const row of batch.records) {
const linked = ["linked", "existing-outside-cohort"].includes(row.status);
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", "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)) ||
(["outside-cohort", "pending-ai"].includes(row.status) && !row.evidenceIds?.length))
throw new Error(`Invalid research candidate: ${row.id}`);
}
}
const coverage = snapshot.coverage; const coverage = snapshot.coverage;
unique((snapshot.directoryRechecks ?? []).map((row) => String(row.ycId)), "directory rechecks");
for (const row of snapshot.directoryRechecks ?? []) {
if (!Number.isSafeInteger(row.ycId) || row.ycId <= 0 || !companies.has(row.companyId) ||
!["ai-unclear", "year-outside-or-missing", "date-conflict"].includes(row.originalStatus) ||
!/^\d{4}-\d{2}-\d{2}$/.test(row.reviewedAt) || Number.isNaN(Date.parse(row.reviewedAt)) ||
new Date(row.reviewedAt).toISOString().slice(0, 10) !== row.reviewedAt || row.reviewedAt > snapshot.asOf ||
!row.reason.trim() || !row.evidenceIds.length || row.evidenceIds.some((id) => !evidence.has(id)))
throw new Error(`Invalid directory recheck: ${row.ycId}`);
}
if (coverage && (coverage.profilesRead + coverage.failedProfiles !== coverage.candidateRecords || if (coverage && (coverage.profilesRead + coverage.failedProfiles !== coverage.candidateRecords ||
coverage.matchingProfiles !== coverage.existingProfiles + coverage.newProfiles || coverage.matchingProfiles !== coverage.existingProfiles + coverage.newProfiles ||
coverage.matchingProfiles + coverage.otherOrMissingYear + coverage.unclearAI + coverage.dateConflicts !== coverage.profilesRead || coverage.matchingProfiles + coverage.otherOrMissingYear + coverage.unclearAI + coverage.dateConflicts !== coverage.profilesRead ||
+11 -2
View File
@@ -1,5 +1,11 @@
import type { DocLink } from "@/lib/docs"; import type { DocLink } from "@/lib/docs";
// Keep superseded Endor pages addressable without offering them as current material.
export function catalogDocuments<T extends DocLink>(docs: T[]): T[] {
return docs.filter((doc) => doc.collection !== "Endor 文档")
.sort((a, b) => Number(b.collection === "Endorphin") - Number(a.collection === "Endorphin"));
}
export function collectionLabel(name: string) { export function collectionLabel(name: string) {
return name.replace(/ 文档$| 源码教程$|问题集$/g, ""); return name.replace(/ 文档$| 源码教程$|问题集$/g, "");
} }
@@ -15,10 +21,13 @@ export function collectionIcon(name?: string): "book" | "code" | "layers" | "nod
if (name?.includes("垃圾回收")) return "nodes"; if (name?.includes("垃圾回收")) return "nodes";
return "book"; return "book";
} }
export function collectionEntries(docs: DocLink[]) { export function collectionEntries(documents: DocLink[]) {
const docs = catalogDocuments(documents);
return [...new Set(docs.map((doc) => doc.collection))].map((name) => { return [...new Set(docs.map((doc) => doc.collection))].map((name) => {
const items = docs.filter((doc) => doc.collection === name); const items = docs.filter((doc) => doc.collection === name);
const entry = items.find((doc) => doc.slug.endsWith("/overview")) ?? items[0]; const entry = items.find((doc) => doc.slug.endsWith("/overview"))
?? (name === "Endorphin" ? items.find((doc) => doc.featured) : undefined)
?? items[0];
return { name, entry, count: items.length }; return { name, entry, count: items.length };
}); });
} }
+180 -19
View File
@@ -1,5 +1,10 @@
import { randomUUID, createHash } from "node:crypto"; import { randomUUID, createHash } from "node:crypto";
import { catalogDocuments } from "./collections";
import { openSiteDatabase } from "./site-database"; 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 = { export type DocLink = {
slug: string; sourceSlug: string; category: string; title: string; searchText?: string; slug: string; sourceSlug: string; category: string; title: string; searchText?: string;
@@ -9,6 +14,7 @@ export type DocLink = {
export type Doc = DocLink & { content: string }; export type Doc = DocLink & { content: string };
export type DocInput = Omit<Doc, "revision" | "searchText">; export type DocInput = Omit<Doc, "revision" | "searchText">;
export type DocumentExport = { format: "tabularium-documents-v1"; documents: Doc[] }; 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]+)*)*$/; export const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
function database() { function database() {
@@ -25,58 +31,131 @@ function database() {
document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL, 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) 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 ( CREATE TABLE IF NOT EXISTS document_imports (
digest TEXT PRIMARY KEY, document_count INTEGER NOT NULL, created_at TEXT NOT NULL digest TEXT PRIMARY KEY, document_count INTEGER NOT NULL, created_at TEXT NOT NULL
); );
`); `);
sectionSchema(db);
return db; return db;
} }
export function validateDocument(input: DocInput): void { 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) for (const key of ["slug", "sourceSlug"] as const)
if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw Error(`Invalid ${key}`); if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw new DocumentValidationError(`Invalid ${key}`);
if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw Error("Use a semantic public slug"); 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) 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" || if (typeof input.content !== "string" || typeof input.description !== "string" ||
typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw Error("Invalid document fields"); typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw new DocumentValidationError("Invalid document fields");
if (input.content.length > 2_000_000) throw Error("Document exceeds content limit"); if (input.content.length > 2_000_000) throw new DocumentValidationError("Document exceeds content limit");
const images = [ const images = [
...input.content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g), ...input.content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g),
...input.content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi), ...input.content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi),
]; ];
if (images.some(([, source]) => /\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source))) 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); validateDocument(input);
const existing = db.prepare("SELECT id, revision FROM documents WHERE source_slug=?") const existing = db.prepare("SELECT id, revision, payload FROM documents WHERE source_slug=?")
.get(input.sourceSlug) as { id: string; revision: number } | undefined; .get(input.sourceSlug) as { id: string; revision: number; payload: string } | undefined;
if ((existing?.revision ?? 0) !== expectedRevision) throw Error("Document revision conflict"); if ((existing?.revision ?? 0) !== expectedRevision) throw new DocumentConflictError("Document revision conflict");
const id = existing?.id ?? randomUUID(); 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, const doc: Doc = { slug: input.slug, sourceSlug: input.sourceSlug, category: input.category,
title: input.title, group: input.group, collection: input.collection, description: input.description, title: input.title, group: input.group, collection: input.collection, description: input.description,
draft: input.draft, featured: input.featured, content: input.content, revision: expectedRevision + 1 }; draft: input.draft, featured: input.featured, content: input.content, revision: expectedRevision + 1 };
const payload = JSON.stringify(doc); const payload = JSON.stringify(doc);
// Aliases remain reserved after renaming; collision rolls back the whole write. // Reserve both canonical and historical names before updating any rows.
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])) { for (const alias of new Set([doc.slug, doc.sourceSlug])) {
const prior = db.prepare("SELECT document_id FROM document_aliases WHERE alias=?") const prior = db.prepare("SELECT document_id FROM document_aliases WHERE alias=?")
.get(alias) as { document_id: string } | undefined; .get(alias) as { document_id: string } | undefined;
if (prior && prior.document_id !== id) throw Error(`Document alias collision: ${alias}`); if (prior && prior.document_id !== id) throw new DocumentConflictError(`Document alias collision: ${alias}`);
db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id);
} }
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 (?,?,?,?)") db.prepare("INSERT INTO document_revisions VALUES (?,?,?,?)")
.run(id, doc.revision, payload, new Date().toISOString()); .run(id, doc.revision, payload, new Date().toISOString());
persistSections(db, id, doc.revision, editedSections ?? reconcileSections(doc.content, previous));
return doc; 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 { 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(); const db = database();
try { try {
db.exec("BEGIN IMMEDIATE"); db.exec("BEGIN IMMEDIATE");
@@ -110,7 +189,7 @@ export async function readDoc(slug: string): Promise<Doc> {
} }
export async function listDocs(): Promise<DocLink[]> { export async function listDocs(): Promise<DocLink[]> {
return allDocuments().map(({ content: _, ...doc }) => { void _; return doc; }); return catalogDocuments(allDocuments()).map(({ content: _, ...doc }) => { void _; return doc; });
} }
// Explicit, atomic bootstrap only. Runtime reads never import repository data. // Explicit, atomic bootstrap only. Runtime reads never import repository data.
@@ -145,3 +224,85 @@ export function documentHistory(sourceSlug: string): Doc[] {
.map((row) => JSON.parse(row.payload) as Doc); .map((row) => JSON.parse(row.payload) as Doc);
} finally { db.close(); } } 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(); }
}
+2
View File
@@ -0,0 +1,2 @@
export class DocumentValidationError extends Error {}
export class DocumentConflictError extends Error {}
+39
View File
@@ -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();
}
}
+56
View File
@@ -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(); }
}
+144
View File
@@ -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 }));
}
+51
View File
@@ -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 };
}
+65
View File
@@ -1,6 +1,50 @@
import type { ResearchCompany, ResearchObservation, ResearchSnapshot } from "./ai-research"; import type { ResearchCompany, ResearchObservation, ResearchSnapshot } from "./ai-research";
export type IncomePoint = { company: ResearchCompany; observation: ResearchObservation; value: number }; 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 = { export type IncomeStats = {
n: number; mean: number; median: number; min: number; max: number; n: number; mean: number; median: number; min: number; max: number;
sampleDeviation: number | null; cv: number | null; meanMedianRatio: number | null; 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))! })); 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); }
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { catalogDocuments } from "@/lib/collections";
import { allDocuments } from "@/lib/docs"; import { allDocuments } from "@/lib/docs";
// The proxy authenticates this endpoint, including index content. // The proxy authenticates this endpoint, including index content.
@@ -9,7 +10,7 @@ export default async function handler(request: NextApiRequest, response: NextApi
return response.status(405).end(); return response.status(405).end();
} }
try { try {
const index = allDocuments().map(({ content, ...doc }) => ({ ...doc, searchText: content })); const index = catalogDocuments(allDocuments()).map(({ content, ...doc }) => ({ ...doc, searchText: content }));
return response.status(200).json(index); return response.status(200).json(index);
} catch { } catch {
return response.status(503).json({ error: "文档数据库暂时不可用" }); return response.status(503).json({ error: "文档数据库暂时不可用" });
+9 -4
View File
@@ -23,6 +23,10 @@ import { collectionEntries, collectionLabel } from "@/lib/collections";
import { Icon } from "@/components/Icon"; import { Icon } from "@/components/Icon";
import { headingsFor } from "@/lib/navigation"; 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[] }; type Props = { doc: Doc; docs: DocLink[] };
const AICompanyResearch = dynamic(() => import("@/components/AICompanyResearch")); const AICompanyResearch = dynamic(() => import("@/components/AICompanyResearch"));
export const getServerSideProps: GetServerSideProps<Props> = async ({ params, res, resolvedUrl }) => { export const getServerSideProps: GetServerSideProps<Props> = async ({ params, res, resolvedUrl }) => {
@@ -54,7 +58,8 @@ export default function DocPage({
const [active, setActive] = useState(""); const [active, setActive] = useState("");
const siblings = docs.filter((d) => d.collection === doc.collection); const siblings = docs.filter((d) => d.collection === doc.collection);
const collectionEntry = collectionEntries(docs).find((c) => c.name === doc.collection)!.entry; // Historical pages remain readable even when their collection is outside the catalog.
const collectionEntry = collectionEntries(docs).find((c) => c.name === doc.collection)?.entry ?? doc;
const index = siblings.findIndex((d) => d.slug === doc.slug); const index = siblings.findIndex((d) => d.slug === doc.slug);
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
@@ -120,14 +125,14 @@ export default function DocPage({
const slides = node?.children.flatMap((child) => const slides = node?.children.flatMap((child) =>
child.type === "element" && child.tagName === "img" && child.type === "element" && child.tagName === "img" &&
typeof child.properties.src === "string" && typeof child.properties.src === "string" &&
child.properties.src.startsWith("/figures/endor-") isZoomableFigure(child.properties.src)
? [{ source: child.properties.src, alt: String(child.properties.alt ?? "") }] ? [{ source: child.properties.src, alt: String(child.properties.alt ?? "") }]
: [], : [],
) ?? []; ) ?? [];
const onlyImages = node?.children.every((child) => const onlyImages = node?.children.every((child) =>
(child.type === "element" && child.tagName === "img" && (child.type === "element" && child.tagName === "img" &&
typeof child.properties.src === "string" && typeof child.properties.src === "string" &&
child.properties.src.startsWith("/figures/endor-")) || isZoomableFigure(child.properties.src)) ||
(child.type === "text" && child.value.trim() === ""), (child.type === "text" && child.value.trim() === ""),
); );
if (slides.length > 1 && onlyImages) return <FigureGallery slides={slides} />; if (slides.length > 1 && onlyImages) return <FigureGallery slides={slides} />;
@@ -141,7 +146,7 @@ export default function DocPage({
); );
}, },
img: ({ src, alt }) => img: ({ src, alt }) =>
typeof src === "string" && src.startsWith("/figures/endor-") ? ( typeof src === "string" && isZoomableFigure(src) ? (
<ArchitectureFigure source={src} alt={alt ?? "Endor 架构图"} /> <ArchitectureFigure source={src} alt={alt ?? "Endor 架构图"} />
) : ( ) : (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
+9
View File
@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth"; import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
import { authenticateDocumentKey } from "./lib/document-keys";
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname; const path = request.nextUrl.pathname;
const headers = { const headers = {
@@ -31,6 +32,14 @@ export function proxy(request: NextRequest) {
? authorization.slice(7) ? authorization.slice(7)
: undefined; : undefined;
const token = bearer ?? request.cookies.get(SESSION_COOKIE)?.value; 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 (validSession(token, config)) {
if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !bearer) { if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !bearer) {
try { try {
+7
View File
@@ -1473,6 +1473,13 @@ svg { flex-shrink: 0; }
.research-distribution { margin: 24px 0 38px; } .research-distribution { margin: 24px 0 38px; }
.research-distribution figure { margin: 20px 0; } .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 { display: block; width: 100%; min-width: 900px; height: auto; }
.income-distribution-chart [role="button"] { cursor: pointer; outline: none; } .income-distribution-chart [role="button"] { cursor: pointer; outline: none; }
.income-distribution-chart [role="button"]:focus circle:last-child { stroke: #b17725; stroke-width: 3; } .income-distribution-chart [role="button"]:focus circle:last-child { stroke: #b17725; stroke-width: 3; }
+258
View File
@@ -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: "![raw](/figures/original.png)" }, 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![raw](/figures/raw.png)\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
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+52 -28
View File
@@ -1,6 +1,6 @@
{ {
"sourceVersion": "2026-10-30-depth-4", "sourceVersion": "2026-11-01-source-closeout-1",
"asOf": "2026-10-30", "asOf": "2026-11-01",
"metric": "platform-mrr-usd", "metric": "platform-mrr-usd",
"sampleUnit": "product-or-business", "sampleUnit": "product-or-business",
"note": "独立Python复算参考值,仅验证样本计算;不是行业估计或财务审计。", "note": "独立Python复算参考值,仅验证样本计算;不是行业估计或财务审计。",
@@ -21,7 +21,9 @@
"MR011", "MR011",
"MR012", "MR012",
"MR015", "MR015",
"MR016" "MR016",
"MR018",
"MR019"
], ],
"groups": [ "groups": [
{ {
@@ -35,7 +37,7 @@
"MR016" "MR016"
], ],
"n": 4, "n": 4,
"mean": 34815.0, "mean": 34815,
"median": 36723.0, "median": 36723.0,
"sampleDeviation": 16334.54845820151, "sampleDeviation": 16334.54845820151,
"cv": 0.46918134304758036, "cv": 0.46918134304758036,
@@ -50,7 +52,7 @@
"MR001" "MR001"
], ],
"n": 1, "n": 1,
"mean": 53101.0, "mean": 53101,
"median": 53101, "median": 53101,
"sampleDeviation": null, "sampleDeviation": null,
"cv": null, "cv": null,
@@ -65,7 +67,7 @@
"MR002" "MR002"
], ],
"n": 1, "n": 1,
"mean": 62423.0, "mean": 62423,
"median": 62423, "median": 62423,
"sampleDeviation": null, "sampleDeviation": null,
"cv": null, "cv": null,
@@ -79,15 +81,17 @@
"products": [ "products": [
"MR003", "MR003",
"MR004", "MR004",
"MR005" "MR005",
"MR018",
"MR019"
], ],
"n": 3, "n": 5,
"mean": 41693.0, "mean": 51674.8,
"median": 49069, "median": 65381,
"sampleDeviation": 28111.375064197768, "sampleDeviation": 24138.82269705795,
"cv": 0.6742468775141575, "cv": 0.4671294847209461,
"meanWithoutLargest": 29849.0, "meanWithoutLargest": 47627.5,
"largestRemovalDrop": 0.2840764636749574 "largestRemovalDrop": 0.07832250923080501
}, },
{ {
"category": "C09", "category": "C09",
@@ -105,7 +109,7 @@
"median": 20483, "median": 20483,
"sampleDeviation": 11942.947278624319, "sampleDeviation": 11942.947278624319,
"cv": 0.4711370488466823, "cv": 0.4711370488466823,
"meanWithoutLargest": 20046.0, "meanWithoutLargest": 20046,
"largestRemovalDrop": 0.20920581320120557 "largestRemovalDrop": 0.20920581320120557
} }
] ]
@@ -127,9 +131,27 @@
"MR012", "MR012",
"MR015", "MR015",
"MR016", "MR016",
"AI016" "AI016",
"MR017",
"MR018",
"MR019"
], ],
"groups": [ "groups": [
{
"category": "C02",
"provider": "Stripe",
"model": "模型网关订阅与用量",
"products": [
"MR017"
],
"n": 1,
"mean": 78764,
"median": 78764,
"sampleDeviation": null,
"cv": null,
"meanWithoutLargest": null,
"largestRemovalDrop": null
},
{ {
"category": "C06", "category": "C06",
"provider": "Stripe", "provider": "Stripe",
@@ -146,7 +168,7 @@
"median": 40112, "median": 40112,
"sampleDeviation": 174786.78744916618, "sampleDeviation": 174786.78744916618,
"cv": 1.5505509613536426, "cv": 1.5505509613536426,
"meanWithoutLargest": 34815.0, "meanWithoutLargest": 34815,
"largestRemovalDrop": 0.6911526751687284 "largestRemovalDrop": 0.6911526751687284
}, },
{ {
@@ -157,7 +179,7 @@
"MR001" "MR001"
], ],
"n": 1, "n": 1,
"mean": 53101.0, "mean": 53101,
"median": 53101, "median": 53101,
"sampleDeviation": null, "sampleDeviation": null,
"cv": null, "cv": null,
@@ -172,7 +194,7 @@
"MR002" "MR002"
], ],
"n": 1, "n": 1,
"mean": 62423.0, "mean": 62423,
"median": 62423, "median": 62423,
"sampleDeviation": null, "sampleDeviation": null,
"cv": null, "cv": null,
@@ -186,15 +208,17 @@
"products": [ "products": [
"MR003", "MR003",
"MR004", "MR004",
"MR005" "MR005",
"MR018",
"MR019"
], ],
"n": 3, "n": 5,
"mean": 41693.0, "mean": 51674.8,
"median": 49069, "median": 65381,
"sampleDeviation": 28111.375064197768, "sampleDeviation": 24138.82269705795,
"cv": 0.6742468775141575, "cv": 0.4671294847209461,
"meanWithoutLargest": 29849.0, "meanWithoutLargest": 47627.5,
"largestRemovalDrop": 0.2840764636749574 "largestRemovalDrop": 0.07832250923080501
}, },
{ {
"category": "C09", "category": "C09",
@@ -212,7 +236,7 @@
"median": 20483, "median": 20483,
"sampleDeviation": 11942.947278624319, "sampleDeviation": 11942.947278624319,
"cv": 0.4711370488466823, "cv": 0.4711370488466823,
"meanWithoutLargest": 20046.0, "meanWithoutLargest": 20046,
"largestRemovalDrop": 0.20920581320120557 "largestRemovalDrop": 0.20920581320120557
} }
] ]
+3 -3
View File
@@ -28,6 +28,9 @@ importers:
remark-gfm: remark-gfm:
specifier: 4.0.1 specifier: 4.0.1
version: 4.0.1(supports-color@7.2.0) version: 4.0.1(supports-color@7.2.0)
sharp:
specifier: 0.35.5
version: 0.35.5(@types/node@22.20.5)
devDependencies: devDependencies:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: 4.3.3 specifier: 4.3.3
@@ -59,9 +62,6 @@ importers:
postcss: postcss:
specifier: 8.5.31 specifier: 8.5.31
version: 8.5.31 version: 8.5.31
sharp:
specifier: 0.35.5
version: 0.35.5(@types/node@22.20.5)
tailwindcss: tailwindcss:
specifier: 4.3.3 specifier: 4.3.3
version: 4.3.3 version: 4.3.3
+298 -5
View File
@@ -4,16 +4,296 @@ from collections import Counter
import json import json
from pathlib import Path from pathlib import Path
import sys import sys
from urllib.parse import urlsplit from urllib.parse import parse_qs, urlsplit
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
DATA = json.loads((ROOT / 'content/research/ai-companies.seed.json').read_text()) DATA = json.loads((ROOT / 'content/research/ai-companies.seed.json').read_text())
AUDIT = json.loads((ROOT / 'content/research/ai-companies.coverage.json').read_text()) AUDIT = json.loads((ROOT / 'content/research/ai-companies.coverage.json').read_text())
SOURCES = json.loads((ROOT / 'content/research/ai-companies.sources.json').read_text())
TARGET = ROOT / '.artifacts/reports/02-coverage-and-gaps.md' TARGET = ROOT / '.artifacts/reports/02-coverage-and-gaps.md'
SOURCE_SLUG = 'ai-company-research/02-coverage-and-gaps' SOURCE_SLUG = 'ai-company-research/02-coverage-and-gaps'
BASE = '/docs/other-research/ai-company-research/overview' 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']
ids = {s['id'] for s in sources}
assert len(ids) == len(sources) and ids == {f'S{i:02}' for i in range(1, 17)}
assert SOURCES['scopeConfirmedAt'] <= DATA['asOf']
host_map = {}
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']:
assert host not in host_map, f'Duplicate source host: {host}'
host_map[host] = source['id']
assigned = {key: [] for key in ids}
for evidence in DATA['evidence']:
owner = host_map.get(urlsplit(evidence['url']).netloc)
if owner is None:
owner = 'S12' if any(word in evidence['kind'] for word in ('官方', '公司自述', '公司披露')) else 'S16'
assigned[owner].append(evidence)
assert sum(len(v) for v in assigned.values()) == len(DATA['evidence'])
statuses = {'candidate-audit': '候选池逐条已读', 'targeted': '定向使用,未穷尽', 'pending': '候选台账待建',
'context': '背景研究', 'access-limited': '访问受限', 'per-company': '随公司逐条核对'}
lines = ['', '## 来源清单与验收边界', '', SOURCES['provenance'], '',
'每条证据在下表只计入一个渠道:先按所列域名归属,再将类型明确标为官方/公司自述/公司披露的剩余项计入S12,其余计入S16。公司可跨渠道出现,所以公司数不可跨行相加。零条证据表示尚未进入研究表,不表示该来源没有相关公司。背景报告不承担公司发现全集的角色。', '',
'| 来源 | 当前状态 | 证据 / 公司 / 观测数 | 已核对范围 | 剩余核查 |', '| --- | --- | --- | --- | --- |']
for source in sources:
evidence = assigned[source['id']]
evidence_ids = {e['id'] for e in evidence}
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']
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
def context_methods():
evidence = {e['id']: e for e in DATA['evidence']}
rows = [s for s in SOURCES['sources'] if s.get('methodology')]
assert {s['id'] for s in rows} == {'S06', 'S07', 'S08', 'S09'}
lines = ['', '## 背景基准适用性与缺项', '',
'以下核对背景资料的方法及适用范围,不是公司名录的完整覆盖。原始方法记录与全局核查随研究版本进入同一数据库;没有把行业汇总值新增为任何公司的收入观测。', '',
'| 来源与证据 | 样本与统计单位 | 时间 | 本次读取范围 | 可用于什么 | 主要限制 |',
'| --- | --- | --- | --- | --- | --- |']
for source in rows:
m = source['methodology']
assert m['checkedAt'] <= DATA['asOf']
assert all(m.get(key) for key in ('sample', 'unit', 'period', 'access', 'use', 'limit', 'evidenceIds'))
assert all(eid in evidence and evidence[eid]['companyId'] is None for eid in m['evidenceIds'])
assert not any(o['evidence'] in m['evidenceIds'] for o in DATA['observations'])
proof = '、'.join(f"[{eid}]({evidence[eid]['url']})" for eid in m['evidenceIds'])
lines.append(f"| {source['id']} {source['name']}:{proof} | {m['sample']};{m['unit']} | {m['period']} | {m['access']} | {m['use']} | {m['limit']} |")
lines += ['', '本表的应用解释属于研究判断。选样、分母、时间或指标不同会造成表观差异;这些差异不单独证明财务造假,也不能作为给未知公司填补收入的依据。', '']
return lines
def candidate_register():
batches = DATA.get('candidateBatches', [])
companies = {c['id']: c for c in DATA['companies']}
categories = {c['id']: c['name'] for c in DATA['categories']}
known_sources = {s['id'] for s in SOURCES['sources']}
evidence = {e['id']: e for e in DATA['evidence']}
assert len({b['id'] for b in batches}) == len(batches)
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': '成立年待核', 'pending-ai': 'AI主营待核', 'linked': '已关联公司',
'existing-outside-cohort': '已有关联,年份范围外',
'outside-cohort': '范围外或既有业务,不新增公司', 'promotion': '推广活动,非公司新增'}
for batch in batches:
assert batch['sourceId'] in known_sources
assert batch['accessedAt'] <= DATA['asOf']
assert any(e['url'] == batch['url'] for e in DATA['evidence'])
rows = batch['records']
counts = Counter(r['status'] for r in rows)
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['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'] 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 = '—'
if row.get('companyId'):
assert row['companyId'] in companies
c = companies[row['companyId']]
company = f"[{c['name']}]({BASE}?company={c['id']}#research-company-detail)"
proof = '、'.join(f"[{eid}]({evidence[eid]['url']})" for eid in row.get('evidenceIds', [])) or '成立证据待补,产品页读取状态见候选记录'
category = f"[{categories[row['categoryId']]}]({BASE}?category={row['categoryId']})"
lines.append(f"| {name} | {category} | {statuses[row['status']]} | {company} | {proof} | {row['note']} |")
if batch['sourceId'] == 'S04':
lines += ['', '产品页可读只表示有可访问的资料;本批未将所有产品页完成公司、技术或财务深查。既有直接HTTP采集返回403,当前采用可读的公开页面响应,不声称取得底层全量列表;后续公开档案与未读页面继续保留缺项。', '']
else:
lines += ['', '发现条目和可读正文分开记录;登录外壳、读取失败或搜索摘录不充当已阅读全文。关联公司以列出的原始材料补证,仍保留技术、主体与收入未核事项。', '']
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:
return []
original = {row['ycId']: row for row in AUDIT['records']}
companies = {c['id']: c for c in DATA['companies']}
evidence = {e['id']: e for e in DATA['evidence']}
assert len({row['ycId'] for row in rows}) == len(rows)
lines = ['', '## 原候选池的后续复核', '',
'原始1,428条读取快照与初筛计数保持不变;下表记录当时未入表、后续补足证据后进入公司表的项目。初筛未通过不是永久排除。后续入表不回写为当时已匹配,也不重复计入928条原始新增目录项。', '',
'| 原候选 | 原始状态 | 后续公司记录 | 复核证据 | 处理与限制 |', '| --- | --- | --- | --- | --- |']
for row in rows:
baseline = original[row['ycId']]
assert baseline['status'] == row['originalStatus']
assert row['reviewedAt'] <= DATA['asOf'] and row['reason']
assert row['originalStatus'] in {'ai-unclear', 'year-outside-or-missing', 'date-conflict'}
company = companies[row['companyId']]
assert row['evidenceIds'] and all(eid in evidence for eid in row['evidenceIds'])
proof = '、'.join(f"[{eid}]({evidence[eid]['url']})" for eid in row['evidenceIds'])
lines.append(f"| [{baseline['name']} · YC{row['ycId']}]({baseline['source']}) | {row['originalStatus']} | [{company['name']}]({BASE}?company={company['id']}#research-company-detail) | {proof} | {row['reviewedAt']}:{row['reason']} |")
lines += ['', f"已记录{len(rows)}条后续复核入表;其余未入表候选仍按原状态保留缺项,不能把这一批复核视为完成全部模糊项。", '']
return lines
def render(): def render():
cov = DATA['coverage'] cov = DATA['coverage']
companies = DATA['companies'] companies = DATA['companies']
@@ -36,6 +316,11 @@ def render():
'year-outside-or-missing': cov['otherOrMissingYear'], 'year-outside-or-missing': cov['otherOrMissingYear'],
'ai-unclear': cov['unclearAI'], 'date-conflict': cov['dateConflicts']} 'ai-unclear': cov['unclearAI'], 'date-conflict': cov['dateConflicts']}
assert len({x['ycId'] for x in AUDIT['records']}) == len(AUDIT['records']) 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 公司资料覆盖与缺项', '', lines = ['# AI 公司资料覆盖与缺项', '',
f"截至 {DATA['asOf']},研究表含{len(companies):,}条公司或产品记录。YC候选池有逐条读取审计;其他来源为定向补充。清单尚不能作为全球AI公司普查,也不能将收入未知解释为零。", '', f"截至 {DATA['asOf']},研究表含{len(companies):,}条公司或产品记录。YC候选池有逐条读取审计;其他来源为定向补充。清单尚不能作为全球AI公司普查,也不能将收入未知解释为零。", '',
'验收范围已于2026-10-30确认为:以确认的公开来源清单为边界,逐条核对并标明缺项。下方区分已逐条审计的候选池与定向补充来源;已使用某来源不代表该来源已被完整覆盖。', '', '验收范围已于2026-10-30确认为:以确认的公开来源清单为边界,逐条核对并标明缺项。下方区分已逐条审计的候选池与定向补充来源;已使用某来源不代表该来源已被完整覆盖。', '',
@@ -47,7 +332,8 @@ def render():
f"| 初筛匹配 | {cov['matchingProfiles']:,} | 成立年及AI相关描述通过初筛,尚非逐家商业验证 |", f"| 初筛匹配 | {cov['matchingProfiles']:,} | 成立年及AI相关描述通过初筛,尚非逐家商业验证 |",
f"| 与原表重合 | {cov['existingProfiles']} | URL匹配,未再次新增 |", f"| 与原表重合 | {cov['existingProfiles']} | URL匹配,未再次新增 |",
f"| 新增目录记录 | {cov['newProfiles']:,} | 原始入表来源,后续深化不改变发现计数 |", f"| 新增目录记录 | {cov['newProfiles']:,} | 原始入表来源,后续深化不改变发现计数 |",
f"| 年份不符或缺失 | {cov['otherOrMissingYear']} | 此轮不作为成立年匹配项 |", f"| 原目录列为范围外年份 | {sum(outside_years.values())} | {year_detail};原快照自述,不是独立登记核验 |",
f"| 原目录未给成立年 | {excluded_years[None]} | 与已列范围外年份分开,仍待补证 |",
f"| AI业务待确认 | {cov['unclearAI']} | 不强行把背景关键词当主营 |", f"| AI业务待确认 | {cov['unclearAI']} | 不强行把背景关键词当主营 |",
f"| 成立年与批次冲突 | {cov['dateConflicts']} | 另保留核查记录 |", '', f"| 成立年与批次冲突 | {cov['dateConflicts']} | 另保留核查记录 |", '',
f"全表由{depth}条目录基础项和{len(companies)-depth}条个案或平台记录组成。候选池已匹配项之外另有{len(companies)-cov['matchingProfiles']}条记录,包含其他来源及发布/转型对照;其中已有{promoted}条原目录记录深化为个案,仍保留目录来源。两种统计边界不同。候选池数字只是该镜像快照的覆盖,Bud等后续定向补充不回填为原候选池已覆盖。", '', f"全表由{depth}条目录基础项和{len(companies)-depth}条个案或平台记录组成。候选池已匹配项之外另有{len(companies)-cov['matchingProfiles']}条记录,包含其他来源及发布/转型对照;其中已有{promoted}条原目录记录深化为个案,仍保留目录来源。两种统计边界不同。候选池数字只是该镜像快照的覆盖,Bud等后续定向补充不回填为原候选池已覆盖。", '',
@@ -60,8 +346,8 @@ def render():
f"| 其他目录基础项 | {depth-reviewed} | 规则初分或资料不足,继续待复核 |", f"| 其他目录基础项 | {depth-reviewed} | 规则初分或资料不足,继续待复核 |",
f"| 有团队数字 | {sum(c['team'] is not None for c in companies)} | 多为自报Team Size或Employees,时点与口径不统一 |", 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"| 专项技术证据记录 | {len(companies)-technical['unreviewed']} | 其中{technical['description']}条自述、{technical['public-artifact']}条公开材料、{technical['independent-test']}条第三方评测 |",
f"| 公司披露收入线索 | {income['self']} | 含历史、下界、币种不明及产品业务声明,不等于可比ARR |", f"| 企业披露收入线索 | {income['self']} | 含公司或交易当事方披露、历史、下界及币种不明,不等于可比ARR |",
f"| 仅平台收入线索 | {income['platform']} | 同时有公司披露者计入上一行,不重复计数 |", f"| 仅平台收入线索 | {income['platform']} | 同时有企业披露者计入上一行,不重复计数 |",
f"| 仅第三方估算 | {income['estimate']} | 不与直接披露混合 |", f"| 仅第三方估算 | {income['estimate']} | 不与直接披露混合 |",
f"| 暂无可用收入数字 | {income['unknown']} | 包含尚未逐家搜索的目录项,不代表没有收入 |", '', f"| 暂无可用收入数字 | {income['unknown']} | 包含尚未逐家搜索的目录项,不代表没有收入 |", '',
'## 业务分类与数据入口', '', '## 业务分类与数据入口', '',
@@ -86,9 +372,16 @@ def render():
lines.append(f"| {name} | {len(entries)} | {len({e['url'] for e in entries})} | {note} |") 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] 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.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 += ['', '## 尚未满足的研究条件', '', lines += ['', '## 尚未满足的研究条件', '',
'行业ARR比较仍缺统一业务截止日、币种、经常性定义和业务边界。现有平台MRR可描述已采集产品,不足以代表全部新公司。技术评测只覆盖所列版本与任务,不宜跨领域汇总为统一强弱分数。', '', '行业ARR比较仍缺统一业务截止日、币种、经常性定义和业务边界。现有平台MRR可描述已采集产品,不足以代表全部新公司。技术评测只覆盖所列版本与任务,不宜跨领域汇总为统一强弱分数。', '',
'下一轮优先补收入线索的日期及合同定义、非YC和非英文来源、规则初分的人工复核,以及停止服务或被收购案例。这样能减少“只见赢家”和“只见融资公司”的偏差。公开来源池之外的未披露公司仍无法证明已被穷尽。', '', '下一轮优先补收入线索的日期及合同定义、成立年冲突、AI主营仍待核项、非YC和非英文来源,以及停止服务或被收购案例。这样能减少“只见赢家”和“只见融资公司”的偏差。公开来源池之外的未披露公司仍无法证明已被穷尽。', '',
] ]
return '\n'.join(lines) return '\n'.join(lines)
+2 -2
View File
@@ -3,7 +3,7 @@
set -eu set -eu
cd /opt/endorphin-tabularium-docs/current cd /opt/endorphin-tabularium-docs/current
case "${1:-}" in 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 "$@" ;; 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 esac
+210
View File
@@ -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()
+6 -4
View File
@@ -7,14 +7,15 @@ import re
import subprocess import subprocess
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
BASELINE = '25c5e83d08eae4277fe429a692955bd3bb2e717f' # Last main revision before database-backed documents. BASELINE = 'cffca6942b1bc0005321e1685f38593fb0b853d5' # Integration snapshot before database migration.
RESEARCH_REVISION = '74af205b2959e1bb6a1fe9f567bc87069ac6d12c' # Final PR 14 update received during integration.
def export(ref): def export(ref):
commit = subprocess.check_output(['git', 'rev-parse', '--verify', ref + '^{commit}'], cwd=ROOT, text=True).strip() commit = subprocess.check_output(['git', 'rev-parse', '--verify', ref + '^{commit}'], cwd=ROOT, text=True).strip()
def read(name): def read(name, revision=commit):
return subprocess.check_output(['git', 'show', f'{commit}:{name}'], cwd=ROOT).decode() return subprocess.check_output(['git', 'show', f'{revision}:{name}'], cwd=ROOT).decode()
catalog = json.loads(read('content/catalog.json')) catalog = json.loads(read('content/catalog.json'))
names = subprocess.check_output(['git', 'ls-tree', '-r', '--name-only', commit, '--', 'content/docs'], cwd=ROOT, text=True).splitlines() names = subprocess.check_output(['git', 'ls-tree', '-r', '--name-only', commit, '--', 'content/docs'], cwd=ROOT, text=True).splitlines()
@@ -23,7 +24,8 @@ def export(ref):
source = name.removeprefix('content/docs/').removesuffix('.md') source = name.removeprefix('content/docs/').removesuffix('.md')
metadata = catalog['documents'][source] metadata = catalog['documents'][source]
collection = next(c for c in catalog['collections'] if not c['path'] or source.startswith(c['path'] + '/')) collection = next(c for c in catalog['collections'] if not c['path'] or source.startswith(c['path'] + '/'))
content = read(name) revision = RESEARCH_REVISION if commit == BASELINE and source.startswith("ai-company-research/") else commit
content = read(name, revision)
title = re.search(r'^# (.+)$', content, re.M) title = re.search(r'^# (.+)$', content, re.M)
documents.append(dict(slug=metadata['slug'], sourceSlug=source, category=metadata['category'], documents.append(dict(slug=metadata['slug'], sourceSlug=source, category=metadata['category'],
title=title[1].strip() if title else source, content=content, title=title[1].strip() if title else source, content=content,
+22 -3
View File
@@ -89,23 +89,42 @@ def link(cid):
def render(): def render():
points, excluded = sample(7) 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 = [ lines = [
'# AI 公司收入样本分布快照', '', '# 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']):,}条公司/产品记录的收入普查。", '', f"这是已采集资料的固定快照。[动态工作台]({BASE})提供筛选与原始证据。窗口按最后同步日计算,不保证所有点同日,也不是{len(DATA['companies']):,}条公司/产品记录的收入普查。", '',
'## 7日窗口的分组结果', '', '## 7日窗口的分组结果', '',
'MRR单位为美元每月。CV为样本标准差除以均值;最大值剔除降幅表示移除最高收入产品后均值下降的比例。单个样本不计算离散度。', '', 'MRR单位为美元每月。CV为样本标准差除以均值;最大值剔除降幅表示移除最高收入产品后均值下降的比例。单个样本不计算离散度。', '',
'| 业务及收费模式 | 平台 | 样本数 | 均值 | 中位数 | CV | 最大值剔除降幅 |', '| 业务及收费模式 | 平台 | 样本数 | 均值 | 中位数 | CV | 最大值剔除降幅 |',
'| --- | --- | --- | --- | --- | --- | --- |', '| --- | --- | --- | --- | --- | --- | --- |',
] ]
for key, values in sorted(groups(points).items()): for key, values in sorted(grouped.items()):
cat, provider, model = key cat, provider, model = key
v = stats(values) v = stats(values)
cv = f"{v['cv']:.3f}" if v['cv'] is not None else '不计算' cv = f"{v['cv']:.3f}" if v['cv'] is not None else '不计算'
drop = f"{v['largestRemovalDrop']:.1%}" if v['largestRemovalDrop'] 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.append(f"| {CATEGORIES[cat]} · {model} | {provider} | {v['n']} | {money(v['mean'])} | {money(v['median'])} | {cv} | {drop} |")
lines += ['', lines += ['',
'在至少3个样本的组内,内容创意软件订阅的均值较高,但移除最大样本后的下降也最大。营销软件与个人应用的CV仅有微小差别,不据此宣称哪个领域更稳定。两个内容类单样本组虽有更高数值,不能作为典型收入或领域排名。', '', interpretation, '',
'单个样本不计算离散度,也不能作为典型收入或领域排名;不同收费模式和支付平台的分组保留各自含义。', '',
'均值和中位数接近只描述当前采集点,并不证明样本具有代表性。平台公开者、成立年、规模、获客渠道与收费方式均可能影响结果。', '', '均值和中位数接近只描述当前采集点,并不证明样本具有代表性。平台公开者、成立年、规模、获客渠道与收费方式均可能影响结果。', '',
'## 样本与证据入口', '', '## 样本与证据入口', '',
'点击产品名称可打开公司明细,再查看平台记录及其限制。连接已过期的近期快照仍按所标日期保留,并不表示连接当前有效。', '', '点击产品名称可打开公司明细,再查看平台记录及其限制。连接已过期的近期快照仍按所标日期保留,并不表示连接当前有效。', '',
+110
View File
@@ -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&section=preamble&section=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()