From 0e5ccafa3027013ffb0dcfa553ee541a4797bbd2 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 17:21:06 +0800 Subject: [PATCH 01/10] Classify remaining directory profiles using official business evidence --- apps/docs/scripts/auth-cli.ts | 2 +- apps/docs/tests/research.test.ts | 4 +- content/research/ai-companies.seed.json | 182 +++++++++++++++++---- content/research/ai-companies.sources.json | 4 +- content/research/ai-income-analysis.json | 2 +- 5 files changed, 160 insertions(+), 34 deletions(-) diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts index 9c7e393..bc562dc 100644 --- a/apps/docs/scripts/auth-cli.ts +++ b/apps/docs/scripts/auth-cli.ts @@ -183,7 +183,7 @@ async function main() { const research = await researchResponse.json(); assert.equal(research.companies.length, 1021); assert.equal(research.categories.length, 11); - assert.equal(research.evidence.length, 1215); + assert.equal(research.evidence.length, 1221); assert.equal(research.observations.length, 189); 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 }); diff --git a/apps/docs/tests/research.test.ts b/apps/docs/tests/research.test.ts index fdb55bc..1f2c177 100644 --- a/apps/docs/tests/research.test.ts +++ b/apps/docs/tests/research.test.ts @@ -24,7 +24,7 @@ test("research revisions share the authentication database and preserve replay l assert.equal(consumeCode(config, "invalid"), "limited"); const initial = readResearchSnapshot(); assert.equal(initial.companies.length, 1021); - assert.equal(initial.evidence.length, 1215); + assert.equal(initial.evidence.length, 1221); assert.equal(initial.observations.length, 189); assert.deepEqual(initial.candidateBatches, seed.candidateBatches, "candidate gaps persist in the same database metadata"); assert.deepEqual(initial.directoryRechecks, seed.directoryRechecks, "follow-up classifications persist without rewriting the discovery audit"); @@ -64,7 +64,7 @@ test("research validation rejects broken evidence and unsafe source links", () = test("reviews link explicit companies without turning global or excluded cases into companies", () => { const snapshot = structuredClone(seed) as ResearchSnapshot; - assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 54); + assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 57); assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 8); assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 16); const review = snapshot.reviews.find((r) => r.id === "R4")!; diff --git a/content/research/ai-companies.seed.json b/content/research/ai-companies.seed.json index 13d767f..ce57355 100644 --- a/content/research/ai-companies.seed.json +++ b/content/research/ai-companies.seed.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-ph-history-1", + "version": "2026-10-30-category-1", "asOf": "2026-10-30", "scope": "全球公开资料研究:保留首批个案,并扩展核查2025—2026八个YC批次候选池;成立年份以官方公司页面字段为依据。", "limitations": [ @@ -7505,28 +7505,35 @@ "id": "YC30686", "name": "OneStop", "url": "https://www.ycombinator.com/companies/onestop", - "cat": "C00", + "cat": "C09", "year": 2025, "team": 2, "location": "New York City, NY", - "business": "公司简介:The local internet for NYC.", - "buyer": "本轮未逐一核验", - "delivery": "公开YC公司资料;交付与付费使用待核验", - "notes": "目录基础资料;AI相关描述与业务分类均待进一步人工复核。收入、定价和技术能力未逐一核验。", + "business": "本地活动与社交计划平台;AI辅助搜索、信息流排序和发帖。", + "buyer": "纽约本地成年用户;收费买方未披露", + "delivery": "官网有候补登记及即将上架提示,条款描述发帖、回复和聊天;实际可用范围未独立测试。", + "notes": "AI具体用途由当前条款补证;公司及团队年份仍以YC自述为准。免费不等公司收入为零,活动卡片不当真实采用。", "cohort": "2025年成立", "teamBasis": "YC官方页面Team Size;2026-10-30访问快照,业务截至日未注明", - "price": "本轮未逐一核验", - "stage": "目录公开,商业成熟度待核验", + "price": "官方条款称当前免费、无订阅及内购,不处理用户间支付;未来变现未知", + "stage": "官网及条款可读,公开可用性和付费使用待核", "sourceType": "YC官方页面中的公司自述", "evidenceIds": [ - "YC-E-30686" + "YC-E-30686", + "CLASS-ONESTOP-HOME", + "CLASS-ONESTOP-MISSION", + "CLASS-ONESTOP-TERMS" ], "revenueStatus": "unknown", "profileOnly": true, - "classificationBasis": "2026-10-30复核后保留待分类:目前只说明纽约本地分类信息网络,未说明AI具体作用或主要客户。", + "classificationBasis": "2026-10-30人工复核:按个人附近活动发现、发帖与聊天归C09,AI辅助搜索/排序/写作据官方条款;不按创始人履历归企业AI转型。", "directoryTags": [ "AI" - ] + ], + "extraUrl": "https://onestop.so/", + "pricingEvidence": "CLASS-ONESTOP-TERMS", + "businessModel": "消费者本地社交服务;当前免费,未来收费及广告等变现未披露", + "entityBasis": "YC关联域名跳转现行官网,Mission创始人可关联;条款列Preempt Technologies, Inc.,精确注册及法律沿革未另核" }, { "id": "YC30687", @@ -7974,28 +7981,31 @@ "id": "YC30707", "name": "Phases", "url": "https://www.ycombinator.com/companies/phases", - "cat": "C00", + "cat": "C10", "year": 2025, "team": 3, "location": "San Francisco", - "business": "公司简介:AI for the physical world", - "buyer": "本轮未逐一核验", - "delivery": "公开YC公司资料;交付与付费使用待核验", - "notes": "目录基础资料;AI相关描述与业务分类均待进一步人工复核。收入、定价和技术能力未逐一核验。", + "business": "面向前沿机器人实验室的解决方案;产品细分及部署方式未公开。", + "buyer": "机器人实验室与研发团队(官网目标用户),实际合同买方待核", + "delivery": "公开官网只有方向和演示预约;未独立确认机器人硬件、模型、数据产品或部署。", + "notes": "官网可补机器人领域和目标用户,尚不足以确定产品细分、技术水平或采用;创始人既往机器人研究经历不作为公司能力证明。", "cohort": "2025年成立", "teamBasis": "YC官方页面Team Size;2026-10-30访问快照,业务截至日未注明", "price": "本轮未逐一核验", "stage": "目录公开,商业成熟度待核验", "sourceType": "YC官方页面中的公司自述", "evidenceIds": [ - "YC-E-30707" + "YC-E-30707", + "CLASS-PHASES-HOME" ], "revenueStatus": "unknown", "profileOnly": true, - "classificationBasis": "2026-10-30复核后保留待分类:公开简介只有面向物理世界的AI,无法确定具体产品与买家。", + "classificationBasis": "2026-10-30人工复核:官网明确frontier robotics labs,按机器人应用领域粗分C10;不据笼统物理世界AI强行认定基础模型、训练平台或硬件产品。", "directoryTags": [ "AI" - ] + ], + "extraUrl": "https://phases.ai/", + "businessModel": "机器人研发解决方案;计价与软件/数据/服务拆分未公开" }, { "id": "YC30709", @@ -12208,31 +12218,36 @@ "id": "YC30995", "name": "Velvet", "url": "https://www.ycombinator.com/companies/velvet", - "cat": "C00", + "cat": "C02", "year": 2025, "team": 3, "location": "San Francisco", - "business": "公司简介:Expressive conversational AI", - "buyer": "本轮未逐一核验", - "delivery": "公开YC公司资料;交付与付费使用待核验", - "notes": "目录基础资料;AI相关描述与业务分类均待进一步人工复核。收入、定价和技术能力未逐一核验。", + "business": "为交互及视听对话模型提供数据集、内容交付与模型合作。", + "buyer": "AI实验室及训练或部署交互模型的消费者公司(官网目标客户)", + "delivery": "公开样本提交与合作入口,条款说明内容任务及许可;实际数据交付和模型性能未独立核验。", + "notes": "官网补足训练数据方向;未将交互模型宣传等同自研基础模型发布。页面模板中的收益$0.00不作为公司收入,创作者任务报酬与公司收入分开。", "cohort": "2025年成立", "teamBasis": "YC官方页面Team Size;2026-10-30访问快照,业务截至日未注明", - "price": "本轮未逐一核验", + "price": "数据、内容任务及合作合同询价;公开条款未列客户统一价格", "stage": "目录公开,商业成熟度待核验", "sourceType": "YC官方页面中的公司自述", "evidenceIds": [ - "YC-E-30995" + "YC-E-30995", + "CLASS-VELVET-HOME", + "CLASS-VELVET-TERMS" ], "revenueStatus": "unknown", "profileOnly": true, - "classificationBasis": "2026-10-30复核后保留待分类:公开简介只有富有表现力的对话AI,无法区分基础模型、开发设施或消费者产品。", + "classificationBasis": "2026-10-30人工复核:按面向模型团队的数据集与训练合作归C02;对话表现是任务方向,不能仅据模型一词归为自研基础模型或消费者聊天应用。", "directoryTags": [ "Generative AI", "Data Engineering", "AI", "Conversational AI" - ] + ], + "extraUrl": "https://velvetlab.ai/", + "businessModel": "训练数据/内容许可与定制交付、模型合作;收入确认、净额/总额及经常性待核", + "entityBasis": "YC指向velvetlab.ai;官网页脚和其链接条款均列Velvet Media, Inc.,条款含velvet.video;不与其他同名Velvet业务合并" }, { "id": "YC30996", @@ -45287,6 +45302,72 @@ "kind": "公司官方材料", "summary": "条款列14天试用、订阅及按月事件额度,事件包括浏览、支付和自定义目标;年付也按月计量额度。", "limitation": "政策生效日不当成立年;被跟踪网站的支付/收入事件是客户数据,不是DataFast自身收入;创作者历史总业绩不转为该产品ARR。" + }, + { + "id": "CLASS-ONESTOP-HOME", + "companyId": "YC30686", + "title": "OneStop当前本地活动产品", + "url": "https://onestop.so/", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "由YC关联askonestop.com进入后跳转onestop.so;官网介绍发布附近活动计划、回复及选择聊天对象,并显示App Store即将开放及候补登记。", + "limitation": "页面示例不当真实活动、用户或付费客户;候补登记不证明产品已经普遍可用。" + }, + { + "id": "CLASS-ONESTOP-MISSION", + "companyId": "YC30686", + "title": "OneStop创始人与产品关联", + "url": "https://onestop.so/mission", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "Mission页列Max和Peyton为联合创始人,介绍按附近地点与时间发布和参加活动;与YC创始人姓名及本地分类定位关联。", + "limitation": "页面Sept.2026是使命信月份,不当公司成立或产品公开上线日期;未提交候补或联系表单。" + }, + { + "id": "CLASS-ONESTOP-TERMS", + "companyId": "YC30686", + "title": "OneStop AI用途、主体与免费条款", + "url": "https://onestop.so/terms-of-service", + "publishedAt": "2026-10-08", + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "2026-10-08更新条款列运营者Preempt Technologies, Inc.;称使用OpenAI和Anthropic辅助计划搜索、信息流排序和发帖,当前免费且无订阅或内购,也不处理用户间支付。", + "limitation": "服务用途和供应商为公司自述,未独立测试;当前免费不说明公司全部或历史收入为零,政策日期非注册日期。" + }, + { + "id": "CLASS-PHASES-HOME", + "companyId": "YC30707", + "title": "Phases官方目标客户说明", + "url": "https://phases.ai/", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "YC关联官网称面向前沿机器人实验室提供解决方案,公开页面提供预约演示入口。", + "limitation": "仅能确认机器人方向与目标用户;具体产品、技术架构、交付和收费未披露,不凭创始人履历或一句口号确定能力、客户与收入。" + }, + { + "id": "CLASS-VELVET-HOME", + "companyId": "YC30995", + "title": "Velvet官方数据及模型合作说明", + "url": "https://velvetlab.ai/", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "YC关联官网介绍为AI实验室及消费者公司创建交互模型相关的数据集和模型;页脚列Velvet Media, Inc.,提供样本提交与合作入口。", + "limitation": "官网自述不等独立模型评测。公开页面抓取包含Your Earnings下Pending/Paid $0.00模板文字,未取得账户归属或业务时点,不能作为公司或账户真实收入、零收入观测;未提交任何表单。" + }, + { + "id": "CLASS-VELVET-TERMS", + "companyId": "YC30995", + "title": "Velvet内容交付、许可及创作者报酬条款", + "url": "https://velvetlab.ai/terms.html", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "条款列Velvet Media, Inc.及velvet.video,描述创作者交付视频等内容任务、平台可出售或许可提交内容并支付报酬,支付服务包含Stripe与Wise。", + "limitation": "原文更新日期2/11/2026格式歧义,未转为精确发布日期;任务报酬和支付通道不等公司收入或ARR,内容许可与模型合作的合同边界仍待核。" } ], "reviews": [ @@ -46418,6 +46499,51 @@ ], "handling": "不凭发布榜单或版本更新新增两年成立公司;保留产品历史与法律成立精度差异,不新增收入或技术排名观测。", "scope": "excluded-candidate" + }, + { + "id": "YC30686-R-CLASS", + "type": "主营分类与证据精度", + "company": "OneStop", + "summary": "2026-10-30人工复核:按个人附近活动发现、发帖与聊天归C09,AI辅助搜索/排序/写作据官方条款;不按创始人履历归企业AI转型。", + "evidenceIds": [ + "CLASS-ONESTOP-HOME", + "CLASS-ONESTOP-MISSION", + "CLASS-ONESTOP-TERMS" + ], + "handling": "AI具体用途由当前条款补证;公司及团队年份仍以YC自述为准。免费不等公司收入为零,活动卡片不当真实采用。 继续保留目录基础资料深度及未知收入;未将官网展示、默认金额、履历或供应商标识新增为收入/独立技术观测。", + "scope": "companies", + "companyIds": [ + "YC30686" + ] + }, + { + "id": "YC30707-R-CLASS", + "type": "主营分类与证据精度", + "company": "Phases", + "summary": "2026-10-30人工复核:官网明确frontier robotics labs,按机器人应用领域粗分C10;不据笼统物理世界AI强行认定基础模型、训练平台或硬件产品。", + "evidenceIds": [ + "CLASS-PHASES-HOME" + ], + "handling": "官网可补机器人领域和目标用户,尚不足以确定产品细分、技术水平或采用;创始人既往机器人研究经历不作为公司能力证明。 继续保留目录基础资料深度及未知收入;未将官网展示、默认金额、履历或供应商标识新增为收入/独立技术观测。", + "scope": "companies", + "companyIds": [ + "YC30707" + ] + }, + { + "id": "YC30995-R-CLASS", + "type": "主营分类与证据精度", + "company": "Velvet", + "summary": "2026-10-30人工复核:按面向模型团队的数据集与训练合作归C02;对话表现是任务方向,不能仅据模型一词归为自研基础模型或消费者聊天应用。", + "evidenceIds": [ + "CLASS-VELVET-HOME", + "CLASS-VELVET-TERMS" + ], + "handling": "官网补足训练数据方向;未将交互模型宣传等同自研基础模型发布。页面模板中的收益$0.00不作为公司收入,创作者任务报酬与公司收入分开。 继续保留目录基础资料深度及未知收入;未将官网展示、默认金额、履历或供应商标识新增为收入/独立技术观测。", + "scope": "companies", + "companyIds": [ + "YC30995" + ] } ], "coverage": { diff --git a/content/research/ai-companies.sources.json b/content/research/ai-companies.sources.json index 684d45a..1d40971 100644 --- a/content/research/ai-companies.sources.json +++ b/content/research/ai-companies.sources.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-11", + "version": "2026-10-30-12", "scopeConfirmedAt": "2026-10-30", "acceptance": "以确认的公开来源清单为边界,逐条核对并标明缺项。", "provenance": "S01—S12恢复自已提交用户的《AI公司研究_来源与分类模板》来源清单;S13—S16单列后续已使用的补充证据渠道,不将使用次数当作全量覆盖。", @@ -15,7 +15,7 @@ ], "initialAccess": "官网目录可访问", "boundary": "已读取八个2025—2026批次镜像候选池1428条,再核YC官方页面;不声称覆盖全部YC历年或未公开公司。", - "nextCheck": "继续复核规则初分和缺项;候选明细见逐条台账。", + "nextCheck": "当前快照三条原待分类经官网方向补证归类;继续复核规则初分、具体产品与财务缺项,不把领域粗分类当独立能力或收入验证。", "limitation": "批次不等于成立年;不代表全部公司" }, { diff --git a/content/research/ai-income-analysis.json b/content/research/ai-income-analysis.json index 1190cc6..0878711 100644 --- a/content/research/ai-income-analysis.json +++ b/content/research/ai-income-analysis.json @@ -1,5 +1,5 @@ { - "sourceVersion": "2026-10-30-ph-history-1", + "sourceVersion": "2026-10-30-category-1", "asOf": "2026-10-30", "metric": "platform-mrr-usd", "sampleUnit": "product-or-business", -- 2.52.0 From d971362a9792f2bf49aa4d730ad65209316019df Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 17:31:44 +0800 Subject: [PATCH 02/10] Resolve four launch candidates through operator and app history evidence --- apps/docs/scripts/auth-cli.ts | 2 +- apps/docs/tests/research.test.ts | 8 +- content/research/ai-companies.seed.json | 222 ++++++++++++++++++++- content/research/ai-companies.sources.json | 6 +- content/research/ai-income-analysis.json | 2 +- 5 files changed, 220 insertions(+), 20 deletions(-) diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts index bc562dc..d7ace86 100644 --- a/apps/docs/scripts/auth-cli.ts +++ b/apps/docs/scripts/auth-cli.ts @@ -183,7 +183,7 @@ async function main() { const research = await researchResponse.json(); assert.equal(research.companies.length, 1021); assert.equal(research.categories.length, 11); - assert.equal(research.evidence.length, 1221); + assert.equal(research.evidence.length, 1233); assert.equal(research.observations.length, 189); 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 }); diff --git a/apps/docs/tests/research.test.ts b/apps/docs/tests/research.test.ts index 1f2c177..06a0c2c 100644 --- a/apps/docs/tests/research.test.ts +++ b/apps/docs/tests/research.test.ts @@ -24,7 +24,7 @@ test("research revisions share the authentication database and preserve replay l assert.equal(consumeCode(config, "invalid"), "limited"); const initial = readResearchSnapshot(); assert.equal(initial.companies.length, 1021); - assert.equal(initial.evidence.length, 1221); + assert.equal(initial.evidence.length, 1233); assert.equal(initial.observations.length, 189); assert.deepEqual(initial.candidateBatches, seed.candidateBatches, "candidate gaps persist in the same database metadata"); assert.deepEqual(initial.directoryRechecks, seed.directoryRechecks, "follow-up classifications persist without rewriting the discovery audit"); @@ -66,7 +66,7 @@ test("reviews link explicit companies without turning global or excluded cases i const snapshot = structuredClone(seed) as ResearchSnapshot; assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 57); assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 8); - assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 16); + assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 20); const review = snapshot.reviews.find((r) => r.id === "R4")!; assert.deepEqual(reviewCompanyIds(snapshot, review), ["AI007", "AI013", "AI021", "AI034"]); assert.deepEqual(reviewCompanyIds(snapshot, snapshot.reviews.find((r) => r.id === "CN-R-PSI")!), []); @@ -279,7 +279,7 @@ test("annual launch archives exclude older companies only with evidence and reus assert.equal(batch.complete, false); assert.equal(batch.records.length, 19); const older = batch.records.filter((r) => r.status === "outside-cohort"); - assert.equal(older.length, 10); + assert.equal(older.length, 12); assert.ok(older.every((r) => !r.companyId && r.evidenceIds?.length)); assert.equal(batch.records.find((r) => r.name === "Trace")?.companyId, "YC30687"); const trace = snapshot.companies.find((c) => c.id === "YC30687")!; @@ -300,7 +300,7 @@ test("unreconciled directory financials remain source reviews instead of income assert.ok(review.evidenceIds.includes("PH-TANA-DR")); assert.equal(reviewCompanyIds(snapshot, review).length, 0); assert.ok(!snapshot.observations.some((o) => o.evidence === "PH-TANA-DR")); - assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 17); + assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 13); }); test("product pivots and founder histories do not become new company years or current revenue", () => { diff --git a/content/research/ai-companies.seed.json b/content/research/ai-companies.seed.json index ce57355..909fb3d 100644 --- a/content/research/ai-companies.seed.json +++ b/content/research/ai-companies.seed.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-category-1", + "version": "2026-10-30-operators-1", "asOf": "2026-10-30", "scope": "全球公开资料研究:保留首批个案,并扩展核查2025—2026八个YC批次候选池;成立年份以官方公司页面字段为依据。", "limitations": [ @@ -45368,6 +45368,138 @@ "kind": "公司官方材料", "summary": "条款列Velvet Media, Inc.及velvet.video,描述创作者交付视频等内容任务、平台可出售或许可提交内容并支付报酬,支付服务包含Stripe与Wise。", "limitation": "原文更新日期2/11/2026格式歧义,未转为精确发布日期;任务报酬和支付通道不等公司收入或ARR,内容许可与模型合作的合同边界仍待核。" + }, + { + "id": "PH-AHA-PROFILE", + "companyId": null, + "title": "AhaCreator公开公司主页年份", + "url": "https://www.linkedin.com/company/ahacreator", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司自述公开主页", + "summary": "官网关联公司主页列Founded 2024,并关联ahacreator.com及网红营销业务;另有八位数ARR和增长自述。", + "limitation": "公司自填年份不是注册核验;ARR未核币种、合同及总额/净额口径,不转为2025—2026新公司收入观测。" + }, + { + "id": "PH-AHA-HOME", + "companyId": null, + "title": "AhaCreator官网品牌与主体线索", + "url": "https://www.ahacreator.com/", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "官网介绍发现、评估及管理创作者合作的AI营销平台;页脚列Ahalab.ai Inc.。", + "limitation": "创作者网络、活动数量、客户效果及预算不等平台收入;公司名称仅据页脚,注册资料未取得。" + }, + { + "id": "PH-AHA-TERMS-ACCESS", + "companyId": null, + "title": "AhaCreator条款正文读取缺项", + "url": "https://www.ahacreator.com/terms-of-service", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官网访问记录", + "summary": "标题可识别为条款页面,但本次正文解析只返回导航和页脚,未取得具体合同内容。", + "limitation": "不记为已核收费、托管预算、保证退款或法律主体全部条款,后续仍需原始合同补证。" + }, + { + "id": "PH-SUPERX-TERMS", + "companyId": null, + "title": "SuperX运营主体及产品范围", + "url": "https://superx.so/terms", + "publishedAt": "2026-09-17", + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "2026-09-17更新条款列服务由TMAKER经营,注册号895032514;产品包括X内容、排程、分析、接口及部分AI功能。", + "limitation": "2026产品发布不证明新法律主体;公司收入与单个产品收入分开,不转移其他产品或创始人业绩。" + }, + { + "id": "PH-SUPERX-REGISTRY", + "companyId": null, + "title": "TMAKER同号第三方登记资料", + "url": "https://www.pappers.fr/entreprise/tmaker-895032514", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "第三方企业登记汇编", + "summary": "Pappers资料的SIREN 895032514与SuperX条款一致,创建及登记字段均在2021年。", + "limitation": "第三方公开登记汇编,不是本次取得的政府原始摘录;创建日和登记日字段不同,只据一致年份判断旧主体,未核完整沿革或产品收入。" + }, + { + "id": "PH-SUPERX-GOV-ACCESS", + "companyId": null, + "title": "TMAKER政府目录访问缺项", + "url": "https://annuaire-entreprises.data.gouv.fr/entreprise/tmaker-895032514", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公开来源访问记录", + "summary": "政府企业目录直达返回403,本次未取得原始登记正文。", + "limitation": "保留独立核验缺项;不绕过访问保护,也不将Pappers材料标为政府原始资料。" + }, + { + "id": "PH-BREW-PROFILE", + "companyId": null, + "title": "Brew公开公司主页年份", + "url": "https://www.linkedin.com/company/brewdotnew", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司自述公开主页", + "summary": "公司公开主页列Founded 2024、关联brew.new及创始人Philip Sørensen与Thomas Park。", + "limitation": "公司自填年份非注册核验;关联员工档案不等团队总人数,客户案例效果不作公司收入。" + }, + { + "id": "PH-BREW-LAUNCH", + "companyId": null, + "title": "Brew官方2025年产品介绍", + "url": "https://brew.marketing/blog/introducing-brew", + "publishedAt": "2025-10-07", + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "2025-10-07产品文章介绍AI邮件平台,页面关联brew.new,列当时免费和付费方案。", + "limitation": "产品发布不当成立年;历史按联系人数套餐不平移当前按credits和发送量定价,也不由报价反推ARR。" + }, + { + "id": "PH-BREW-TERMS", + "companyId": null, + "title": "Brew现行官方条款主体", + "url": "https://docs.brew.new/legal/terms-of-service", + "publishedAt": "2025-10-19", + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "条款列Brew Emails, Inc.和brew.new,页面标更新2025-10-19。", + "limitation": "条款更新时间不当公司成立日;与其他同名阅读器、咨询或营销业务不合并,未取得实际合同或收入。" + }, + { + "id": "PH-MUSA-APPLE", + "companyId": null, + "title": "Musa官方应用身份与运营者", + "url": "https://apps.apple.com/us/app/musa-period-pregnancy/id6504586102", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "官方应用商店资料", + "summary": "美区App Store列Musa周期及自我照护应用,开发者Musa Health, Inc.,并标免费及内购;应用ID 6504586102。", + "limitation": "上架、内购和评分不等真实付费或收入;健康效果为开发者宣称,未独立评估,不以相邻同名应用资料补成立年。" + }, + { + "id": "PH-MUSA-LOOKUP", + "companyId": null, + "title": "Musa Apple公开元数据的历史日期", + "url": "https://itunes.apple.com/lookup?id=6504586102&country=us", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "官方应用商店元数据", + "summary": "Apple公开元数据trackId 6504586102、sellerName Musa Health Inc、sellerUrl musa.com;releaseDate为2024-07-02T07:00:00Z。", + "limitation": "releaseDate为该地区应用元数据中的发布时点,不是公司注册年;当前版本日期另列,不能把2025 PH发布当应用首次出现。" + }, + { + "id": "PH-MUSA-HOME-ACCESS", + "companyId": null, + "title": "Musa当前官网读取范围", + "url": "https://www.musa.com/", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官网访问记录", + "summary": "PH关联musa.app跳转musa.com,与Apple卖方网址相符;本次官网只返回需要JavaScript的外壳。", + "limitation": "未记为读到网站业务或条款全文;业务与历史由Apple资料补核,不推测未读页面。" } ], "reviews": [ @@ -46544,6 +46676,58 @@ "companyIds": [ "YC30995" ] + }, + { + "id": "PH26-13-R-OPERATOR", + "type": "运营主体、产品历史与收入归属", + "company": "SuperX(候选排除)", + "summary": "条款运营者TMAKER的注册号与第三方登记资料一致,资料列2021年旧主体;按既有运营者排除,不以2026 PH发布算新公司。政府目录403,原始登记及沿革仍待核,其他产品业绩不归SuperX。", + "evidenceIds": [ + "PH-SUPERX-TERMS", + "PH-SUPERX-REGISTRY", + "PH-SUPERX-GOV-ACCESS" + ], + "handling": "保留原始资料与访问缺项,按来源层级理解年份精度;不新增公司或收入观测,不把报价、内购、客户结果及其他业务业绩当该产品ARR。", + "scope": "excluded-candidate" + }, + { + "id": "PH26-15-R-OPERATOR", + "type": "运营主体、产品历史与收入归属", + "company": "Brew(候选排除)", + "summary": "公开公司主页列2024成立,按自述年份排除;官方2025产品介绍和Brew Emails, Inc.条款关联brew.new。PH直达本次仍超时,检索可见不当全文;不混其他同名Brew,历史报价不当当前报价或ARR。", + "evidenceIds": [ + "PH-BREW-PROFILE", + "PH-BREW-LAUNCH", + "PH-BREW-TERMS" + ], + "handling": "保留原始资料与访问缺项,按来源层级理解年份精度;不新增公司或收入观测,不把报价、内购、客户结果及其他业务业绩当该产品ARR。", + "scope": "excluded-candidate" + }, + { + "id": "PH25-14-R-OPERATOR", + "type": "运营主体、产品历史与收入归属", + "company": "Aha(候选排除)", + "summary": "官网关联公司主页列2024成立,按自述年份排除;页脚Ahalab.ai Inc.,注册未独立核验。八位数ARR、创作者预算和效果宣传不入两年新公司观测;条款正文仍未读。", + "evidenceIds": [ + "PH-AHA-PROFILE", + "PH-AHA-HOME", + "PH-AHA-TERMS-ACCESS" + ], + "handling": "保留原始资料与访问缺项,按来源层级理解年份精度;不新增公司或收入观测,不把报价、内购、客户结果及其他业务业绩当该产品ARR。", + "scope": "excluded-candidate" + }, + { + "id": "PH25-18-R-OPERATOR", + "type": "运营主体、产品历史与收入归属", + "company": "Musa(候选排除)", + "summary": "Apple同一应用ID及卖方网址证明该产品2024已有发布,按既有业务排除,精确法律成立日未核;PH的2025发布不当首次上架。官网仅JavaScript外壳,业务由官方商店补核;内购、评价与健康宣传不当收入或独立效果。", + "evidenceIds": [ + "PH-MUSA-APPLE", + "PH-MUSA-LOOKUP", + "PH-MUSA-HOME-ACCESS" + ], + "handling": "保留原始资料与访问缺项,按来源层级理解年份精度;不新增公司或收入观测,不把报价、内购、客户结果及其他业务业绩当该产品ARR。", + "scope": "excluded-candidate" } ], "coverage": { @@ -46753,8 +46937,13 @@ "categoryId": "C06", "kind": "product", "profileStatus": "readable", - "status": "pending-year", - "note": "候选分类按发布定位初分;公司成立年、主体及收入待核,不能因2026榜单就加入成立年样本。" + "status": "outside-cohort", + "note": "条款运营者TMAKER的注册号与第三方登记资料一致,资料列2021年旧主体;按既有运营者排除,不以2026 PH发布算新公司。政府目录403,原始登记及沿革仍待核,其他产品业绩不归SuperX。", + "evidenceIds": [ + "PH-SUPERX-TERMS", + "PH-SUPERX-REGISTRY", + "PH-SUPERX-GOV-ACCESS" + ] }, { "id": "PH26-14", @@ -46778,8 +46967,13 @@ "categoryId": "C06", "kind": "product", "profileStatus": "unavailable", - "status": "pending-year", - "note": "产品页读取超时,保留榜单发现;主体、成立年和收入待核。" + "status": "outside-cohort", + "note": "公开公司主页列2024成立,按自述年份排除;官方2025产品介绍和Brew Emails, Inc.条款关联brew.new。PH直达本次仍超时,检索可见不当全文;不混其他同名Brew,历史报价不当当前报价或ARR。", + "evidenceIds": [ + "PH-BREW-PROFILE", + "PH-BREW-LAUNCH", + "PH-BREW-TERMS" + ] }, { "id": "PH26-16", @@ -47039,10 +47233,13 @@ "categoryId": "C06", "kind": "product", "profileStatus": "readable", - "status": "pending-year", - "note": "网红营销Agent;当前品牌AhaCreator,主体、成立年待核。", + "status": "outside-cohort", + "note": "官网关联公司主页列2024成立,按自述年份排除;页脚Ahalab.ai Inc.,注册未独立核验。八位数ARR、创作者预算和效果宣传不入两年新公司观测;条款正文仍未读。", "evidenceIds": [ - "PH25-E-14" + "PH25-E-14", + "PH-AHA-PROFILE", + "PH-AHA-HOME", + "PH-AHA-TERMS-ACCESS" ] }, { @@ -47093,10 +47290,13 @@ "categoryId": "C09", "kind": "product", "profileStatus": "readable", - "status": "pending-year", - "note": "个人健康与自我照护产品;公司成立年及AI是否主营待核。", + "status": "outside-cohort", + "note": "Apple同一应用ID及卖方网址证明该产品2024已有发布,按既有业务排除,精确法律成立日未核;PH的2025发布不当首次上架。官网仅JavaScript外壳,业务由官方商店补核;内购、评价与健康宣传不当收入或独立效果。", "evidenceIds": [ - "PH25-E-18" + "PH25-E-18", + "PH-MUSA-APPLE", + "PH-MUSA-LOOKUP", + "PH-MUSA-HOME-ACCESS" ] }, { diff --git a/content/research/ai-companies.sources.json b/content/research/ai-companies.sources.json index 1d40971..95c33b9 100644 --- a/content/research/ai-companies.sources.json +++ b/content/research/ai-companies.sources.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-12", + "version": "2026-10-30-13", "scopeConfirmedAt": "2026-10-30", "acceptance": "以确认的公开来源清单为边界,逐条核对并标明缺项。", "provenance": "S01—S12恢复自已提交用户的《AI公司研究_来源与分类模板》来源清单;S13—S16单列后续已使用的补充证据渠道,不将使用次数当作全量覆盖。", @@ -60,8 +60,8 @@ "producthunt.com" ], "initialAccess": "官网可读取", - "boundary": "两年公开响应各19条,共38条。当前4条关联公司、2条范围外对照、16条范围外/既有业务、14条年份待核、2条推广。补Screen Studio、Tool Finder、Basalt、DataFast历史证据;非两年全量。", - "nextCheck": "继续补两年其余分页/日期、仅图片位置及14条年份待核候选;保留Pazi日期冲突,核清品牌与主体,避免转移历史业绩。", + "boundary": "两年公开响应各19条,共38条。当前4条关联公司、2条范围外对照、20条范围外/既有业务、10条年份待核、2条推广。排除据成立字段、旧运营者或历史产品证据,精度及访问缺项保留;非两年全量。", + "nextCheck": "继续补两年其余分页/日期、仅图片位置及10条年份待核候选;保留Pazi日期冲突,核清品牌与主体,避免转移历史业绩。", "limitation": "上线时间不等于成立时间", "accessRecheckedAt": "2026-10-30" }, diff --git a/content/research/ai-income-analysis.json b/content/research/ai-income-analysis.json index 0878711..a246fcf 100644 --- a/content/research/ai-income-analysis.json +++ b/content/research/ai-income-analysis.json @@ -1,5 +1,5 @@ { - "sourceVersion": "2026-10-30-category-1", + "sourceVersion": "2026-10-30-operators-1", "asOf": "2026-10-30", "metric": "platform-mrr-usd", "sampleUnit": "product-or-business", -- 2.52.0 From e075eee8a5be7fcdbfb408aab42f8fbe349e9ce8 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 17:41:15 +0800 Subject: [PATCH 03/10] Separate legacy launches from unresolved Happycapy entity lineage --- apps/docs/scripts/auth-cli.ts | 2 +- apps/docs/tests/research.test.ts | 8 +- content/research/ai-companies.seed.json | 161 +++++++++++++++++++-- content/research/ai-companies.sources.json | 6 +- content/research/ai-income-analysis.json | 2 +- 5 files changed, 158 insertions(+), 21 deletions(-) diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts index d7ace86..6e08543 100644 --- a/apps/docs/scripts/auth-cli.ts +++ b/apps/docs/scripts/auth-cli.ts @@ -183,7 +183,7 @@ async function main() { const research = await researchResponse.json(); assert.equal(research.companies.length, 1021); assert.equal(research.categories.length, 11); - assert.equal(research.evidence.length, 1233); + assert.equal(research.evidence.length, 1240); assert.equal(research.observations.length, 189); 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 }); diff --git a/apps/docs/tests/research.test.ts b/apps/docs/tests/research.test.ts index 06a0c2c..8e21e5d 100644 --- a/apps/docs/tests/research.test.ts +++ b/apps/docs/tests/research.test.ts @@ -24,7 +24,7 @@ test("research revisions share the authentication database and preserve replay l assert.equal(consumeCode(config, "invalid"), "limited"); const initial = readResearchSnapshot(); assert.equal(initial.companies.length, 1021); - assert.equal(initial.evidence.length, 1233); + assert.equal(initial.evidence.length, 1240); assert.equal(initial.observations.length, 189); assert.deepEqual(initial.candidateBatches, seed.candidateBatches, "candidate gaps persist in the same database metadata"); assert.deepEqual(initial.directoryRechecks, seed.directoryRechecks, "follow-up classifications persist without rewriting the discovery audit"); @@ -66,7 +66,7 @@ test("reviews link explicit companies without turning global or excluded cases i const snapshot = structuredClone(seed) as ResearchSnapshot; assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 57); assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 8); - assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 20); + assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 24); const review = snapshot.reviews.find((r) => r.id === "R4")!; assert.deepEqual(reviewCompanyIds(snapshot, review), ["AI007", "AI013", "AI021", "AI034"]); assert.deepEqual(reviewCompanyIds(snapshot, snapshot.reviews.find((r) => r.id === "CN-R-PSI")!), []); @@ -279,7 +279,7 @@ test("annual launch archives exclude older companies only with evidence and reus assert.equal(batch.complete, false); assert.equal(batch.records.length, 19); const older = batch.records.filter((r) => r.status === "outside-cohort"); - assert.equal(older.length, 12); + assert.equal(older.length, 15); assert.ok(older.every((r) => !r.companyId && r.evidenceIds?.length)); assert.equal(batch.records.find((r) => r.name === "Trace")?.companyId, "YC30687"); const trace = snapshot.companies.find((c) => c.id === "YC30687")!; @@ -300,7 +300,7 @@ test("unreconciled directory financials remain source reviews instead of income assert.ok(review.evidenceIds.includes("PH-TANA-DR")); assert.equal(reviewCompanyIds(snapshot, review).length, 0); assert.ok(!snapshot.observations.some((o) => o.evidence === "PH-TANA-DR")); - assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 13); + assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 10); }); test("product pivots and founder histories do not become new company years or current revenue", () => { diff --git a/content/research/ai-companies.seed.json b/content/research/ai-companies.seed.json index 909fb3d..bba6fd7 100644 --- a/content/research/ai-companies.seed.json +++ b/content/research/ai-companies.seed.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-operators-1", + "version": "2026-10-30-lineage-1", "asOf": "2026-10-30", "scope": "全球公开资料研究:保留首批个案,并扩展核查2025—2026八个YC批次候选池;成立年份以官方公司页面字段为依据。", "limitations": [ @@ -45500,6 +45500,83 @@ "kind": "公司官网访问记录", "summary": "PH关联musa.app跳转musa.com,与Apple卖方网址相符;本次官网只返回需要JavaScript的外壳。", "limitation": "未记为读到网站业务或条款全文;业务与历史由Apple资料补核,不推测未读页面。" + }, + { + "id": "PH-SIDER-HISTORY", + "companyId": null, + "title": "Sider原版与后续版本发布历史", + "url": "https://www.producthunt.com/products/chatgpt-sidebar-chrome-extension?launch=sider-4&page=1", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "平台产品发布记录", + "summary": "平台列原版ChatGPT Sidebar于2023年发布,4.0同在2023年,5.0为2025年更新,均关联sider.ai。", + "limitation": "产品版本与法律主体成立日分开;不凭5.0榜单更新新增两年公司,平台用户和评分不作付费或收入。" + }, + { + "id": "PH-SIDER-TERMS", + "companyId": null, + "title": "Sider当前运营主体", + "url": "https://sider.ai/policies/terms", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "当前官方条款列Sider由Vidline Inc.运营,覆盖网站、浏览器扩展及移动和桌面应用。", + "limitation": "条款没有在本次材料中补足精确注册年;产品发布历史不强填为法人成立年,不移入同名或其他业务业绩。" + }, + { + "id": "PH-CHRONICLE-PROFILE", + "companyId": null, + "title": "Chronicle演示软件公司公开主页年份", + "url": "https://www.linkedin.com/company/chroniclehq", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司自述公开主页", + "summary": "公开公司主页列Founded 2021,关联chroniclehq.com及AI演示/叙事软件业务。", + "limitation": "公司自填年份非独立注册核验;不与Chronicle Labs等近名企业混并,既有历史博客超时记录仍保留为未读。" + }, + { + "id": "PH-TRICKLE-PROFILE", + "companyId": null, + "title": "Trickle公开公司主页年份", + "url": "https://au.linkedin.com/company/trickleai", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司自述公开主页", + "summary": "公司公开主页列Founded 2018、trickle.so及AI建站/应用画布业务。", + "limitation": "公司自填年份未独立注册核验;不直接把2018或该团队规模与收入归给Happycapy,关联员工档案不是精确团队总数。" + }, + { + "id": "PH-TRICKLE-HOME", + "companyId": null, + "title": "Trickle官方产品与Happycapy交叉链接", + "url": "https://trickle.so/", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "官网介绍用自然语言构建应用与网站,并以官方链接介绍Happycapy;两种产品方向仍分别可见。", + "limitation": "产品家族/导流关系不独自证明新旧法律主体同一、并购或注册日期;官网版权年不当成立年,社区示例不作已付费采用。" + }, + { + "id": "PH-HAPPYCAPY-TERMS", + "companyId": null, + "title": "Happycapy当前服务与订阅条款", + "url": "https://happycapy.ai/terms", + "publishedAt": "2026-03-18", + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "2026-03-18条款说明消费应用、AI Agent图形界面、托管沙箱及月/年订阅,文本以Happycapy自称。", + "limitation": "本次仍未据条款确认法律运营者和注册日;条款生效日不等成立日,订阅条款不证明实际收入、独立安全性或采用规模。" + }, + { + "id": "PH-HAPPYCAPY-HISTORY", + "companyId": null, + "title": "Trickle与Happycapy共用发布页的边界", + "url": "https://www.producthunt.com/products/happycapy", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "平台产品发布记录", + "summary": "平台共页包含较早Trickle产品和2025 Magic Canvas、2026 Happycapy发布,并称原Trickle团队开发。", + "limitation": "页首Launched in 2022不直接解释为Happycapy品牌或法人成立于2022;旧团队产品历史与新品牌精确主体继续分开。" } ], "reviews": [ @@ -46728,6 +46805,56 @@ ], "handling": "保留原始资料与访问缺项,按来源层级理解年份精度;不新增公司或收入观测,不把报价、内购、客户结果及其他业务业绩当该产品ARR。", "scope": "excluded-candidate" + }, + { + "id": "PH25-05-R-LINEAGE", + "type": "版本、品牌与旧业务范围", + "company": "Sider 5.0(候选排除)", + "summary": "Sider原版2023已有发布,当前条款运营者Vidline Inc.;按既有业务排除,不因2025年5.0更新新增公司,精确法律成立日未核。", + "evidenceIds": [ + "PH-SIDER-HISTORY", + "PH-SIDER-TERMS" + ], + "handling": "按明确来源精度记录,不凭更新或更名新增公司;产品历史与法人成立日期分开,既有访问缺项保留,不新增收入或独立技术观测。", + "scope": "excluded-candidate" + }, + { + "id": "PH25-12-R-LINEAGE", + "type": "版本、品牌与旧业务范围", + "company": "Chronicle: Cursor for Slides(候选排除)", + "summary": "公司公开主页列2021成立并关联同一演示软件域名,按自述年份排除,注册未独立核验;与Chronicle Labs分开,既有2023博客仍未读,不伪称完成该原文核查。", + "evidenceIds": [ + "PH-CHRONICLE-PROFILE" + ], + "handling": "按明确来源精度记录,不凭更新或更名新增公司;产品历史与法人成立日期分开,既有访问缺项保留,不新增收入或独立技术观测。", + "scope": "excluded-candidate" + }, + { + "id": "PH25-19-R-LINEAGE", + "type": "版本、品牌与旧业务范围", + "company": "Trickle - Magic Canvas(候选排除)", + "summary": "Trickle公开公司主页列2018成立,官网仍提供AI应用画布并链接Happycapy;Magic Canvas为既有业务更新,按自述年份排除。Happycapy是否同一法律主体未核,不把Trickle年份、收入和团队自动转移。", + "evidenceIds": [ + "PH-TRICKLE-PROFILE", + "PH-TRICKLE-HOME", + "PH-HAPPYCAPY-HISTORY" + ], + "handling": "按明确来源精度记录,不凭更新或更名新增公司;产品历史与法人成立日期分开,既有访问缺项保留,不新增收入或独立技术观测。", + "scope": "excluded-candidate" + }, + { + "id": "PH-HAPPYCAPY-R-ENTITY", + "type": "产品关联与法律主体缺项", + "company": "Happycapy(待核候选)", + "summary": "官网交叉链接与平台团队描述能关联产品家族,条款未补足法律运营者和注册年份。", + "evidenceIds": [ + "PH-TRICKLE-HOME", + "PH-TRICKLE-PROFILE", + "PH-HAPPYCAPY-HISTORY", + "PH-HAPPYCAPY-TERMS" + ], + "handling": "保持年份待核;不把共用发布页、旧公司自述年份、政策生效日或版权年当新品牌法人成立日,不转移旧业务业绩。", + "scope": "excluded-candidate" } ], "coverage": { @@ -46803,11 +46930,15 @@ "kind": "product", "profileStatus": "readable", "status": "pending-year", - "note": "Trickle历史发布与Happycapy当前产品页相关联;官网功能及政策可读,但法人成立与股权沿革未核。政策生效日不作成立日,仍保留候选,不重复新增公司。", + "note": "Trickle官网直接链接Happycapy,平台称原团队开发,确认产品关联;但Happycapy条款仍未补足法律运营者/成立年,继续待核,不自动承接Trickle的2018自述年份或其他历史业绩。", "evidenceIds": [ "PH25-E-19", "PH-HAPPYCAPY-ABOUT", - "PH-HAPPYCAPY-PRIVACY" + "PH-HAPPYCAPY-PRIVACY", + "PH-TRICKLE-HOME", + "PH-TRICKLE-PROFILE", + "PH-HAPPYCAPY-HISTORY", + "PH-HAPPYCAPY-TERMS" ] }, { @@ -47108,10 +47239,12 @@ "categoryId": "C09", "kind": "product", "profileStatus": "readable", - "status": "pending-year", - "note": "AI研究与浏览器工具;版本更新不等于公司创立,成立年待核。", + "status": "outside-cohort", + "note": "Sider原版2023已有发布,当前条款运营者Vidline Inc.;按既有业务排除,不因2025年5.0更新新增公司,精确法律成立日未核。", "evidenceIds": [ - "PH25-E-05" + "PH25-E-05", + "PH-SIDER-HISTORY", + "PH-SIDER-TERMS" ] }, { @@ -47205,11 +47338,12 @@ "categoryId": "C08", "kind": "product", "profileStatus": "readable", - "status": "pending-year", - "note": "演示文稿产品,与Chronicle Labs不同;历史公告正文两次超时,不能据搜索摘要确定成立年,继续待核。", + "status": "outside-cohort", + "note": "公司公开主页列2021成立并关联同一演示软件域名,按自述年份排除,注册未独立核验;与Chronicle Labs分开,既有2023博客仍未读,不伪称完成该原文核查。", "evidenceIds": [ "PH25-E-12", - "PH-CHRONICLE-ACCESS" + "PH-CHRONICLE-ACCESS", + "PH-CHRONICLE-PROFILE" ] }, { @@ -47306,12 +47440,15 @@ "categoryId": "C04", "kind": "product", "profileStatus": "readable", - "status": "pending-year", - "note": "Trickle历史发布与Happycapy当前产品页相关联;官网功能及政策可读,但法人成立与股权沿革未核。政策生效日不作成立日,仍保留候选,不重复新增公司。", + "status": "outside-cohort", + "note": "Trickle公开公司主页列2018成立,官网仍提供AI应用画布并链接Happycapy;Magic Canvas为既有业务更新,按自述年份排除。Happycapy是否同一法律主体未核,不把Trickle年份、收入和团队自动转移。", "evidenceIds": [ "PH25-E-19", "PH-HAPPYCAPY-ABOUT", - "PH-HAPPYCAPY-PRIVACY" + "PH-HAPPYCAPY-PRIVACY", + "PH-TRICKLE-PROFILE", + "PH-TRICKLE-HOME", + "PH-HAPPYCAPY-HISTORY" ] } ] diff --git a/content/research/ai-companies.sources.json b/content/research/ai-companies.sources.json index 95c33b9..fef5a10 100644 --- a/content/research/ai-companies.sources.json +++ b/content/research/ai-companies.sources.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-13", + "version": "2026-10-30-14", "scopeConfirmedAt": "2026-10-30", "acceptance": "以确认的公开来源清单为边界,逐条核对并标明缺项。", "provenance": "S01—S12恢复自已提交用户的《AI公司研究_来源与分类模板》来源清单;S13—S16单列后续已使用的补充证据渠道,不将使用次数当作全量覆盖。", @@ -60,8 +60,8 @@ "producthunt.com" ], "initialAccess": "官网可读取", - "boundary": "两年公开响应各19条,共38条。当前4条关联公司、2条范围外对照、20条范围外/既有业务、10条年份待核、2条推广。排除据成立字段、旧运营者或历史产品证据,精度及访问缺项保留;非两年全量。", - "nextCheck": "继续补两年其余分页/日期、仅图片位置及10条年份待核候选;保留Pazi日期冲突,核清品牌与主体,避免转移历史业绩。", + "boundary": "两年公开响应各19条,共38条。当前4条关联公司、2条范围外对照、23条范围外/既有业务、7条年份待核、2条推广。旧版本、品牌与法律主体分开,Happycapy仍待核;非两年全量。", + "nextCheck": "继续补两年其余分页/日期、仅图片位置及7条年份待核候选;保留Pazi日期冲突,核清品牌与主体,避免转移历史业绩。", "limitation": "上线时间不等于成立时间", "accessRecheckedAt": "2026-10-30" }, diff --git a/content/research/ai-income-analysis.json b/content/research/ai-income-analysis.json index a246fcf..dce53b2 100644 --- a/content/research/ai-income-analysis.json +++ b/content/research/ai-income-analysis.json @@ -1,5 +1,5 @@ { - "sourceVersion": "2026-10-30-operators-1", + "sourceVersion": "2026-10-30-lineage-1", "asOf": "2026-10-30", "metric": "platform-mrr-usd", "sampleUnit": "product-or-business", -- 2.52.0 From 677cd7394540cb3e183033a5bae6a37d529c6c33 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 17:54:08 +0800 Subject: [PATCH 04/10] Fix Peec team bounds regression assertion --- apps/docs/tests/research.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/docs/tests/research.test.ts b/apps/docs/tests/research.test.ts index 8e21e5d..40d44e1 100644 --- a/apps/docs/tests/research.test.ts +++ b/apps/docs/tests/research.test.ts @@ -151,7 +151,14 @@ test("retracted ARR and projected margins remain excluded", () => { const forecast = snapshot.observations.find((o) => o.kind === "非GAAP毛利率预期")!; assert.equal(forecast.selection, "excluded"); const peec = snapshot.companies.find((c) => c.name === "Peec AI")!; - assert.equal(peec.teamBound, ">"); + assert.equal(peec.team, 100); + assert.equal(peec.teamBound, "≥"); + const currentTeam = snapshot.observations.find((o) => o.id === "PEEC-JOB-TEAM")!; + assert.equal(currentTeam.value, peec.team); + assert.equal(currentTeam.bound, peec.teamBound); + const historicalTeam = snapshot.observations.find((o) => o.id === "O067")!; + assert.equal(historicalTeam.value, 70); + assert.equal(historicalTeam.bound, ">"); assert.equal(snapshot.observations.filter((o) => o.company === peec.id && o.selection === "signal").length, 1); assert.ok(snapshot.observations.some((o) => o.company === peec.id && o.selection === "historical-estimate")); }); -- 2.52.0 From 347b9587c60f4ce57b09ed2f79b402e0e4326041 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 18:04:11 +0800 Subject: [PATCH 05/10] Reconcile Tapflow history and Atoms company-year evidence --- apps/docs/scripts/auth-cli.ts | 2 +- apps/docs/tests/research.test.ts | 8 +-- content/research/ai-companies.seed.json | 62 +++++++++++++++++++--- content/research/ai-companies.sources.json | 6 +-- content/research/ai-income-analysis.json | 2 +- 5 files changed, 65 insertions(+), 15 deletions(-) diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts index 6e08543..9d4e5f3 100644 --- a/apps/docs/scripts/auth-cli.ts +++ b/apps/docs/scripts/auth-cli.ts @@ -183,7 +183,7 @@ async function main() { const research = await researchResponse.json(); assert.equal(research.companies.length, 1021); assert.equal(research.categories.length, 11); - assert.equal(research.evidence.length, 1240); + assert.equal(research.evidence.length, 1242); assert.equal(research.observations.length, 189); 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 }); diff --git a/apps/docs/tests/research.test.ts b/apps/docs/tests/research.test.ts index 40d44e1..c3bb537 100644 --- a/apps/docs/tests/research.test.ts +++ b/apps/docs/tests/research.test.ts @@ -24,7 +24,7 @@ test("research revisions share the authentication database and preserve replay l assert.equal(consumeCode(config, "invalid"), "limited"); const initial = readResearchSnapshot(); assert.equal(initial.companies.length, 1021); - assert.equal(initial.evidence.length, 1240); + assert.equal(initial.evidence.length, 1242); assert.equal(initial.observations.length, 189); assert.deepEqual(initial.candidateBatches, seed.candidateBatches, "candidate gaps persist in the same database metadata"); assert.deepEqual(initial.directoryRechecks, seed.directoryRechecks, "follow-up classifications persist without rewriting the discovery audit"); @@ -66,7 +66,7 @@ test("reviews link explicit companies without turning global or excluded cases i const snapshot = structuredClone(seed) as ResearchSnapshot; assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 57); assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 8); - assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 24); + assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 26); const review = snapshot.reviews.find((r) => r.id === "R4")!; assert.deepEqual(reviewCompanyIds(snapshot, review), ["AI007", "AI013", "AI021", "AI034"]); assert.deepEqual(reviewCompanyIds(snapshot, snapshot.reviews.find((r) => r.id === "CN-R-PSI")!), []); @@ -286,7 +286,7 @@ test("annual launch archives exclude older companies only with evidence and reus assert.equal(batch.complete, false); assert.equal(batch.records.length, 19); const older = batch.records.filter((r) => r.status === "outside-cohort"); - assert.equal(older.length, 15); + assert.equal(older.length, 16); assert.ok(older.every((r) => !r.companyId && r.evidenceIds?.length)); assert.equal(batch.records.find((r) => r.name === "Trace")?.companyId, "YC30687"); const trace = snapshot.companies.find((c) => c.id === "YC30687")!; @@ -307,7 +307,7 @@ test("unreconciled directory financials remain source reviews instead of income assert.ok(review.evidenceIds.includes("PH-TANA-DR")); assert.equal(reviewCompanyIds(snapshot, review).length, 0); assert.ok(!snapshot.observations.some((o) => o.evidence === "PH-TANA-DR")); - assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 10); + assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 9); }); test("product pivots and founder histories do not become new company years or current revenue", () => { diff --git a/content/research/ai-companies.seed.json b/content/research/ai-companies.seed.json index bba6fd7..f1c35c5 100644 --- a/content/research/ai-companies.seed.json +++ b/content/research/ai-companies.seed.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-lineage-1", + "version": "2026-10-30-lineage-2", "asOf": "2026-10-30", "scope": "全球公开资料研究:保留首批个案,并扩展核查2025—2026八个YC批次候选池;成立年份以官方公司页面字段为依据。", "limitations": [ @@ -45577,6 +45577,28 @@ "kind": "平台产品发布记录", "summary": "平台共页包含较早Trickle产品和2025 Magic Canvas、2026 Happycapy发布,并称原Trickle团队开发。", "limitation": "页首Launched in 2022不直接解释为Happycapy品牌或法人成立于2022;旧团队产品历史与新品牌精确主体继续分开。" + }, + { + "id": "PH-TAPFLOW-HISTORY", + "companyId": null, + "title": "Tapflow原版与2.0的公开发布沿革", + "url": "https://www.producthunt.com/products/tapflow", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "平台产品发布记录", + "summary": "同一产品页与两个发布页列1.0于2024-05-16、2.0于2025-05-28发布,均链接tapflow.ai知识商品业务。", + "limitation": "旧产品沿革足以排除本次新公司候选,但不是法律注册年份;官网本次读取超时,运营者和收入未核。不能套用tapflowai.com招聘工具的成立年或业绩。" + }, + { + "id": "PH-ATOMS-PROFILE", + "companyId": null, + "title": "MetaGPT公开公司主页的年份与MGX关联", + "url": "https://www.linkedin.com/company/metagpt/", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司公开主页自述", + "summary": "公开主页列Founded 2025,并将mgx.dev列为商业平台,关联MetaGPT开源框架与后续Atoms品牌。", + "limitation": "自述年份不是注册证明;尚未消歧MetaGPT LLC、DeepWisdom与产品团队的主体关系及官网更早沿革。融资金额不是收入,关注数与页面关联人员数不是员工总人数。Atoms展示页和DeepWisdom关联页本次读取失败。" } ], "reviews": [ @@ -46855,6 +46877,32 @@ ], "handling": "保持年份待核;不把共用发布页、旧公司自述年份、政策生效日或版权年当新品牌法人成立日,不转移旧业务业绩。", "scope": "excluded-candidate" + }, + { + "id": "PH-TAPFLOW-R-HISTORY", + "type": "版本发布与新公司边界", + "company": "Tapflow 2.0(既有业务候选)", + "summary": "平台同页关联的2024原版与2025版本更新属于既有产品沿革。", + "evidenceIds": [ + "PH25-E-17", + "PH-TAPFLOW-HISTORY" + ], + "handling": "转为范围外既有业务;不新增公司、团队或收入观测,不把2024产品发布日写成精确法人成立日;同名招聘平台另行识别。", + "scope": "excluded-candidate" + }, + { + "id": "PH-ATOMS-R-ENTITY", + "type": "成立年自述与产品沿革边界", + "company": "MGX / Atoms(待核候选)", + "summary": "公开公司主页的2025自述提供新线索,但不能直接消除更早技术与业务沿革、服务主体之间的歧义。", + "evidenceIds": [ + "PH-ATOMS-PROFILE", + "PH-ATOMS-ABOUT", + "PH-ATOMS-TIMELINE", + "PH-ATOMS-ENTITY" + ], + "handling": "保留年份待核,需补同一法律主体或独立新公司的成立依据;不转移旧产品收入、开源使用量、团队规模或融资为ARR。", + "scope": "excluded-candidate" } ], "coverage": { @@ -47296,11 +47344,12 @@ "kind": "product", "profileStatus": "readable", "status": "pending-year", - "note": "MetaGPT研究→MGX→Atoms为产品沿革;官网产品化年份有2024与2024—2025口径,技术服务方列MetaGPT LLC。公司成立年和跨主体关系待核,不因更名新增公司。", + "note": "公司公开主页自述Founded 2025并链接mgx.dev;官网沿革列2022—2023 MetaGPT、2024—2025 MGX、后续Atoms,条款技术服务方MetaGPT LLC。主页年份与技术/业务沿革的实体边界未消歧,暂不新增2025公司,不把更名或旧框架业绩直接归新主体。", "evidenceIds": [ "PH-ATOMS-ABOUT", "PH-ATOMS-TIMELINE", - "PH-ATOMS-ENTITY" + "PH-ATOMS-ENTITY", + "PH-ATOMS-PROFILE" ] }, { @@ -47411,10 +47460,11 @@ "categoryId": "C05", "kind": "product", "profileStatus": "readable", - "status": "pending-year", - "note": "将知识与文档打包出售;公司成立年、AI主营与收入归属待核。", + "status": "outside-cohort", + "note": "同一知识商品工具已有2024-05-16的1.0发布记录,2025-05-28为2.0更新;作为既有业务排除,不凭版本发布新增2025公司。精确法人成立年、运营主体、AI主营与收入归属待核;不与同名招聘平台混用。", "evidenceIds": [ - "PH25-E-17" + "PH25-E-17", + "PH-TAPFLOW-HISTORY" ] }, { diff --git a/content/research/ai-companies.sources.json b/content/research/ai-companies.sources.json index fef5a10..40771cf 100644 --- a/content/research/ai-companies.sources.json +++ b/content/research/ai-companies.sources.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-14", + "version": "2026-10-30-15", "scopeConfirmedAt": "2026-10-30", "acceptance": "以确认的公开来源清单为边界,逐条核对并标明缺项。", "provenance": "S01—S12恢复自已提交用户的《AI公司研究_来源与分类模板》来源清单;S13—S16单列后续已使用的补充证据渠道,不将使用次数当作全量覆盖。", @@ -60,8 +60,8 @@ "producthunt.com" ], "initialAccess": "官网可读取", - "boundary": "两年公开响应各19条,共38条。当前4条关联公司、2条范围外对照、23条范围外/既有业务、7条年份待核、2条推广。旧版本、品牌与法律主体分开,Happycapy仍待核;非两年全量。", - "nextCheck": "继续补两年其余分页/日期、仅图片位置及7条年份待核候选;保留Pazi日期冲突,核清品牌与主体,避免转移历史业绩。", + "boundary": "两年公开响应各19条,共38条。当前4条关联公司、2条范围外对照、24条范围外/既有业务、6条年份待核、2条推广。旧版本、品牌与法律主体分开,Tapflow据2024原版记录排除,Happycapy仍待核;非两年全量。", + "nextCheck": "继续补两年其余分页/日期、仅图片位置及6条年份待核候选;保留Pazi日期冲突,核清品牌与主体,避免转移历史业绩。", "limitation": "上线时间不等于成立时间", "accessRecheckedAt": "2026-10-30" }, diff --git a/content/research/ai-income-analysis.json b/content/research/ai-income-analysis.json index dce53b2..b9b8d29 100644 --- a/content/research/ai-income-analysis.json +++ b/content/research/ai-income-analysis.json @@ -1,5 +1,5 @@ { - "sourceVersion": "2026-10-30-lineage-1", + "sourceVersion": "2026-10-30-lineage-2", "asOf": "2026-10-30", "metric": "platform-mrr-usd", "sampleUnit": "product-or-business", -- 2.52.0 From 96f1417cda077b9db4ba27ef6913e34f8bc3cfd8 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 18:08:01 +0800 Subject: [PATCH 06/10] Add Goldfish with investor-backed founding-year evidence --- apps/docs/scripts/auth-cli.ts | 4 +- apps/docs/tests/research.test.ts | 14 +-- content/research/ai-companies.seed.json | 115 +++++++++++++++++++-- content/research/ai-companies.sources.json | 6 +- content/research/ai-income-analysis.json | 2 +- 5 files changed, 118 insertions(+), 23 deletions(-) diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts index 9d4e5f3..6dcc78a 100644 --- a/apps/docs/scripts/auth-cli.ts +++ b/apps/docs/scripts/auth-cli.ts @@ -181,9 +181,9 @@ async function main() { assert.equal(researchResponse.status, 200, "authenticated research API"); assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store")); const research = await researchResponse.json(); - assert.equal(research.companies.length, 1021); + assert.equal(research.companies.length, 1022); assert.equal(research.categories.length, 11); - assert.equal(research.evidence.length, 1242); + assert.equal(research.evidence.length, 1245); assert.equal(research.observations.length, 189); 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 }); diff --git a/apps/docs/tests/research.test.ts b/apps/docs/tests/research.test.ts index c3bb537..419eaf0 100644 --- a/apps/docs/tests/research.test.ts +++ b/apps/docs/tests/research.test.ts @@ -23,8 +23,8 @@ test("research revisions share the authentication database and preserve replay l for (let i = 0; i < 20; i++) consumeCode(config, "invalid"); assert.equal(consumeCode(config, "invalid"), "limited"); const initial = readResearchSnapshot(); - assert.equal(initial.companies.length, 1021); - assert.equal(initial.evidence.length, 1242); + assert.equal(initial.companies.length, 1022); + assert.equal(initial.evidence.length, 1245); assert.equal(initial.observations.length, 189); assert.deepEqual(initial.candidateBatches, seed.candidateBatches, "candidate gaps persist in the same database metadata"); assert.deepEqual(initial.directoryRechecks, seed.directoryRechecks, "follow-up classifications persist without rewriting the discovery audit"); @@ -64,7 +64,7 @@ test("research validation rejects broken evidence and unsafe source links", () = test("reviews link explicit companies without turning global or excluded cases into companies", () => { const snapshot = structuredClone(seed) as ResearchSnapshot; - assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 57); + assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 58); assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 8); assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 26); const review = snapshot.reviews.find((r) => r.id === "R4")!; @@ -85,11 +85,11 @@ test("reviews link explicit companies without turning global or excluded cases i test("disclosure counts exclude client income, biographies, targets and duplicate milestones", () => { const snapshot = seed as ResearchSnapshot; const summary = categorySummary(snapshot); - assert.equal(summary.reduce((n, c) => n + c.count, 0), 1021); + assert.equal(summary.reduce((n, c) => n + c.count, 0), 1022); assert.equal(summary.reduce((n, c) => n + c.self, 0), 26); assert.equal(summary.reduce((n, c) => n + c.estimate, 0), 1); assert.equal(summary.reduce((n, c) => n + c.platform, 0), 16); - assert.equal(summary.reduce((n, c) => n + c.unknown, 0), 978); + assert.equal(summary.reduce((n, c) => n + c.unknown, 0), 979); assert.equal(snapshot.companies.find((c) => c.name === "Rork")?.revenueStatus, "unknown"); assert.equal(snapshot.companies.find((c) => c.name === "Result")?.revenueStatus, "unknown"); assert.ok(snapshot.observations.filter((o) => o.selection === "signal").every((o) => o.scope === "公司整体")); @@ -97,7 +97,7 @@ test("disclosure counts exclude client income, biographies, targets and duplicat test("technical reviews keep accessible materials separate from tested capabilities", () => { const snapshot = structuredClone(seed) as ResearchSnapshot; - assert.equal(snapshot.companies.filter((c) => c.technicalReview).length, 14); + assert.equal(snapshot.companies.filter((c) => c.technicalReview).length, 15); assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "public-artifact").length, 6); assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "independent-test").length, 1); assert.equal(snapshot.companies.find((c) => c.id === "AI051")?.revenueStatus, "unknown"); @@ -307,7 +307,7 @@ test("unreconciled directory financials remain source reviews instead of income assert.ok(review.evidenceIds.includes("PH-TANA-DR")); assert.equal(reviewCompanyIds(snapshot, review).length, 0); assert.ok(!snapshot.observations.some((o) => o.evidence === "PH-TANA-DR")); - assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 9); + assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 8); }); test("product pivots and founder histories do not become new company years or current revenue", () => { diff --git a/content/research/ai-companies.seed.json b/content/research/ai-companies.seed.json index f1c35c5..f9fb801 100644 --- a/content/research/ai-companies.seed.json +++ b/content/research/ai-companies.seed.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-lineage-2", + "version": "2026-10-30-goldfish-1", "asOf": "2026-10-30", "scope": "全球公开资料研究:保留首批个案,并扩展核查2025—2026八个YC批次候选池;成立年份以官方公司页面字段为依据。", "limitations": [ @@ -28869,6 +28869,47 @@ "WABI-TERMS" ] } + }, + { + "id": "AI078", + "name": "Goldfish", + "url": "https://goldfish.sh/", + "cat": "C09", + "year": 2026, + "team": null, + "location": "公开材料关联旧金山创业项目;法律总部待核", + "business": "桌面个人上下文记忆与写作助手,读取系统辅助功能文本并结合本地记忆生成回复,可为AI工具提供上下文。", + "buyer": "个人知识工作者;企业采购与实际付费客户待核", + "delivery": "官网提供macOS和Windows下载,公开产品文档可读;未安装或独立测试效果、隐私承诺及成本。", + "notes": "成立2026依据Entrepreneurs First投资组合页,创始人与官方社区页一致;精确注册日未核。创始人此前Strawberry收入及VR教育业务ARR不是Goldfish收入。官网点击数不是用户或付费客户数,免费测试不等于公司收入为零。", + "cohort": "2026年成立(投资机构名录);注册日期未独立核验", + "teamBasis": "公开两位创始人不等于完整员工人数,保留未知", + "price": "截至2026-10-30官网称测试期间免费,未来定价与企业合同待核", + "stage": "已公开下载的测试产品;商业收费与财务数据未知", + "sourceType": "Product Hunt发现;官方产品/条款/文档及EF投资组合页补证", + "evidenceIds": [ + "GOLD-HOME", + "GOLD-TERMS", + "GOLD-PRIVACY", + "PH-GOLD", + "GOLD-EF", + "GOLD-CREATORS", + "GOLD-DOCS" + ], + "revenueStatus": "unknown", + "businessModel": "个人AI记忆与助手;当前免费测试,付费模式待核", + "entityBasis": "官方条款与页脚列Goldfish AI Inc.,EF与官方社区页均列Joel Edholm、Kaspian Jakobsson并关联goldfish.sh;不与同名语音产品及创始人旧公司合并。", + "classificationBasis": "按个人桌面上下文与写作场景归C09;MCP为上下文交付方式,不据此归为基础模型或企业开发工具。", + "pricingEvidence": "GOLD-HOME", + "technicalReview": { + "level": "description", + "reviewedAt": "2026-10-30", + "summary": "已读系统辅助功能文本读取、本地存储与外部AI调用的官方文档;没有独立测试或可核源代码,本地记忆不表示推理全部本地运行。", + "evidenceIds": [ + "GOLD-DOCS", + "GOLD-PRIVACY" + ] + } } ], "observations": [ @@ -44755,18 +44796,18 @@ }, { "id": "GOLD-HOME", - "companyId": null, + "companyId": "AI078", "title": "Goldfish官方产品及免费测试状态", "url": "https://goldfish.sh/", "publishedAt": null, "accessedAt": "2026-10-30", "kind": "公司官方材料", "summary": "官网称本地上下文记忆助手,测试期间免费,页脚列Goldfish AI Inc.。", - "limitation": "测试免费不表示公司收入为零;点击计数不是用户数、付费数或MRR,成立年未核。" + "limitation": "测试免费不表示公司收入为零;点击计数不是用户数、付费数或MRR;成立年另由EF名录补证。" }, { "id": "GOLD-TERMS", - "companyId": null, + "companyId": "AI078", "title": "Goldfish条款与运营者", "url": "https://goldfish.sh/tos", "publishedAt": null, @@ -44777,7 +44818,7 @@ }, { "id": "GOLD-PRIVACY", - "companyId": null, + "companyId": "AI078", "title": "Goldfish隐私与数据处理说明", "url": "https://goldfish.sh/privacy", "publishedAt": null, @@ -44821,7 +44862,7 @@ }, { "id": "PH-GOLD", - "companyId": null, + "companyId": "AI078", "title": "Goldfish产品发布线索", "url": "https://www.producthunt.com/products/goldfish-early-access", "publishedAt": null, @@ -45599,6 +45640,39 @@ "kind": "公司公开主页自述", "summary": "公开主页列Founded 2025,并将mgx.dev列为商业平台,关联MetaGPT开源框架与后续Atoms品牌。", "limitation": "自述年份不是注册证明;尚未消歧MetaGPT LLC、DeepWisdom与产品团队的主体关系及官网更早沿革。融资金额不是收入,关注数与页面关联人员数不是员工总人数。Atoms展示页和DeepWisdom关联页本次读取失败。" + }, + { + "id": "GOLD-EF", + "companyId": "AI078", + "title": "EF投资组合Goldfish成立年与创始人", + "url": "https://www.joinef.com/companies/goldfish/?pagenum=2", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "投资机构公司名录", + "summary": "页面标注Founded 2026、Joel Edholm和Kaspian Jakobsson,并链接goldfish.sh。", + "limitation": "机构与投资对象有利益关系,年份非独立注册核验;创始人此前Strawberry收入和VR教育企业ARR不能转作Goldfish收入。无查询参数的入口本次重定向组合首页,保留实际可读URL。" + }, + { + "id": "GOLD-CREATORS", + "companyId": "AI078", + "title": "Goldfish官方社区与创始人对应", + "url": "https://docs.goldfish.sh/community/", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司产品文档", + "summary": "官方社区页列Joel Edholm与Kaspian Jakobsson,与EF页面姓名一致。", + "limitation": "两位创始人不等完整员工数;社区加入数与创始人关注者不作客户或收入。" + }, + { + "id": "GOLD-DOCS", + "companyId": "AI078", + "title": "Goldfish官方架构与平台说明", + "url": "https://docs.goldfish.sh/", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司产品文档", + "summary": "说明经操作系统辅助功能读取屏幕文字、设备本地存储记忆,运行于macOS与Windows,并提供写作与上下文功能。", + "limitation": "属于产品描述,非独立测试或安全审计;本地记忆不等所有模型推理在本地,不以文档可读判定技术领先。" } ], "reviews": [ @@ -46903,6 +46977,23 @@ ], "handling": "保留年份待核,需补同一法律主体或独立新公司的成立依据;不转移旧产品收入、开源使用量、团队规模或融资为ARR。", "scope": "excluded-candidate" + }, + { + "id": "GOLD-R-COHORT", + "type": "成立依据与财务边界", + "company": "Goldfish", + "companyIds": [ + "AI078" + ], + "summary": "EF名录2026年份、官方创始人与产品域名一致,纳入公司表;公开材料没有Goldfish收入数值。", + "evidenceIds": [ + "GOLD-EF", + "GOLD-CREATORS", + "GOLD-HOME", + "GOLD-PRIVACY" + ], + "handling": "收入、完整团队规模与精确注册日留缺;不将创始人历史业绩、点击数或免费测试推算为当前ARR、客户数或零收入。", + "scope": "companies" } ], "coverage": { @@ -47025,14 +47116,18 @@ "categoryId": "C09", "kind": "product", "profileStatus": "readable", - "status": "pending-year", - "note": "运营者Goldfish AI Inc.,成立年未核;政策生效日不当成立日。测试期间免费不说明收入为零,点击计数不等于客户数或MRR。", + "status": "linked", + "note": "据EF投资组合页Founded 2026及官方创始人/域名关联纳入AI078;精确注册日、完整团队和收入留缺。免费测试不作零收入,创始人旧公司业绩不转移。", "evidenceIds": [ "GOLD-HOME", "GOLD-TERMS", "GOLD-PRIVACY", - "PH-GOLD" - ] + "PH-GOLD", + "GOLD-EF", + "GOLD-CREATORS", + "GOLD-DOCS" + ], + "companyId": "AI078" }, { "id": "PH26-08", diff --git a/content/research/ai-companies.sources.json b/content/research/ai-companies.sources.json index 40771cf..d69e607 100644 --- a/content/research/ai-companies.sources.json +++ b/content/research/ai-companies.sources.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-15", + "version": "2026-10-30-16", "scopeConfirmedAt": "2026-10-30", "acceptance": "以确认的公开来源清单为边界,逐条核对并标明缺项。", "provenance": "S01—S12恢复自已提交用户的《AI公司研究_来源与分类模板》来源清单;S13—S16单列后续已使用的补充证据渠道,不将使用次数当作全量覆盖。", @@ -60,8 +60,8 @@ "producthunt.com" ], "initialAccess": "官网可读取", - "boundary": "两年公开响应各19条,共38条。当前4条关联公司、2条范围外对照、24条范围外/既有业务、6条年份待核、2条推广。旧版本、品牌与法律主体分开,Tapflow据2024原版记录排除,Happycapy仍待核;非两年全量。", - "nextCheck": "继续补两年其余分页/日期、仅图片位置及6条年份待核候选;保留Pazi日期冲突,核清品牌与主体,避免转移历史业绩。", + "boundary": "两年公开响应各19条,共38条。当前5条关联公司、2条范围外对照、24条范围外/既有业务、5条年份待核、2条推广。旧版本、品牌与法律主体分开,Tapflow据2024原版记录排除,Happycapy仍待核;非两年全量。", + "nextCheck": "继续补两年其余分页/日期、仅图片位置及5条年份待核候选;保留Pazi日期冲突,核清品牌与主体,避免转移历史业绩。", "limitation": "上线时间不等于成立时间", "accessRecheckedAt": "2026-10-30" }, diff --git a/content/research/ai-income-analysis.json b/content/research/ai-income-analysis.json index b9b8d29..4561e7a 100644 --- a/content/research/ai-income-analysis.json +++ b/content/research/ai-income-analysis.json @@ -1,5 +1,5 @@ { - "sourceVersion": "2026-10-30-lineage-2", + "sourceVersion": "2026-10-30-goldfish-1", "asOf": "2026-10-30", "metric": "platform-mrr-usd", "sampleUnit": "product-or-business", -- 2.52.0 From e65d90c78b0beda9e68843907f7d656c9fd5dcb6 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 18:10:35 +0800 Subject: [PATCH 07/10] Resolve Utopai founding history and preserve Fundraisly entity gaps --- apps/docs/scripts/auth-cli.ts | 2 +- apps/docs/tests/research.test.ts | 8 +-- content/research/ai-companies.seed.json | 61 +++++++++++++++++++--- content/research/ai-companies.sources.json | 6 +-- content/research/ai-income-analysis.json | 2 +- 5 files changed, 64 insertions(+), 15 deletions(-) diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts index 6dcc78a..afbc7e7 100644 --- a/apps/docs/scripts/auth-cli.ts +++ b/apps/docs/scripts/auth-cli.ts @@ -183,7 +183,7 @@ async function main() { const research = await researchResponse.json(); assert.equal(research.companies.length, 1022); assert.equal(research.categories.length, 11); - assert.equal(research.evidence.length, 1245); + assert.equal(research.evidence.length, 1248); assert.equal(research.observations.length, 189); 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 }); diff --git a/apps/docs/tests/research.test.ts b/apps/docs/tests/research.test.ts index 419eaf0..2854acc 100644 --- a/apps/docs/tests/research.test.ts +++ b/apps/docs/tests/research.test.ts @@ -24,7 +24,7 @@ test("research revisions share the authentication database and preserve replay l assert.equal(consumeCode(config, "invalid"), "limited"); const initial = readResearchSnapshot(); assert.equal(initial.companies.length, 1022); - assert.equal(initial.evidence.length, 1245); + assert.equal(initial.evidence.length, 1248); assert.equal(initial.observations.length, 189); assert.deepEqual(initial.candidateBatches, seed.candidateBatches, "candidate gaps persist in the same database metadata"); assert.deepEqual(initial.directoryRechecks, seed.directoryRechecks, "follow-up classifications persist without rewriting the discovery audit"); @@ -66,7 +66,7 @@ test("reviews link explicit companies without turning global or excluded cases i const snapshot = structuredClone(seed) as ResearchSnapshot; assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 58); assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 8); - assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 26); + assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 27); const review = snapshot.reviews.find((r) => r.id === "R4")!; assert.deepEqual(reviewCompanyIds(snapshot, review), ["AI007", "AI013", "AI021", "AI034"]); assert.deepEqual(reviewCompanyIds(snapshot, snapshot.reviews.find((r) => r.id === "CN-R-PSI")!), []); @@ -307,7 +307,7 @@ test("unreconciled directory financials remain source reviews instead of income assert.ok(review.evidenceIds.includes("PH-TANA-DR")); assert.equal(reviewCompanyIds(snapshot, review).length, 0); assert.ok(!snapshot.observations.some((o) => o.evidence === "PH-TANA-DR")); - assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 8); + assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 7); }); test("product pivots and founder histories do not become new company years or current revenue", () => { @@ -342,7 +342,7 @@ test("Chinese discovery entries preserve unread articles and use separate primar assert.equal(batch.records.length, 4); assert.ok(batch.records.every((row) => row.kind === "company" && row.profileStatus === "unavailable")); assert.equal(batch.records.filter((row) => row.status === "linked").length, 2); - assert.equal(batch.records.find((row) => row.name === "Utopai")?.status, "pending-year"); + assert.equal(batch.records.find((row) => row.name === "Utopai")?.status, "outside-cohort"); assert.ok(!snapshot.observations.some((o) => ["PS-CODE", "SG-START", "SG-APR", "SSI-HISTORY"].includes(o.evidence)), "task costs, investment, capacity and financing are not revenue"); const prism = snapshot.companies.find((c) => c.id === "AI075")!; assert.equal(prism.team, null, "a team range or linked employees must not become exact headcount"); diff --git a/content/research/ai-companies.seed.json b/content/research/ai-companies.seed.json index f9fb801..8e0bc78 100644 --- a/content/research/ai-companies.seed.json +++ b/content/research/ai-companies.seed.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-goldfish-1", + "version": "2026-10-30-utopai-1", "asOf": "2026-10-30", "scope": "全球公开资料研究:保留首批个案,并扩展核查2025—2026八个YC批次候选池;成立年份以官方公司页面字段为依据。", "limitations": [ @@ -45673,6 +45673,39 @@ "kind": "公司产品文档", "summary": "说明经操作系统辅助功能读取屏幕文字、设备本地存储记忆,运行于macOS与Windows,并提供写作与上下文功能。", "limitation": "属于产品描述,非独立测试或安全审计;本地记忆不等所有模型推理在本地,不以文档可读判定技术领先。" + }, + { + "id": "UTOPAI-HISTORY", + "companyId": null, + "title": "Utopai官方成立与品牌沿革", + "url": "https://www.utopaistudios.com/en/about", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "官网明确Founded 2022,历史段说明以Cybever起步,2025转为Utopai Studios,延续既有技术并拓展影视制作。", + "limitation": "公司自述非注册证明;2025更名、2026产品/模型发布与未来影视片单不等新公司、已确认收入或ARR,团队Members不是统一员工口径。" + }, + { + "id": "UTOPAI-FAQ", + "companyId": null, + "title": "Utopai公司与PAI产品边界", + "url": "https://www.utopaistudios.com/en/faq", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "FAQ区分Utopai Studios公司、PAI制作平台与其中的Utopai X视频模型,并说明自有/合拍影视业务。", + "limitation": "产品、模型、区域制作业务不各自新增公司;官方对比不当独立技术评测,影片计划或合作不当已确认公司收入。" + }, + { + "id": "PH-FUNDRAISLY-PROFILE", + "companyId": null, + "title": "Fundraisly公开主页的Accumulator关系", + "url": "https://www.linkedin.com/company/fundraisly", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司公开主页自述", + "summary": "公开主页链接fundraisly.com,称属于Accumulator,结合投资者匹配、引荐路径与外联服务。页面没有成立年份字段。", + "limitation": "业务与关联集团关系不能替代Accumulator Fundraising LLC的注册年份;页面2—10人数范围及关联档案数不当精确团队人数,团队筹资和客户融资不当收入。" } ], "reviews": [ @@ -46994,6 +47027,19 @@ ], "handling": "收入、完整团队规模与精确注册日留缺;不将创始人历史业绩、点击数或免费测试推算为当前ARR、客户数或零收入。", "scope": "companies" + }, + { + "id": "UTOPAI-R-HISTORY", + "type": "更名与成立年边界", + "company": "Utopai Studios(既有公司候选)", + "summary": "官网自述2022创立Cybever、2025更名并拓展影视制作,非2025—2026新公司。", + "evidenceIds": [ + "JQ-UTOPAI", + "UTOPAI-HISTORY", + "UTOPAI-FAQ" + ], + "handling": "按既有业务排除;保留中文报道未读全文的访问缺项。品牌、模型、制作平台与区域业务不重复计数,估值、片单及排名不导入收入观测。", + "scope": "excluded-candidate" } ], "coverage": { @@ -47026,11 +47072,12 @@ "kind": "product", "profileStatus": "readable", "status": "pending-year", - "note": "运营主体Accumulator Fundraising LLC;成立年待核。固定/经常性或按通话的服务收费需拆分;Gett历史ARR、创始人及客户筹资不作Fundraisly收入。", + "note": "运营主体Accumulator Fundraising LLC;成立年待核。固定/经常性或按通话的服务收费需拆分;Gett历史ARR、创始人及客户筹资不作Fundraisly收入。 公司公开主页称属于Accumulator,但无成立年份字段,保持待核。", "evidenceIds": [ "PH-FUNDRAISLY-ABOUT", "PH-FUNDRAISLY-ENTITY", - "PH-FUNDRAISLY-PLAN" + "PH-FUNDRAISLY-PLAN", + "PH-FUNDRAISLY-PROFILE" ] }, { @@ -47660,11 +47707,13 @@ "categoryId": "C08", "kind": "company", "profileStatus": "unavailable", - "status": "pending-year", + "status": "outside-cohort", "evidenceIds": [ - "JQ-UTOPAI" + "JQ-UTOPAI", + "UTOPAI-HISTORY", + "UTOPAI-FAQ" ], - "note": "搜索摘录有公司与成立线索,但直达仅登录外壳,原始成立、主体和业务材料未补齐,不把估值、片单或模型排名作为公司收入或独立技术验证。" + "note": "官网自述2022以Cybever成立、2025更名Utopai并转向影视制作;2026品牌/模型报道不作新公司成立年。作为既有业务排除;中文报道仍未读全文,融资估值、模型排名及未来片单不作收入。" } ] }, diff --git a/content/research/ai-companies.sources.json b/content/research/ai-companies.sources.json index d69e607..0293dcf 100644 --- a/content/research/ai-companies.sources.json +++ b/content/research/ai-companies.sources.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-16", + "version": "2026-10-30-17", "scopeConfirmedAt": "2026-10-30", "acceptance": "以确认的公开来源清单为边界,逐条核对并标明缺项。", "provenance": "S01—S12恢复自已提交用户的《AI公司研究_来源与分类模板》来源清单;S13—S16单列后续已使用的补充证据渠道,不将使用次数当作全量覆盖。", @@ -204,8 +204,8 @@ "pro.jiqizhixin.com" ], "initialAccess": "首页可读取", - "boundary": "已登记4篇定向检索线索;三篇直达仅登录外壳,一篇读取失败。2项经公开原始资料补证关联公司,1项范围外,1项仍待核;未阅读全文或穷尽两年档案。", - "nextCheck": "补公开可读文章、2025档案及Utopai原始成立材料;受限正文留缺,不以搜索摘录代全文。", + "boundary": "已登记4篇定向检索线索;三篇直达仅登录外壳,一篇读取失败。2项经公开原始资料关联公司,SSI与Utopai经原始历史材料判为范围外,0项成立年待核;未阅读全文或穷尽两年档案。", + "nextCheck": "补公开可读文章及2025—2026档案;受限正文留缺,不以搜索摘录代全文,更名与产品发布不新增公司。", "limitation": "全文访问受限;仅作发现时明确标识,技术和财务另追原始资料", "accessRecheckedAt": "2026-10-30" }, diff --git a/content/research/ai-income-analysis.json b/content/research/ai-income-analysis.json index 4561e7a..6acde9d 100644 --- a/content/research/ai-income-analysis.json +++ b/content/research/ai-income-analysis.json @@ -1,5 +1,5 @@ { - "sourceVersion": "2026-10-30-goldfish-1", + "sourceVersion": "2026-10-30-utopai-1", "asOf": "2026-10-30", "metric": "platform-mrr-usd", "sampleUnit": "product-or-business", -- 2.52.0 From f156406265a45e5ecca24e5913dc6c983c6a4f55 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 18:12:52 +0800 Subject: [PATCH 08/10] Classify Thrive services rollup and exclude Brila existing operator --- apps/docs/scripts/auth-cli.ts | 4 +- apps/docs/tests/research.test.ts | 16 +-- content/research/ai-companies.seed.json | 133 +++++++++++++++++++-- content/research/ai-companies.sources.json | 10 +- content/research/ai-income-analysis.json | 2 +- 5 files changed, 136 insertions(+), 29 deletions(-) diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts index afbc7e7..46f1524 100644 --- a/apps/docs/scripts/auth-cli.ts +++ b/apps/docs/scripts/auth-cli.ts @@ -181,9 +181,9 @@ async function main() { assert.equal(researchResponse.status, 200, "authenticated research API"); assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store")); const research = await researchResponse.json(); - assert.equal(research.companies.length, 1022); + assert.equal(research.companies.length, 1023); assert.equal(research.categories.length, 11); - assert.equal(research.evidence.length, 1248); + assert.equal(research.evidence.length, 1251); assert.equal(research.observations.length, 189); 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 }); diff --git a/apps/docs/tests/research.test.ts b/apps/docs/tests/research.test.ts index 2854acc..d03f46a 100644 --- a/apps/docs/tests/research.test.ts +++ b/apps/docs/tests/research.test.ts @@ -23,8 +23,8 @@ test("research revisions share the authentication database and preserve replay l for (let i = 0; i < 20; i++) consumeCode(config, "invalid"); assert.equal(consumeCode(config, "invalid"), "limited"); const initial = readResearchSnapshot(); - assert.equal(initial.companies.length, 1022); - assert.equal(initial.evidence.length, 1248); + assert.equal(initial.companies.length, 1023); + assert.equal(initial.evidence.length, 1251); assert.equal(initial.observations.length, 189); assert.deepEqual(initial.candidateBatches, seed.candidateBatches, "candidate gaps persist in the same database metadata"); assert.deepEqual(initial.directoryRechecks, seed.directoryRechecks, "follow-up classifications persist without rewriting the discovery audit"); @@ -64,9 +64,9 @@ test("research validation rejects broken evidence and unsafe source links", () = test("reviews link explicit companies without turning global or excluded cases into companies", () => { const snapshot = structuredClone(seed) as ResearchSnapshot; - assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 58); + assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 59); assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 8); - assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 27); + assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 28); const review = snapshot.reviews.find((r) => r.id === "R4")!; assert.deepEqual(reviewCompanyIds(snapshot, review), ["AI007", "AI013", "AI021", "AI034"]); assert.deepEqual(reviewCompanyIds(snapshot, snapshot.reviews.find((r) => r.id === "CN-R-PSI")!), []); @@ -85,11 +85,11 @@ test("reviews link explicit companies without turning global or excluded cases i test("disclosure counts exclude client income, biographies, targets and duplicate milestones", () => { const snapshot = seed as ResearchSnapshot; const summary = categorySummary(snapshot); - assert.equal(summary.reduce((n, c) => n + c.count, 0), 1022); + assert.equal(summary.reduce((n, c) => n + c.count, 0), 1023); assert.equal(summary.reduce((n, c) => n + c.self, 0), 26); assert.equal(summary.reduce((n, c) => n + c.estimate, 0), 1); assert.equal(summary.reduce((n, c) => n + c.platform, 0), 16); - assert.equal(summary.reduce((n, c) => n + c.unknown, 0), 979); + assert.equal(summary.reduce((n, c) => n + c.unknown, 0), 980); assert.equal(snapshot.companies.find((c) => c.name === "Rork")?.revenueStatus, "unknown"); assert.equal(snapshot.companies.find((c) => c.name === "Result")?.revenueStatus, "unknown"); assert.ok(snapshot.observations.filter((o) => o.selection === "signal").every((o) => o.scope === "公司整体")); @@ -97,7 +97,7 @@ test("disclosure counts exclude client income, biographies, targets and duplicat test("technical reviews keep accessible materials separate from tested capabilities", () => { const snapshot = structuredClone(seed) as ResearchSnapshot; - assert.equal(snapshot.companies.filter((c) => c.technicalReview).length, 15); + assert.equal(snapshot.companies.filter((c) => c.technicalReview).length, 16); assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "public-artifact").length, 6); assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "independent-test").length, 1); assert.equal(snapshot.companies.find((c) => c.id === "AI051")?.revenueStatus, "unknown"); @@ -307,7 +307,7 @@ test("unreconciled directory financials remain source reviews instead of income assert.ok(review.evidenceIds.includes("PH-TANA-DR")); assert.equal(reviewCompanyIds(snapshot, review).length, 0); assert.ok(!snapshot.observations.some((o) => o.evidence === "PH-TANA-DR")); - assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 7); + assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 5); }); test("product pivots and founder histories do not become new company years or current revenue", () => { diff --git a/content/research/ai-companies.seed.json b/content/research/ai-companies.seed.json index 8e0bc78..2964c5f 100644 --- a/content/research/ai-companies.seed.json +++ b/content/research/ai-companies.seed.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-utopai-1", + "version": "2026-10-30-thrive-brila-1", "asOf": "2026-10-30", "scope": "全球公开资料研究:保留首批个案,并扩展核查2025—2026八个YC批次候选池;成立年份以官方公司页面字段为依据。", "limitations": [ @@ -28910,6 +28910,45 @@ "GOLD-PRIVACY" ] } + }, + { + "id": "AI079", + "name": "Thrive Holdings", + "url": "https://www.thriveholdings.com/", + "cat": "C07", + "year": 2025, + "team": null, + "location": "美国纽约;集团官网标注New York City", + "business": "收购并长期运营会计和IT等成熟服务企业,将AI和技术团队嵌入其交付流程。", + "buyer": "被持有企业的会计、IT等服务客户;集团直接软件合同与实际付费结构待核", + "delivery": "已公开描述会计与IT服务平台及合作关系;没有本轮独立交付、成本节约或技术性能测试。", + "notes": "成立2025据公司公开主页,与2025-04-29官方发布公告关联;精确注册日及法律主体链待核。收购/并表带来的规模不等有机AI收入或ARR。ADT交易预计2027年一季度交割,当前不记完成,不合并目标公司历史收入。", + "cohort": "2025年成立(公司公开主页自述);注册日期未独立核验", + "teamBasis": "主页11—50范围、关联档案数与集团所持企业员工口径不同,完整团队保留未知", + "price": "集团没有本轮可核统一软件订阅价;会计、IT项目/托管服务等按子公司合同核实", + "stage": "公开运营会计与IT服务控股平台;按集团/子公司、收购/有机边界另核财务", + "sourceType": "TechCrunch发现;官方业务、发布公告、公司主页和交易/合作当事方材料补证", + "evidenceIds": [ + "TC-THRIVE", + "TH-ABOUT", + "TH-OPENAI", + "TH-ADT", + "TH-LAUNCH", + "TH-PROFILE" + ], + "revenueStatus": "unknown", + "businessModel": "永久资本控股平台收购服务企业,以行业人员加AI改造交付;集团及软件/服务收入拆分待核", + "entityBasis": "thriveholdings.com与公司公开主页、合作方和ADT交易公告相互关联;不与同名乌干达公关企业、其他Thrive Holdings LLC或Thrive Capital历史混合。", + "classificationBasis": "按会计和IT专业服务的运营改造归C07;控股/收购是商业模式,不能当纯软件平台并入同一ARR比较组。", + "technicalReview": { + "level": "description", + "reviewedAt": "2026-10-30", + "summary": "官方材料说明将AI模型与行业人员、流程结合;本轮未测试模型或财务改善,合作关系不替代能力验证。", + "evidenceIds": [ + "TH-ABOUT", + "TH-OPENAI" + ] + } } ], "observations": [ @@ -45049,7 +45088,7 @@ }, { "id": "TC-THRIVE", - "companyId": null, + "companyId": "AI079", "title": "Thrive Holdings报道发现条目", "url": "https://techcrunch.com/2026/10/29/openai-backed-thrive-holdings-acquires-adt/", "publishedAt": "2026-10-29", @@ -45115,18 +45154,18 @@ }, { "id": "TH-ABOUT", - "companyId": null, + "companyId": "AI079", "title": "Thrive Holdings官方业务说明", "url": "https://www.thriveholdings.com/about", "publishedAt": null, "accessedAt": "2026-10-30", "kind": "公司官方材料", "summary": "公司称收购成熟企业,将行业人员经验与技术和运营结合,目前涉及会计与IT服务。", - "limitation": "控股平台、收购组合与原有企业的经营收入须区分;本次未取得明确成立年份或并表收入凭证。" + "limitation": "控股平台、收购组合与原有企业经营收入须区分;年份另据公司主页补证,本次未取得并表收入或注册凭证。" }, { "id": "TH-OPENAI", - "companyId": null, + "companyId": "AI079", "title": "Thrive Holdings合作方2025年公告", "url": "https://openai.com/index/thrive-holdings/", "publishedAt": "2025-12-01", @@ -45137,7 +45176,7 @@ }, { "id": "TH-ADT", - "companyId": null, + "companyId": "AI079", "title": "ADT拟收购交易的原始公告", "url": "https://investor.adt.com/News--Events/news/news-details/2026/ADT-to-be-Acquired-by-Thrive-Holdings-in-15-3-Billion-Transaction/default.aspx", "publishedAt": "2026-10-29", @@ -45181,7 +45220,7 @@ }, { "id": "TH-LAUNCH", - "companyId": null, + "companyId": "AI079", "title": "Thrive Holdings官方2025年发布公告", "url": "https://www.thriveholdings.com/thrive-holdings-launch", "publishedAt": "2025-04-29", @@ -45706,6 +45745,39 @@ "kind": "公司公开主页自述", "summary": "公开主页链接fundraisly.com,称属于Accumulator,结合投资者匹配、引荐路径与外联服务。页面没有成立年份字段。", "limitation": "业务与关联集团关系不能替代Accumulator Fundraising LLC的注册年份;页面2—10人数范围及关联档案数不当精确团队人数,团队筹资和客户融资不当收入。" + }, + { + "id": "TH-PROFILE", + "companyId": "AI079", + "title": "Thrive Holdings公司公开主页年份", + "url": "https://www.linkedin.com/company/thrive-holdings", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司公开主页自述", + "summary": "页面列Founded 2025,链接thriveholdings.com,描述收购并运营会计与IT服务企业。", + "limitation": "非独立注册核验;11—50范围、关联档案数不等集团员工数。所持企业原有收入与收购规模不能当有机AI增长或软件ARR。" + }, + { + "id": "BRILA-ENTITY-HISTORY", + "companyId": null, + "title": "Generated Photos早期政策的同一运营主体", + "url": "https://intercom.help/generated-photos/en/articles/7241273-generated-photos-privacy-policy", + "publishedAt": "2024-02-14", + "accessedAt": "2026-10-30", + "kind": "公司官方历史政策", + "summary": "页面日期2024-02-14,列Generated Media, Inc.及Delaware注册号7627361,与Brila官方政策同名同号。", + "limitation": "早期材料可证明该运营主体并非2025—2026新公司;政策日期和其中2020生效日都不当精确注册年。旧产品收入、人数和品牌历史不直接转作Brila收入。" + }, + { + "id": "BRILA-ENTITY-TERMS", + "companyId": null, + "title": "Generated Photos早期服务条款", + "url": "https://intercom.help/generated-photos/en/articles/7872726-generated-photos-terms-and-conditions", + "publishedAt": "2023-10-16", + "accessedAt": "2026-10-30", + "kind": "公司官方历史条款", + "summary": "官方条款页面日期2023-10-16,服务合同方列Generated Media, Inc.,业务为生成图像许可。", + "limitation": "单看同名条款不唯一识别主体,需与注册号7627361的隐私政策及Brila政策合看;不能把图像许可收入当新建站产品ARR。" } ], "reviews": [ @@ -47040,6 +47112,37 @@ ], "handling": "按既有业务排除;保留中文报道未读全文的访问缺项。品牌、模型、制作平台与区域业务不重复计数,估值、片单及排名不导入收入观测。", "scope": "excluded-candidate" + }, + { + "id": "TH-R-CONSOLIDATION", + "type": "控股平台成立与并表收入边界", + "company": "Thrive Holdings", + "companyIds": [ + "AI079" + ], + "summary": "2025自述成立年与官方发布材料关联,纳入专业服务改造模式;没有可核同口径集团ARR。", + "evidenceIds": [ + "TH-PROFILE", + "TH-LAUNCH", + "TH-ABOUT", + "TH-ADT" + ], + "handling": "精确注册、完整团队、付费合同及并表拆分留缺;收购资金、集团持有公司数、目标公司历史收入、预期交易交割不转为当前有机AI收入或软件ARR。", + "scope": "companies" + }, + { + "id": "BRILA-R-OLD-ENTITY", + "type": "旧运营主体的新产品边界", + "company": "Brila(既有主体产品候选)", + "summary": "Brila与Generated Photos官方政策列同一Generated Media, Inc.注册号7627361,早期材料先于2025。", + "evidenceIds": [ + "PH-BRILA-ENTITY", + "PH-BRILA-TERMS", + "BRILA-ENTITY-HISTORY", + "BRILA-ENTITY-TERMS" + ], + "handling": "归范围外既有主体;保留精确注册日未知,不因产品发布新增公司,不将图像业务收入或团队规模转作Brila建站业务。", + "scope": "excluded-candidate" } ], "coverage": { @@ -47101,11 +47204,13 @@ "categoryId": "C04", "kind": "product", "profileStatus": "readable", - "status": "pending-year", - "note": "官网及条款列Generated Media, Inc.运营;注册号不代表日期,母公司成立年待核。网站生成与托管订阅为产品业务,不因2026发布新增独立公司。", + "status": "outside-cohort", + "note": "官方运营者Generated Media, Inc.注册号7627361与Generated Photos的2024政策一致,且有2023旧业务条款;作为既有主体产品排除。政策日期不作精确注册年,旧图像业务业绩不转移给Brila。", "evidenceIds": [ "PH-BRILA-ENTITY", - "PH-BRILA-TERMS" + "PH-BRILA-TERMS", + "BRILA-ENTITY-HISTORY", + "BRILA-ENTITY-TERMS" ] }, { @@ -47768,15 +47873,17 @@ "categoryId": "C07", "kind": "company", "profileStatus": "readable", - "status": "pending-year", + "status": "linked", "evidenceIds": [ "TC-THRIVE", "TH-ABOUT", "TH-OPENAI", "TH-ADT", - "TH-LAUNCH" + "TH-LAUNCH", + "TH-PROFILE" ], - "note": "官方2025-04-29公告证明平台公开发布,不能单独填精确成立年,继续待核。按专业服务收购运营初分C07,控股模式单列;ADT预计2027一季度交割,不记已完成,不将目标历史收入当有机AI收入。" + "note": "公司公开主页Founded 2025,结合官方发布及业务关联纳入AI079。按专业服务控股改造模式单列;收购/并表不等有机AI ARR,ADT拟收购尚未交割,精确注册与收入留缺。", + "companyId": "AI079" } ] } diff --git a/content/research/ai-companies.sources.json b/content/research/ai-companies.sources.json index 0293dcf..0023b3c 100644 --- a/content/research/ai-companies.sources.json +++ b/content/research/ai-companies.sources.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-17", + "version": "2026-10-30-18", "scopeConfirmedAt": "2026-10-30", "acceptance": "以确认的公开来源清单为边界,逐条核对并标明缺项。", "provenance": "S01—S12恢复自已提交用户的《AI公司研究_来源与分类模板》来源清单;S13—S16单列后续已使用的补充证据渠道,不将使用次数当作全量覆盖。", @@ -28,8 +28,8 @@ "techcrunch.com" ], "initialAccess": "AI栏目可读取", - "boundary": "栏目首页选择的3条公司线索已追溯原始资料:Wabi据公开名录2025年份关联公司;DreamHQ与Thrive Holdings成立年待核,后者2025发布公告另存。不是首页全部条目或两年档案;公开分类接口与RSS普通HTTP请求返回403。", - "nextCheck": "补可公开取得的2025、2026档案及剩余分页;核DreamHQ、Thrive Holdings成立依据及Wabi精确注册、团队和付费口径。", + "boundary": "栏目首页选择的3条公司线索已追溯原始资料:Wabi据公开名录2025年份、Thrive Holdings据公司主页2025自述关联公司,DreamHQ成立年仍待核。Thrive控股改造与收购/有机收入边界单列;非首页全部条目或两年档案,公开分类接口与RSS普通HTTP请求返回403。", + "nextCheck": "补可公开取得的2025、2026档案及剩余分页;核DreamHQ成立依据与Wabi、Thrive Holdings精确注册、团队及收入边界。", "limitation": "需追溯原始披露,部分文章受访问限制", "accessRecheckedAt": "2026-10-30" }, @@ -60,8 +60,8 @@ "producthunt.com" ], "initialAccess": "官网可读取", - "boundary": "两年公开响应各19条,共38条。当前5条关联公司、2条范围外对照、24条范围外/既有业务、5条年份待核、2条推广。旧版本、品牌与法律主体分开,Tapflow据2024原版记录排除,Happycapy仍待核;非两年全量。", - "nextCheck": "继续补两年其余分页/日期、仅图片位置及5条年份待核候选;保留Pazi日期冲突,核清品牌与主体,避免转移历史业绩。", + "boundary": "两年公开响应各19条,共38条。当前5条关联公司、2条范围外对照、25条范围外/既有业务、4条年份待核、2条推广。旧版本、品牌与法律主体分开,Tapflow据2024原版记录排除,Happycapy仍待核;非两年全量。", + "nextCheck": "继续补两年其余分页/日期、仅图片位置及4条年份待核候选;保留Pazi日期冲突,核清品牌与主体,避免转移历史业绩。", "limitation": "上线时间不等于成立时间", "accessRecheckedAt": "2026-10-30" }, diff --git a/content/research/ai-income-analysis.json b/content/research/ai-income-analysis.json index 6acde9d..522c22e 100644 --- a/content/research/ai-income-analysis.json +++ b/content/research/ai-income-analysis.json @@ -1,5 +1,5 @@ { - "sourceVersion": "2026-10-30-utopai-1", + "sourceVersion": "2026-10-30-thrive-brila-1", "asOf": "2026-10-30", "metric": "platform-mrr-usd", "sampleUnit": "product-or-business", -- 2.52.0 From 6bfeea4e7ecf9651057a83783b633fc643d661fe Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 18:14:33 +0800 Subject: [PATCH 09/10] feat: add scoped document API and resumable batch uploader --- AGENTS.md | 2 +- README.md | 70 +++++++- apps/docs/scripts/auth-cli.ts | 41 +++++ apps/docs/scripts/docs-cli.ts | 32 +++- apps/docs/scripts/test-docs.mjs | 7 +- apps/docs/src/lib/docs.ts | 120 +++++++++++-- apps/docs/src/lib/document-keys.ts | 56 ++++++ apps/docs/src/lib/documents-api.ts | 51 ++++++ apps/docs/src/pages/api/documents/batch.ts | 22 +++ apps/docs/src/pages/api/documents/index.ts | 21 +++ .../src/pages/api/documents/item/[...slug].ts | 30 ++++ apps/docs/src/proxy.ts | 9 + apps/docs/tests/documents-api.test.ts | 136 ++++++++++++++ scripts/docs-admin.sh | 4 +- scripts/documents-client.py | 167 ++++++++++++++++++ scripts/test_documents_client.py | 85 +++++++++ 16 files changed, 826 insertions(+), 27 deletions(-) create mode 100644 apps/docs/src/lib/document-keys.ts create mode 100644 apps/docs/src/lib/documents-api.ts create mode 100644 apps/docs/src/pages/api/documents/batch.ts create mode 100644 apps/docs/src/pages/api/documents/index.ts create mode 100644 apps/docs/src/pages/api/documents/item/[...slug].ts create mode 100644 apps/docs/tests/documents-api.test.ts create mode 100644 scripts/documents-client.py create mode 100644 scripts/test_documents_client.py diff --git a/AGENTS.md b/AGENTS.md index c44c3b7..e6e5432 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ - 本仓库专门维护 Endor 文档与制图脚本。支持本地开发;CI/CD 仅在 main 使用现有 ces-control Runner,验证通过后自动部署;工作分支与 PR 不运行 CI,确认 Runner 不构成扩大触发范围的授权。不得修改其他项目、共享 Runner 配置或清理共享 Docker 缓存。 - Web 技术栈为 TypeScript、React、Tailwind CSS、Next.js Pages Router;不要切换到 App Router。 - 使用 pnpm 管理 JavaScript workspace,使用 uv 管理 `tools/figures` 的 Python 项目,保留两套锁文件。 -- 文档正文、目录元数据与修订记录以站点现有 SQLite 数据库为唯一权威源,使用 docs 管理工具直接更新;不得把新正文、目录 JSON、数据库文件或日常内容快照提交到仓库。历史 Git 文档只用于显式的一次性迁移,不作为运行时种子。Python 图形源码放在 `tools/figures/src/endor_figures`,可复现的 SVG 输出放在 `apps/docs/public/figures`。 +- 文档正文、目录元数据与修订记录以站点现有 SQLite 数据库为唯一权威源,使用文档 API 或 docs 管理工具直接更新;不得把新正文、目录 JSON、数据库文件或日常内容快照提交到仓库。历史 Git 文档只用于显式的一次性迁移,不作为运行时种子。Python 图形源码放在 `tools/figures/src/endor_figures`,可复现的 SVG 输出放在 `apps/docs/public/figures`。 - 未确认的架构提议明确标注为草案,不能表述为现有实现。 - 本地开发与预览只监听 `127.0.0.1`。部署容器监听 `0.0.0.0:3000`,服务器入口默认 18135,可通过独立 production.env 配置。 - 对 gitea.entroia.com 仅使用 codex-bot,提交身份为 Codex Bot 。SSH 使用隔离配置:`ssh -F /dev/null -o IdentitiesOnly=yes -i /Users/zzyang/.codex/credentials/gitea-entroia/codex_entroia_ed25519 -p 22222`。 diff --git a/README.md b/README.md index 242379d..7eb67af 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Endor 的文档站与 AI 公司研究工作台,包含文档阅读、全文搜 | 内容 | 权威来源与维护方式 | | --- | --- | -| 文档正文、分类、合集、分组、摘要、草案和推荐状态 | 数据库;通过服务器上的 `docs` 管理工具读写 | +| 文档正文、分类、合集、分组、摘要、草案和推荐状态 | 数据库;通过文档 API 或服务器上的 `docs` 管理工具读写 | | 文档旧地址与修订历史 | 数据库;改名保留别名,修改追加修订 | | AI 公司、观测、证据、候选与核查记录 | `content/research` 中的版本化快照;服务端显式按版本导入同库后查询 | | 页面、组件、管理工具和部署逻辑 | 本仓库,通过工作分支与 PR 评审 | @@ -32,7 +32,7 @@ Endor 的文档站与 AI 公司研究工作台,包含文档阅读、全文搜 ## 管理文档 -部署后,管理员可在服务器上使用 `/opt/endorphin-tabularium-docs/docs`。该工具在当前容器中操作数据库,需要服务器运维权限;站点没有公开的文档写入 API。 +部署后,管理员可在服务器上使用 `/opt/endorphin-tabularium-docs/docs`。该工具在当前容器中操作数据库,需要服务器运维权限。自动化上传使用下文的文档 API,日常读写无需登录服务器。 ```sh sudo /opt/endorphin-tabularium-docs/docs list @@ -63,6 +63,72 @@ sudo /opt/endorphin-tabularium-docs/docs save < "$doc_work_dir/document.json" 保存成功后,正文、目录和搜索通过后续请求读取新内容。发生版本冲突时重新读取并合并修改,不能仅改大 `revision` 覆盖并发编辑。调整分类或分组也走同一保存流程;`history` 使用稳定的 `sourceSlug` 查询修订。 +## 文档上传 API + +文档 API 直接读写同一个数据库,供自动化整理和大量文档上传使用。它只接受独立的文档 API Key,不接受网页 Cookie 或通用网页会话令牌;Key 不能用于研究、登录或其他站点 API。 + +| 方法与地址 | 用途 | +| --- | --- | +| `GET /api/documents?limit=50&offset=0` | 分页读取目录元数据;不返回全部正文 | +| `GET /api/documents/item/` | 读取单篇完整正文、目录字段和当前 revision;支持旧地址 | +| `GET /api/documents/item/?history=1&limit=50&offset=0` | 分页读取修订元数据与时间,按新到旧排列 | +| `GET /api/documents/item/?revision=1` | 读取指定修订的完整正文 | +| `POST /api/documents` | 保存一个完整文档 JSON;字段与 `docs save` 相同 | +| `POST /api/documents/batch` | 保存 `{ "documents": [...] }`,每个请求 1—100 篇 | + +写入使用 `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 + +管理员只需通过已验证的服务器连接进行 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 写入认证私有目录中的指定新文件,权限 `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。 + ## 研究数据与检查 研究工作台提供公司筛选、经营指标、原始证据、收费与交付模式、技术材料及核查记录。它区分公司披露、支付平台指标、第三方估算、融资和客户收益;未知收入不填零,材料可访问也不代表性能或商业效果已经验证。研究范围、样本限制和个案结论以工作台记录为准。 diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts index 9c7e393..7d3f60c 100644 --- a/apps/docs/scripts/auth-cli.ts +++ b/apps/docs/scripts/auth-cli.ts @@ -18,6 +18,7 @@ import { totp, isLoginRedirect, } from "../src/lib/auth"; +import { createDocumentKey, revokeDocumentKey } from "../src/lib/document-keys"; import { readDocument, saveDocument } from "../src/lib/docs"; import { consumeCode, initializeStore } from "../src/lib/auth-store"; @@ -210,6 +211,7 @@ async function main() { assert.equal(raw.status, 200); assert.ok((await raw.text()).startsWith("# Endor")); if (process.env.DOCUMENT_SMOKE_TEST) { + if (command === "exercise") await exerciseDocumentApi(request, headers); const sourceSlug = "ci-live-document"; const previous = readDocument(sourceSlug); if (command === "exercise") { @@ -356,3 +358,42 @@ main().catch((error) => { ); process.exitCode = 1; }); + +async function exerciseDocumentApi(request: (path: string, init?: RequestInit) => Promise, session: Record) { + 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"); +} diff --git a/apps/docs/scripts/docs-cli.ts b/apps/docs/scripts/docs-cli.ts index a3f6f50..22a7b8e 100644 --- a/apps/docs/scripts/docs-cli.ts +++ b/apps/docs/scripts/docs-cli.ts @@ -1,11 +1,37 @@ -import { readFileSync, writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync, openSync, closeSync, unlinkSync } from "node:fs"; +import { resolve, dirname, sep } from "node:path"; +import { createDocumentKey, listDocumentKeys, revokeDocumentKey, type DocumentKeyScope } from "../src/lib/document-keys"; import { openSiteDatabase } from "../src/lib/site-database"; import { allDocuments, readDocument, saveDocument, importDocuments, documentHistory } from "../src/lib/docs"; process.umask(0o077); -const [command, argument] = process.argv.slice(2); +const [command, argument, ...extra] = process.argv.slice(2); try { switch (command) { + case "key-create": { + const authFile = process.env.AUTH_CONFIG_FILE; + if (!authFile || !argument || !extra[0]) throw Error("Usage: key-create [read|read-write] [days]"); + const file = resolve(extra[0]); + const directory = resolve(dirname(authFile)); + if (!file.startsWith(directory + sep)) throw Error("Key output must be inside the private authentication directory"); + const fd = openSync(file, "wx", 0o600); + let created: { id: string } | undefined; + try { + const key = createDocumentKey(argument, (extra[1] ?? "read-write") as DocumentKeyScope, Number(extra[2] ?? 90)); + created = key; + writeFileSync(fd, JSON.stringify(key) + "\n"); + console.log(JSON.stringify({ id: key.id, scope: key.scope, expiresAt: key.expiresAt, saved: file })); + } catch (error) { + if (created) revokeDocumentKey(created.id); + unlinkSync(file); + throw error; + } finally { closeSync(fd); } + break; + } + case "key-list": console.log(JSON.stringify(listDocumentKeys(), null, 2)); break; + case "key-revoke": + if (!argument) throw Error("Provide key id"); + revokeDocumentKey(argument); console.log("Document key revoked"); break; case "backup": { if (!argument) throw Error("Provide a private backup filename"); const db = openSiteDatabase(); @@ -36,7 +62,7 @@ try { { flag: "wx", mode: 0o600 }); console.log("Document export created; existing files are never overwritten"); break; } - default: throw Error("Usage: docs backup | list | get | save < input.json | history | import [--if-empty] < export.json | export "); + default: throw Error("Usage: docs key-create [read|read-write] [days] | key-list | key-revoke | backup | list | get | save < input.json | history | import [--if-empty] < export.json | export "); } } catch (error) { console.error(error instanceof Error ? error.message : "Document operation failed"); diff --git a/apps/docs/scripts/test-docs.mjs b/apps/docs/scripts/test-docs.mjs index 339d95b..c4f4531 100644 --- a/apps/docs/scripts/test-docs.mjs +++ b/apps/docs/scripts/test-docs.mjs @@ -6,9 +6,8 @@ import { spawnSync } from "node:child_process"; const dir = await mkdtemp(join(tmpdir(), "tabularium-docs-tests-")); try { - const file = join(dir, "docs.test.cjs"); - await build({ entryPoints: ["tests/docs.test.ts"], bundle: true, - platform: "node", target: "node22", format: "cjs", outfile: file }); - const result = spawnSync(process.execPath, ["--test", file], { stdio: "inherit" }); + await build({ entryPoints: ["tests/docs.test.ts", "tests/documents-api.test.ts"], bundle: true, + platform: "node", target: "node22", format: "cjs", outdir: dir, outExtension: { ".js": ".cjs" } }); + const result = spawnSync(process.execPath, ["--test", join(dir, "docs.test.cjs"), join(dir, "documents-api.test.cjs")], { stdio: "inherit" }); process.exitCode = result.status ?? 1; } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/apps/docs/src/lib/docs.ts b/apps/docs/src/lib/docs.ts index 7f14bcb..5e16128 100644 --- a/apps/docs/src/lib/docs.ts +++ b/apps/docs/src/lib/docs.ts @@ -9,6 +9,9 @@ export type DocLink = { export type Doc = DocLink & { content: string }; export type DocInput = Omit; export type DocumentExport = { format: "tabularium-documents-v1"; documents: Doc[] }; +export class DocumentValidationError extends Error {} +export class DocumentConflictError extends Error {} +export type DocumentReceipt = Pick; export const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/; function database() { @@ -25,6 +28,10 @@ function database() { document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(document_id, revision) ); + CREATE TABLE IF NOT EXISTS document_write_requests ( + namespace TEXT NOT NULL, request_id TEXT NOT NULL, digest TEXT NOT NULL, + response TEXT NOT NULL, PRIMARY KEY(namespace, request_id) + ); CREATE TABLE IF NOT EXISTS document_imports ( digest TEXT PRIMARY KEY, document_count INTEGER NOT NULL, created_at TEXT NOT NULL ); @@ -33,50 +40,51 @@ function database() { } export function validateDocument(input: DocInput): void { - if (!input || typeof input !== "object") throw Error("Invalid document"); + if (!input || typeof input !== "object") throw new DocumentValidationError("Invalid document"); for (const key of ["slug", "sourceSlug"] as const) - if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw Error(`Invalid ${key}`); - if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw Error("Use a semantic public slug"); + if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw new DocumentValidationError(`Invalid ${key}`); + if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw new DocumentValidationError("Use a semantic public slug"); for (const key of ["title", "category", "collection", "group"] as const) - if (typeof input[key] !== "string" || !input[key].trim()) throw Error(`Missing ${key}`); + if (typeof input[key] !== "string" || !input[key].trim()) throw new DocumentValidationError(`Missing ${key}`); if (typeof input.content !== "string" || typeof input.description !== "string" || - typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw Error("Invalid document fields"); - if (input.content.length > 2_000_000) throw Error("Document exceeds content limit"); + typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw new DocumentValidationError("Invalid document fields"); + if (input.content.length > 2_000_000) throw new DocumentValidationError("Document exceeds content limit"); const images = [ ...input.content.matchAll(/!\[[^\]]*\]\(]+)/g), ...input.content.matchAll(/]*\bsrc=["']([^"']+)/gi), ]; if (images.some(([, source]) => /\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source))) - throw Error("Use compressed WebP images instead of original rasters"); + throw new DocumentValidationError("Use compressed WebP images instead of original rasters"); } function write(db: ReturnType, input: DocInput, expectedRevision: number): Doc { validateDocument(input); const existing = db.prepare("SELECT id, revision FROM documents WHERE source_slug=?") .get(input.sourceSlug) as { id: string; revision: number } | undefined; - if ((existing?.revision ?? 0) !== expectedRevision) throw Error("Document revision conflict"); + if ((existing?.revision ?? 0) !== expectedRevision) throw new DocumentConflictError("Document revision conflict"); const id = existing?.id ?? randomUUID(); const doc: Doc = { slug: input.slug, sourceSlug: input.sourceSlug, category: input.category, title: input.title, group: input.group, collection: input.collection, description: input.description, draft: input.draft, featured: input.featured, content: input.content, revision: expectedRevision + 1 }; const payload = JSON.stringify(doc); - // Aliases remain reserved after renaming; collision rolls back the whole write. - db.prepare(`INSERT INTO documents VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET - slug=excluded.slug, revision=excluded.revision, payload=excluded.payload`) - .run(id, doc.slug, doc.sourceSlug, doc.revision, payload); + // Reserve both canonical and historical names before updating any rows. for (const alias of new Set([doc.slug, doc.sourceSlug])) { const prior = db.prepare("SELECT document_id FROM document_aliases WHERE alias=?") .get(alias) as { document_id: string } | undefined; - if (prior && prior.document_id !== id) throw Error(`Document alias collision: ${alias}`); - db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id); + if (prior && prior.document_id !== id) throw new DocumentConflictError(`Document alias collision: ${alias}`); } + db.prepare(`INSERT INTO documents VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET + slug=excluded.slug, revision=excluded.revision, payload=excluded.payload`) + .run(id, doc.slug, doc.sourceSlug, doc.revision, payload); + for (const alias of new Set([doc.slug, doc.sourceSlug])) + db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id); db.prepare("INSERT INTO document_revisions VALUES (?,?,?,?)") .run(id, doc.revision, payload, new Date().toISOString()); return 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(); try { db.exec("BEGIN IMMEDIATE"); @@ -145,3 +153,85 @@ export function documentHistory(sourceSlug: string): Doc[] { .map((row) => JSON.parse(row.payload) as Doc); } finally { db.close(); } } + +// API batches commit entirely or not at all; receipts make lost-response retries safe. +export function saveDocumentBatch(documents: Doc[], request?: { namespace: string; id: string }) { + if (!Array.isArray(documents) || !documents.length || documents.length > 100) + throw new DocumentValidationError("Provide 1 to 100 documents"); + const seen = new Set(); + 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(); } +} diff --git a/apps/docs/src/lib/document-keys.ts b/apps/docs/src/lib/document-keys.ts new file mode 100644 index 0000000..5f8b16a --- /dev/null +++ b/apps/docs/src/lib/document-keys.ts @@ -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; + 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 }; +} diff --git a/apps/docs/src/pages/api/documents/batch.ts b/apps/docs/src/pages/api/documents/batch.ts new file mode 100644 index 0000000..2f90622 --- /dev/null +++ b/apps/docs/src/pages/api/documents/batch.ts @@ -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); } +} diff --git a/apps/docs/src/pages/api/documents/index.ts b/apps/docs/src/pages/api/documents/index.ts new file mode 100644 index 0000000..4679e3e --- /dev/null +++ b/apps/docs/src/pages/api/documents/index.ts @@ -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); } +} diff --git a/apps/docs/src/pages/api/documents/item/[...slug].ts b/apps/docs/src/pages/api/documents/item/[...slug].ts new file mode 100644 index 0000000..fa04bca --- /dev/null +++ b/apps/docs/src/pages/api/documents/item/[...slug].ts @@ -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); } +} diff --git a/apps/docs/src/proxy.ts b/apps/docs/src/proxy.ts index 2f4980d..77cc4e0 100644 --- a/apps/docs/src/proxy.ts +++ b/apps/docs/src/proxy.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth"; +import { authenticateDocumentKey } from "./lib/document-keys"; export function proxy(request: NextRequest) { const path = request.nextUrl.pathname; const headers = { @@ -31,6 +32,14 @@ export function proxy(request: NextRequest) { ? authorization.slice(7) : undefined; const token = bearer ?? request.cookies.get(SESSION_COOKIE)?.value; + // Document keys are scoped to the document API; they never authenticate other pages or APIs. + if (bearer && (path === "/api/documents" || path.startsWith("/api/documents/"))) { + try { + if (authenticateDocumentKey(bearer)) return NextResponse.next({ headers }); + } catch { + return NextResponse.json({ error: "文档认证暂时不可用" }, { status: 503, headers }); + } + } if (validSession(token, config)) { if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !bearer) { try { diff --git a/apps/docs/tests/documents-api.test.ts b/apps/docs/tests/documents-api.test.ts new file mode 100644 index 0000000..1c28528 --- /dev/null +++ b/apps/docs/tests/documents-api.test.ts @@ -0,0 +1,136 @@ +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 { createDocumentKey, authenticateDocumentKey, revokeDocumentKey, listDocumentKeys } from "../src/lib/document-keys"; +import { allDocuments, documentHistory, readDocument, saveDocument, 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; + query?: Record }; +function call(handler: (req: NextApiRequest, res: NextApiResponse) => unknown, options: Options = {}) { + let status = 200; + let body: unknown; + const headers: Record = {}; + 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 }) => 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[] = [ + { 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); +})); diff --git a/scripts/docs-admin.sh b/scripts/docs-admin.sh index 8bc4eeb..b2309fe 100644 --- a/scripts/docs-admin.sh +++ b/scripts/docs-admin.sh @@ -3,7 +3,7 @@ set -eu cd /opt/endorphin-tabularium-docs/current case "${1:-}" in - list|get|save|history|export|backup|import) + list|get|save|history|export|backup|import|key-create|key-list|key-revoke) docker compose -p endorphin-tabularium-docs --env-file /etc/endorphin-tabularium-docs/production.env --env-file release.env -f compose.yml exec -T docs node apps/docs/docs-cli.cjs "$@" ;; - *) echo 'Usage: docs list | get | save < JSON | history | export | backup | import < JSON' >&2; exit 1 ;; + *) echo 'Usage: docs key-create [read|read-write] [days] | key-list | key-revoke | list | get | save < JSON | history | export | backup | import < JSON' >&2; exit 1 ;; esac diff --git a/scripts/documents-client.py b/scripts/documents-client.py new file mode 100644 index 0000000..fc287be --- /dev/null +++ b/scripts/documents-client.py @@ -0,0 +1,167 @@ +"""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): + data = 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'] = 'application/json' + 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) + 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 == '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) + 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() diff --git a/scripts/test_documents_client.py b/scripts/test_documents_client.py new file mode 100644 index 0000000..11e1267 --- /dev/null +++ b/scripts/test_documents_client.py @@ -0,0 +1,85 @@ +"""Lightweight client tests: no HTTP server, compilation or real credentials.""" +import importlib.util +import io +import json +from pathlib import Path +import tempfile +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_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() -- 2.52.0 From 770be836922f1f9575ffc058d1158784497da313 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 18:19:29 +0800 Subject: [PATCH 10/10] Backfill 2025 discovery ledger and verify public archive scope --- apps/docs/scripts/auth-cli.ts | 2 +- apps/docs/tests/research.test.ts | 10 +- content/research/ai-companies.seed.json | 218 ++++++++++++++++++++- content/research/ai-companies.sources.json | 6 +- content/research/ai-income-analysis.json | 2 +- 5 files changed, 224 insertions(+), 14 deletions(-) diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts index 46f1524..0ece61b 100644 --- a/apps/docs/scripts/auth-cli.ts +++ b/apps/docs/scripts/auth-cli.ts @@ -183,7 +183,7 @@ async function main() { const research = await researchResponse.json(); assert.equal(research.companies.length, 1023); assert.equal(research.categories.length, 11); - assert.equal(research.evidence.length, 1251); + assert.equal(research.evidence.length, 1259); assert.equal(research.observations.length, 189); 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 }); diff --git a/apps/docs/tests/research.test.ts b/apps/docs/tests/research.test.ts index d03f46a..ad9fd6f 100644 --- a/apps/docs/tests/research.test.ts +++ b/apps/docs/tests/research.test.ts @@ -24,7 +24,7 @@ test("research revisions share the authentication database and preserve replay l assert.equal(consumeCode(config, "invalid"), "limited"); const initial = readResearchSnapshot(); assert.equal(initial.companies.length, 1023); - assert.equal(initial.evidence.length, 1251); + assert.equal(initial.evidence.length, 1259); assert.equal(initial.observations.length, 189); assert.deepEqual(initial.candidateBatches, seed.candidateBatches, "candidate gaps persist in the same database metadata"); assert.deepEqual(initial.directoryRechecks, seed.directoryRechecks, "follow-up classifications persist without rewriting the discovery audit"); @@ -64,9 +64,9 @@ test("research validation rejects broken evidence and unsafe source links", () = test("reviews link explicit companies without turning global or excluded cases into companies", () => { const snapshot = structuredClone(seed) as ResearchSnapshot; - assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 59); - assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 8); - assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 28); + assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 60); + assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 9); + assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 29); const review = snapshot.reviews.find((r) => r.id === "R4")!; assert.deepEqual(reviewCompanyIds(snapshot, review), ["AI007", "AI013", "AI021", "AI034"]); assert.deepEqual(reviewCompanyIds(snapshot, snapshot.reviews.find((r) => r.id === "CN-R-PSI")!), []); @@ -307,7 +307,7 @@ test("unreconciled directory financials remain source reviews instead of income assert.ok(review.evidenceIds.includes("PH-TANA-DR")); assert.equal(reviewCompanyIds(snapshot, review).length, 0); assert.ok(!snapshot.observations.some((o) => o.evidence === "PH-TANA-DR")); - assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 5); + assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 8); }); test("product pivots and founder histories do not become new company years or current revenue", () => { diff --git a/content/research/ai-companies.seed.json b/content/research/ai-companies.seed.json index 2964c5f..0e7e8ce 100644 --- a/content/research/ai-companies.seed.json +++ b/content/research/ai-companies.seed.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-thrive-brila-1", + "version": "2026-10-30-archives-1", "asOf": "2026-10-30", "scope": "全球公开资料研究:保留首批个案,并扩展核查2025—2026八个YC批次候选池;成立年份以官方公司页面字段为依据。", "limitations": [ @@ -10387,7 +10387,7 @@ "id": "YC30879", "name": "Hypercubic", "url": "https://www.ycombinator.com/companies/hypercubic", - "cat": "C05", + "cat": "C04", "year": 2025, "team": 3, "location": "San Francisco", @@ -10401,11 +10401,12 @@ "stage": "目录公开,商业成熟度待核验", "sourceType": "YC官方页面中的公司自述", "evidenceIds": [ - "YC-E-30879" + "YC-E-30879", + "TC-BF25-HYPER" ], "revenueStatus": "unknown", "profileOnly": true, - "classificationBasis": "主营简介的规则初分", + "classificationBasis": "人工复核YC当前主营:大型机与COBOL开发、维护和现代化工具,按开发者交付任务归C04;目录其他业务与财务字段尚未逐项核验。", "directoryTags": [] }, { @@ -45778,6 +45779,94 @@ "kind": "公司官方历史条款", "summary": "官方条款页面日期2023-10-16,服务合同方列Generated Media, Inc.,业务为生成图像许可。", "limitation": "单看同名条款不唯一识别主体,需与注册号7627361的隐私政策及Brila政策合看;不能把图像许可收入当新建站产品ARR。" + }, + { + "id": "TC-ARCHIVE-2025", + "companyId": null, + "title": "TechCrunch 2025普通年度档案入口", + "url": "https://techcrunch.com/2025/", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "媒体档案索引", + "summary": "年度档案首屏可读并提供Next链接,可从普通页面继续回查2025报道。", + "limitation": "本次只核入口和可见首屏,不代表已穷尽分页;综合档案包含非AI内容,新闻日期不当成立年。" + }, + { + "id": "TC-ARCHIVE-AI2", + "companyId": null, + "title": "TechCrunch AI栏目第二页读取范围", + "url": "https://techcrunch.com/category/artificial-intelligence/page/2/", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "媒体档案索引", + "summary": "普通AI栏目第二页可读,页面存在跨公司、产品和行业报道。", + "limitation": "只证明本次页面可读;动态相对时间和当前分页位置不是稳定的两年候选池,不以栏目名保证每条均为新AI公司。" + }, + { + "id": "TC-BF25", + "companyId": null, + "title": "2025企业竞赛名单的定向发现入口", + "url": "https://techcrunch.com/2025/12/29/the-32-top-enterprise-tech-startups-from-disrupt-startup-battlefield/", + "publishedAt": "2025-12-29", + "accessedAt": "2026-10-30", + "kind": "媒体候选发现", + "summary": "从2025企业竞赛精选名单选择五条线索,逐项另查原始资料。", + "limitation": "仅读取并核查五项,不声称32项、全部200参赛者或两年档案完成;参赛年不等成立年,不复用文章宣传评价为独立能力判断。" + }, + { + "id": "TC-BF25-HYPER", + "companyId": "YC30879", + "title": "Hypercubic当前YC主营与年份复核", + "url": "https://www.ycombinator.com/companies/hypercubic", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "YC官方公司资料", + "summary": "页面列Founded 2025,描述面向大型机与COBOL的维护现代化及Hopper开发环境。", + "limitation": "同一公司复用YC30879;公开演示、客户行业或目标企业不等付费客户,未测试性能或核收入。" + }, + { + "id": "TC-BF25-BILLOW", + "companyId": null, + "title": "Billow AI Labs的YC资料与成立年缺项", + "url": "https://www.ycombinator.com/companies/billow-ai-labs", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "YC官方公司资料", + "summary": "页面链接thebillow.ai,列Summer 2026批次,介绍会计和财务流程服务,本次可读正文无Founded字段。", + "limitation": "批次不等成立年;与2025竞赛发现线索的时序和实体需补证,不仅按相同域名和批次新增2026公司。创始人旧平台用户、交易量和客户企业价值不当Billow收入。" + }, + { + "id": "TC-BF25-DOBS", + "companyId": null, + "title": "Dobs AI公司主页的范围外年份", + "url": "https://www.linkedin.com/company/dobs-ai", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司公开主页自述", + "summary": "公司主页链接dobs.ai并列Founded 2024,介绍财务尽调与盈利质量分析。", + "limitation": "2024为公司自述,精确注册未核;准确率及合规宣传不是本轮独立测试,客户数据分析金额不作其自身收入。" + }, + { + "id": "TC-BF25-CASHEW", + "companyId": null, + "title": "Cashew Research官方业务与同名边界", + "url": "https://www.cashewresearch.com/about", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官方材料", + "summary": "公开公司介绍页可读,关联市场研究业务与cashewresearch.com。", + "limitation": "成立年及运营主体仍待可靠材料补证;不是YC的Cashew Labs,不能套用后者2025年份、团队或技术。" + }, + { + "id": "TC-BF25-ZINNIA", + "companyId": null, + "title": "Zinnia发现域名的当前访问缺项", + "url": "https://www.getzinnia.ai/", + "publishedAt": null, + "accessedAt": "2026-10-30", + "kind": "公司官网访问核查", + "summary": "从发现链接访问getzinnia.ai,本次重定向getzinnia.com后无法读取正文。", + "limitation": "不以搜索摘录或其他同名Zinnia公司填补成立年、团队或收入;当前无法核实该域名的主体连续性。" } ], "reviews": [ @@ -47143,6 +47232,46 @@ ], "handling": "归范围外既有主体;保留精确注册日未知,不因产品发布新增公司,不将图像业务收入或团队规模转作Brila建站业务。", "scope": "excluded-candidate" + }, + { + "id": "TC-R-ARCHIVE-SCOPE", + "type": "档案入口与读取边界", + "company": "资料来源整体", + "summary": "2025普通年度档案与AI第二页可读,说明RSS/API受限不代表所有普通档案页面不可读。", + "evidenceIds": [ + "TC-ARCHIVE-2025", + "TC-ARCHIVE-AI2", + "TC-BF25" + ], + "handling": "补2025定向候选台账,继续保留未读分页与未核名单;不把入口可读、竞赛选样或新闻时点写成两年新公司全集。", + "scope": "dataset" + }, + { + "id": "HYPER-R-CLASSIFICATION", + "type": "主营人工分类复核", + "company": "Hypercubic", + "companyIds": [ + "YC30879" + ], + "summary": "当前YC材料显示大型机/COBOL开发维护与现代化,归开发者工具。", + "evidenceIds": [ + "YC-E-30879", + "TC-BF25-HYPER" + ], + "handling": "复用既有公司记录,不重复新增;仍为目录基础记录,商业模式、定价及财务继续留缺,不把演示当独立测试。", + "scope": "companies" + }, + { + "id": "DOBS-R-COHORT", + "type": "参赛与成立年边界", + "company": "Dobs AI(既有公司候选)", + "summary": "2025参赛发现线索由公司主页2024自述年份补证为范围外。", + "evidenceIds": [ + "TC-BF25", + "TC-BF25-DOBS" + ], + "handling": "不新增公司或收入观测,不将客户盈利分析结果、准确率或合规宣传转为公司收入或独立技术验证。", + "scope": "excluded-candidate" } ], "coverage": { @@ -47886,6 +48015,87 @@ "companyId": "AI079" } ] + }, + { + "id": "TC-2025-battlefield-targeted-1", + "sourceId": "S02", + "url": "https://techcrunch.com/2025/12/29/the-32-top-enterprise-tech-startups-from-disrupt-startup-battlefield/", + "accessedAt": "2026-10-30", + "scope": "从2025-12-29企业竞赛名单中选取五条线索,按另列原始资料补证;不是该文32项全表、全部参赛者或两年档案。名录、参赛和报道时点均不直接作成立年。", + "complete": false, + "records": [ + { + "id": "TC25-BF-01", + "name": "Billow AI Labs", + "url": "https://www.ycombinator.com/companies/billow-ai-labs", + "categoryId": "C07", + "kind": "company", + "profileStatus": "readable", + "status": "pending-year", + "evidenceIds": [ + "TC-BF25", + "TC-BF25-BILLOW" + ], + "note": "YC仅列S2026批次且无可读Founded字段;与2025发现线索的实体/时序待核。旧创业业绩、客户企业价值和交易量不作新公司收入。" + }, + { + "id": "TC25-BF-02", + "name": "Cashew Research", + "url": "https://www.cashewresearch.com/about", + "categoryId": "C06", + "kind": "company", + "profileStatus": "readable", + "status": "pending-year", + "evidenceIds": [ + "TC-BF25", + "TC-BF25-CASHEW" + ], + "note": "成立年与主体未补齐;不与Cashew Labs混用,也不从参赛或新闻年推成立年。" + }, + { + "id": "TC25-BF-03", + "name": "Dobs AI", + "url": "https://www.linkedin.com/company/dobs-ai", + "categoryId": "C07", + "kind": "company", + "profileStatus": "readable", + "status": "outside-cohort", + "evidenceIds": [ + "TC-BF25", + "TC-BF25-DOBS" + ], + "note": "公司主页自述2024成立,按范围外保留,不凭2025参赛新增公司;精确注册与财务未核。" + }, + { + "id": "TC25-BF-04", + "name": "Hypercubic", + "url": "https://www.ycombinator.com/companies/hypercubic", + "categoryId": "C04", + "kind": "company", + "profileStatus": "readable", + "status": "linked", + "evidenceIds": [ + "TC-BF25", + "TC-BF25-HYPER" + ], + "note": "复用既有YC30879,2025年份由YC自述;当前主营复核为大型机开发维护工具,收入未知。", + "companyId": "YC30879" + }, + { + "id": "TC25-BF-05", + "name": "Zinnia(getzinnia.ai线索)", + "url": "https://www.getzinnia.ai/", + "categoryId": "C06", + "kind": "company", + "profileStatus": "unavailable", + "status": "pending-year", + "evidenceIds": [ + "TC-BF25", + "TC-BF25-ZINNIA" + ], + "note": "官方域名重定向后的正文不可读,成立年和主体连续性留缺,不套用其他同名公司资料。" + } + ] } ], "directoryRechecks": [ diff --git a/content/research/ai-companies.sources.json b/content/research/ai-companies.sources.json index 0023b3c..080fd66 100644 --- a/content/research/ai-companies.sources.json +++ b/content/research/ai-companies.sources.json @@ -1,5 +1,5 @@ { - "version": "2026-10-30-18", + "version": "2026-10-30-19", "scopeConfirmedAt": "2026-10-30", "acceptance": "以确认的公开来源清单为边界,逐条核对并标明缺项。", "provenance": "S01—S12恢复自已提交用户的《AI公司研究_来源与分类模板》来源清单;S13—S16单列后续已使用的补充证据渠道,不将使用次数当作全量覆盖。", @@ -28,8 +28,8 @@ "techcrunch.com" ], "initialAccess": "AI栏目可读取", - "boundary": "栏目首页选择的3条公司线索已追溯原始资料:Wabi据公开名录2025年份、Thrive Holdings据公司主页2025自述关联公司,DreamHQ成立年仍待核。Thrive控股改造与收购/有机收入边界单列;非首页全部条目或两年档案,公开分类接口与RSS普通HTTP请求返回403。", - "nextCheck": "补可公开取得的2025、2026档案及剩余分页;核DreamHQ成立依据与Wabi、Thrive Holdings精确注册、团队及收入边界。", + "boundary": "栏目首页选择的3条公司线索已追溯原始资料:Wabi据公开名录2025年份、Thrive Holdings据公司主页2025自述关联公司,DreamHQ成立年仍待核。Thrive控股改造与收购/有机收入边界单列;非首页全部条目或两年档案,公开分类接口与RSS普通HTTP请求返回403。 已补读2025年度档案入口、AI第二页,并从2025企业竞赛报道定向核5条:复用1条、范围外1条、待核3条;不是完整32项名单或两年全量。", + "nextCheck": "沿已验证可读的普通2025档案及AI分页继续补台账;记录未读分页,补DreamHQ、Billow、Cashew Research和Zinnia成立与主体依据,区分竞赛年、批次、产品和公司成立年。", "limitation": "需追溯原始披露,部分文章受访问限制", "accessRecheckedAt": "2026-10-30" }, diff --git a/content/research/ai-income-analysis.json b/content/research/ai-income-analysis.json index 522c22e..891417d 100644 --- a/content/research/ai-income-analysis.json +++ b/content/research/ai-income-analysis.json @@ -1,5 +1,5 @@ { - "sourceVersion": "2026-10-30-thrive-brila-1", + "sourceVersion": "2026-10-30-archives-1", "asOf": "2026-10-30", "metric": "platform-mrr-usd", "sampleUnit": "product-or-business", -- 2.52.0