@clawhub-cosmofang-f4f60536f9
moneybigA — A股/港股/美股机构级多框架股票分析 Skill。 专为主动交易者和研究者设计:筹码分布+主力控盘、智能资金概念(SMC)订单块/公允价值缺口/流动性清扫、 威科夫吸筹/派发六阶段、波浪理论浪级判断、MACD背离、量价关系; 基本面层:杜邦三因子、DCF内在价值、PEG/EV-EBITDA...
---
name: moneybigA
description: >
moneybigA — A股/港股/美股机构级多框架股票分析 Skill。
专为主动交易者和研究者设计:筹码分布+主力控盘、智能资金概念(SMC)订单块/公允价值缺口/流动性清扫、
威科夫吸筹/派发六阶段、波浪理论浪级判断、MACD背离、量价关系;
基本面层:杜邦三因子、DCF内在价值、PEG/EV-EBITDA估值、波特五力行业竞争;
量化层:Alpha101多因子评分体系(动量/价值/成长/质量/资金流)。
自动搜集实时数据,输出带综合评分仪表盘的交互式 HTML 报告,
含分级买卖信号(HIGH/MEDIUM/LOW 置信度)、风险等级触发条件、止损参考与目标价。
Trigger on:股票分析、筹码分布、买卖信号、主力控盘、短线机会、上证指数、基本面分析、
财务分析、估值分析、财报综合分析、行业分析、技术分析、量化选股、波浪理论、威科夫、
SMC、Order Block、Alpha因子、stock analysis、chip distribution、buy signal、
sell signal、fundamental analysis、valuation、DCF、financial report analysis。
keywords:
- 股票分析
- 筹码分布
- 主力控盘
- 买卖信号
- 短线机会
- 上证指数
- 技术分析
- 基本面分析
- 财务分析
- 估值分析
- 财报分析
- 行业分析
- 量化选股
- Alpha因子
- 波浪理论
- 威科夫
- 威科夫吸筹
- 威科夫派发
- SMC
- 智能资金
- Order Block
- Fair Value Gap
- 流动性清扫
- MACD背离
- 量价分析
- 杜邦分析
- DCF估值
- PE PB ROE
- 竞争分析
- 行业景气
- stock analysis
- chip distribution
- buy signal
- sell signal
- fundamental analysis
- valuation
- financial report
- sector analysis
- smart money
- wyckoff
- elliott wave
metadata:
openclaw:
runtime:
node: ">=18"
---
# 股票金融分析 (Stock Financial Analysis)
> 机构级多框架分析引擎 — 技术面 × 基本面 × 量化因子 × 智能资金追踪
---
## Purpose & Capability
moneybigA 是面向**主动交易者、量化研究者和价值投资者**的机构级股票分析 Skill。
**核心能力:**
| 维度 | 能力 |
|------|------|
| 技术面 | 筹码分布+主力控盘、威科夫六阶段、SMC(订单块/FVG/流动性清扫)、波浪理论、MACD背离、量价综合 |
| 基本面 | 杜邦三因子、现金流质量验证、财务预警、DCF内在价值+安全边际、PEG/EV-EBITDA、波特五力 |
| 量化因子 | Alpha101体系(动量/价值/成长/质量/资金流)综合评分 |
| 信号系统 | 综合评分(0-100)、分级买卖信号、置信度(HIGH/MEDIUM/LOW)、风险等级 |
| 输出 | 交互式单文件 HTML 仪表盘,含多模块卡片、圆形评分仪表、量化因子热力图 |
**不做的事:**
- 不执行真实交易或下单操作
- 不提供实时 Level2 盘口数据
- 不保证分析结果的投资收益,所有输出不构成投资建议
- 不回测历史交易策略
---
## Instruction Scope
**在 scope 内(会处理):**
- "分析一下 000001 的筹码分布和主力控盘"
- "帮我看看茅台的基本面,值不值得买"
- "上证指数现在处于什么位置,有没有短线机会"
- "解读这份财务报告,综合给出买卖建议"
- "这只股票的威科夫阶段在哪里?有没有 SMC 订单块"
- "帮我做个行业竞争分析,用波特五力"
- "AAPL 的 DCF 估值和 PE 历史分位分别在哪"
**不在 scope 内(不处理):**
- 直接下单或执行交易(无券商 API 接入)
- 内幕消息或非公开信息查询
- 提供具体仓位比例或资金管理方案
- 期货、期权复杂衍生品定价
**凭证缺失时的行为:**
本 Skill 无需任何 API 凭证。所有数据通过 WebSearch 公开渠道搜集。若数据无法获取,会明确告知并基于可用信息完成分析。
---
## Credentials
本 Skill 无需任何 API 密钥、token 或账号凭证。
| 操作 | 凭证 | 范围 |
|------|------|------|
| 数据搜集 | 无 | 通过 WebSearch 访问公开信息 |
| 分析计算 | 无 | 本地 LLM 推理 |
| HTML 输出 | 无 | 生成单文件 artifact |
**不会读取或写入:** 任何本地文件、环境变量、系统配置。
---
## Persistence & Privilege
**写入路径:** 无。本 Skill 仅在对话上下文中运行,不向任何本地路径写入文件。
| 路径 | 内容 | 触发条件 |
|------|------|---------|
| 无 | — | — |
**不写入的内容:**
- 不写入任何本地文件或目录
- 不修改系统配置或 shell 环境
- 不持久化用户数据或分析历史
- 不注册 cron 或后台进程
**卸载方法:**
```bash
rm -rf ~/.claude/skills/moneybigA
```
---
## Install Mechanism
### 从 clawHub 安装(推荐)
```bash
clawhub install moneybigA
```
### 手动安装
```bash
cp -r /path/to/moneybigA ~/.claude/skills/moneybigA/
```
### 验证安装
```bash
ls ~/.claude/skills/moneybigA/
# 应看到:SKILL.md _meta.json package.json .clawhub/
```
### 环境变量
本 Skill 无需配置任何环境变量,开箱即用。
### 使用方法
安装后直接在 Claude Code 中输入分析请求即可自动触发:
```
/moneybigA 分析 000001
/moneybigA 帮我看一下茅台的基本面
```
---
## 分析框架
### 技术面矩阵
- **筹码分布**:获利盘比例、筹码集中度、主力成本区、穿透率 → 主力控盘判断
- **威科夫方法**:吸筹六阶段(Spring→SOS→主升浪)/ 派发六阶段(UTAD→SOW→下跌)
- **SMC 智能资金**:订单块(Order Block)/ 公允价值缺口(FVG)/ 流动性清扫
- **波浪理论**:大中小三级浪判断,3浪确认买点,5浪末+背离提示顶部
- **MACD背离**:顶/底背离识别,量价背离(出货/吸筹)
### 基本面矩阵
- **杜邦分析**:ROE = 净利润率 × 资产周转率 × 权益乘数,来源质量判断
- **财务健康**:现金流质量(OCF/净利润)、FCF、负债率、利息覆盖率、财务雷区
- **DCF估值**:5年FCF预测 + 永续增长 + WACC折现,安全边际 ≥30% 为显著低估
- **相对估值**:PE历史分位、PEG(<1低估)、EV/EBITDA跨行业比较
- **波特五力**:竞争强度、进入壁垒、替代品、供应商/客户议价能力
### 量化因子(Alpha101体系)
动量 / 价值(EP/BP)/ 成长(营收增速/ROE趋势)/ 质量(现金流/毛利率稳定性)/ 资金流(大单净流入/北向/融资余额)
### 信号系统
| 信号 | 分数 | 标志 |
|------|------|------|
| 强烈买入 | ≥75 | 多框架共振,置信度 HIGH |
| 温和买入 | 60-75 | 2+ 技术信号一致,置信度 MEDIUM |
| 观望 | 40-60 | 信号混沌,等待方向 |
| 减仓 | <40 | 风险信号触发,置信度 MEDIUM |
| 强烈卖出 | 基本面恶化或派发Phase C/D | 置信度 HIGH |
### HTML 输出
单文件交互式仪表盘,深色金融风格(#0d1117 背景),包含:
股票信息头 / 技术面分析卡 / 基本面评分卡 / 量化因子热力图 / 综合评分圆形仪表(0-100)/ 操作建议与止损目标价 / 风险免责声明
---
> **免责声明**:本分析基于公开信息,仅供参考和学习研究,**不构成任何投资建议**。股市有风险,投资需谨慎。
FILE:SKILL.full.md
---
name: moneybigA
description: >
moneybigA — A股/港股/美股机构级多框架股票分析 Skill。
专为主动交易者和研究者设计:筹码分布+主力控盘、智能资金概念(SMC)订单块/公允价值缺口/流动性清扫、
威科夫吸筹/派发六阶段、波浪理论浪级判断、MACD背离、量价关系;
基本面层:杜邦三因子、DCF内在价值、PEG/EV-EBITDA估值、波特五力行业竞争;
量化层:Alpha101多因子评分体系(动量/价值/成长/质量/资金流)。
自动搜集实时数据,输出带综合评分仪表盘的交互式 HTML 报告,
含分级买卖信号(HIGH/MEDIUM/LOW 置信度)、风险等级触发条件、止损参考与目标价。
Trigger on:股票分析、筹码分布、买卖信号、主力控盘、短线机会、上证指数、基本面分析、
财务分析、估值分析、财报综合分析、行业分析、技术分析、量化选股、波浪理论、威科夫、
SMC、Order Block、Alpha因子、stock analysis、chip distribution、buy signal、
sell signal、fundamental analysis、valuation、DCF、financial report analysis。
keywords:
- 股票分析
- 筹码分布
- 主力控盘
- 买卖信号
- 短线机会
- 上证指数
- 技术分析
- 基本面分析
- 财务分析
- 估值分析
- 财报分析
- 行业分析
- 量化选股
- Alpha因子
- 波浪理论
- 威科夫
- 威科夫吸筹
- 威科夫派发
- SMC
- 智能资金
- Order Block
- Fair Value Gap
- 流动性清扫
- MACD背离
- 量价分析
- 杜邦分析
- DCF估值
- PE PB ROE
- 竞争分析
- 行业景气
- stock analysis
- chip distribution
- buy signal
- sell signal
- fundamental analysis
- valuation
- financial report
- sector analysis
- smart money
- wyckoff
- elliott wave
metadata:
openclaw:
runtime:
node: ">=18"
---
# 股票金融分析 (Stock Financial Analysis)
> 机构级多框架分析引擎 — 技术面 × 基本面 × 量化因子 × 智能资金追踪
---
## Purpose & Capability
moneybigA 是一个面向**主动交易者、量化研究者和价值投资者**的股票分析 Skill,将原本需要打开多个软件(同花顺/财报狗/Wind/行研报告)的工作整合到一次对话中,输出机构级质量的分析报告。
**核心能力:**
| 维度 | 能力 |
|------|------|
| 技术面 | 筹码分布与主力控盘判断、威科夫六阶段识别、SMC(订单块/公允价值缺口/流动性清扫)、波浪理论浪级、MACD背离、量价综合判断 |
| 基本面 | 杜邦三因子分解、现金流质量验证、财务预警雷区、DCF内在价值+安全边际、PEG/EV-EBITDA估值、波特五力行业分析 |
| 量化因子 | Alpha101体系五类因子(动量/价值/成长/质量/资金流)综合评分 |
| 信号系统 | 综合评分(0-100)、分级买卖信号(强烈买入/温和/观望/减仓/强烈卖出)、置信度(HIGH/MEDIUM/LOW)、风险等级 |
| 输出 | 交互式单文件 HTML 仪表盘,含多模块卡片、圆形评分仪表、量化因子热力图 |
**不做的事:**
- 不执行真实交易或下单操作
- 不提供实时 Level2 盘口数据(依赖公开延迟数据)
- 不保证分析结果的投资收益,所有输出不构成投资建议
- 不回测历史交易策略(仅分析当前状态)
---
## Instruction Scope
**在 scope 内(会处理):**
- "分析一下 000001 的筹码分布和主力控盘"
- "帮我看看茅台的基本面,值不值得买"
- "上证指数现在处于什么位置,有没有短线机会"
- "解读这份财务报告,综合给出买卖建议"
- "这只股票的威科夫阶段在哪里?有没有 SMC 订单块"
- "帮我做个行业竞争分析,用波特五力"
- "AAPL 的 DCF 估值和 PE 历史分位分别在哪"
**不在 scope 内(不处理):**
- 直接下单或执行交易(无券商 API 接入)
- 内幕消息或非公开信息查询
- 提供具体仓位比例或资金管理方案(超出分析范围,属于个人理财建议)
- 期货、期权复杂衍生品定价(超出当前框架)
**凭证缺失时的行为:**
本 Skill 无需任何 API 凭证。所有数据通过 WebSearch 公开渠道搜集。若数据无法获取(如停牌股票、数据源维护),会明确告知数据缺失情况并基于可用信息完成分析。
---
## Credentials
本 Skill 无需任何 API 密钥、token 或账号凭证。
| 操作 | 凭证 | 范围 |
|------|------|------|
| 数据搜集 | 无 | 通过 WebSearch 访问公开信息 |
| 分析计算 | 无 | 本地 LLM 推理 |
| HTML 输出 | 无 | 生成单文件 artifact |
**不会读取或写入:** 任何本地文件、环境变量、系统配置。
---
## Persistence & Privilege
**写入路径:** 无。本 Skill 仅在对话上下文中运行,不向任何本地路径写入文件。
| 路径 | 内容 | 触发条件 |
|------|------|---------|
| 无 | — | — |
**不写入的内容:**
- 不写入任何本地文件或目录
- 不修改系统配置或 shell 环境
- 不持久化用户数据或分析历史
- 不注册 cron 或后台进程
**卸载方法:**
```bash
rm -rf ~/.claude/skills/moneybigA
```
删除后重启 Claude Code 即完全卸载,无任何残留。
---
## Install Mechanism
### 从 clawHub 安装(推荐)
```bash
clawhub install moneybigA
# 安装路径:~/.openclaw/workspace/skills/moneybigA/
```
### 手动安装
```bash
cp -r /path/to/moneybigA ~/.claude/skills/moneybigA/
```
### 验证安装
```bash
ls ~/.claude/skills/moneybigA/
# 应看到:SKILL.md _meta.json package.json .clawhub/
```
### 环境变量
本 Skill 无需配置任何环境变量,开箱即用。
### 使用方法
安装后直接在 Claude Code 中输入分析请求即可自动触发,或使用斜杠命令:
```
/moneybigA 分析 000001
/moneybigA 帮我看一下茅台的基本面
```
---
## 何时触发
- 用户提到股票代码(如 000001、AAPL、0700.HK)
- 要求分析筹码分布、主力控盘程度
- 寻找短线买卖点或交易信号
- 分析上证指数/市场整体走势
- 进行上市公司基本面/财务/估值分析
- 分析行业发展、竞争格局
- 解读或综合分析财务报告
- 提到:波浪理论、威科夫、SMC、Order Block、MACD背离、量价关系
---
## 分析框架总览
```
用户输入
↓
┌─────────────────────────────────────────┐
│ STEP 1: 意图识别 & 数据搜集 │
│ 技术面请求 / 基本面请求 / 综合请求 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ STEP 2: 多维度分析引擎 │
│ A. 技术面矩阵 │
│ B. 基本面矩阵 │
│ C. 量化因子矩阵 │
│ D. 智能资金追踪 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ STEP 3: 综合评分 & 信号生成 │
│ 置信度评分 / 风险分级 / 操作建议 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ STEP 4: 可视化 HTML 仪表盘输出 │
└─────────────────────────────────────────┘
```
---
## STEP 1: 数据搜集工作流
根据用户意图搜集以下数据:
**技术面数据搜集:**
```
WebSearch: "[股票代码/名称] 股价 今日行情 K线"
WebSearch: "[股票代码] 主力资金流向 大单净流入"
WebSearch: "[股票代码] 筹码分布 成本区间"
WebSearch: "[股票代码] MACD RSI 均线 技术指标"
WebSearch: "[股票代码] 近期涨跌 量价 换手率"
```
**基本面数据搜集:**
```
WebSearch: "[公司名] 年报 财务报告 2024 2025"
WebSearch: "[公司名] 营收 净利润 毛利率 ROE"
WebSearch: "[公司名] 主营业务 行业地位 市场份额"
WebSearch: "[行业名] 行业景气 发展趋势 竞争格局 2025"
WebSearch: "[公司名] PE PB 估值 同行业比较"
WebSearch: "[公司名] 自由现金流 负债率 资产负债表"
```
**宏观/市场数据搜集:**
```
WebSearch: "上证指数 今日行情 主力资金 北向资金"
WebSearch: "A股市场情绪 涨停板 板块轮动 热点"
WebSearch: "[行业板块] 龙头股 近期表现 资金流入"
```
---
## STEP 2-A: 技术面分析矩阵
### 1. 筹码分布分析(A股核心方法)
分析以下筹码关键指标:
| 指标 | 含义 | 信号解读 |
|------|------|---------|
| **获利盘比例** | 当前价格以下的筹码占比 | >80% 上方套牢盘重,压力大;<30% 筹码集中成本区,支撑强 |
| **筹码集中度** | 筹码在价格区间的分散程度 | 集中度高(峰值窄) → 主力控盘;分散 → 散户主导 |
| **主力成本区** | 机构/主力平均建仓价格 | 现价 > 主力成本 → 主力浮盈,可继续拉升;现价 < 主力成本 → 主力被套,慎入 |
| **套牢盘密集区** | 上方密集成本区位置 | 接近密集区时阻力加大,突破则转化为支撑 |
| **穿透率** | 筹码在当前价格以下比例 | 穿透率>70% + 筹码高度集中 → 强势主力控盘信号 |
**主力控盘判断模型:**
```
控盘程度 = f(筹码集中度, 换手率, 大单净流入, 成本区间宽度)
高度控盘标志:
✓ 筹码集中在20%价格区间内
✓ 日换手率 < 1.5%(锁仓特征)
✓ 大单净流入持续为正
✓ 成交量萎缩但股价不跌(吸筹特征)
```
---
### 2. 威科夫方法(Wyckoff Method)
识别当前处于哪个阶段:
**吸筹阶段(Accumulation)识别:**
```
Phase A: 供给高峰(PS)→ 阶段性底部(SC)→ 自动反弹(AR)→ 二次测试(ST)
Phase B: 建仓区间,横盘震荡,成交量萎缩
Phase C: 弹簧(Spring)= 假突破下方后快速收复(关键确认信号)
Phase D: SOS(强势信号)= 放量突破阻力线
Phase E: 主升浪启动
⚡ 核心信号:Spring + SOS + 量价背离修复 = 高置信度买点
```
**派发阶段(Distribution)识别:**
```
Phase A: 供给重新出现,上涨乏力(PSY、BC、AR、ST)
Phase B: 高位震荡,量能逐渐放大但涨幅收窄
Phase C: UTAD(上方假突破)= 吸引散户追高后快速回落
Phase D: SOW(弱势信号)= 跌破支撑线
⚡ 核心信号:UTAD + SOW + 缩量反弹 = 高置信度卖点
```
---
### 3. 智能资金概念(SMC - Smart Money Concepts)
**订单块(Order Block)分析:**
```
多头订单块 = 价格大幅上涨前的最后一根阴线(机构买入区域)
空头订单块 = 价格大幅下跌前的最后一根阳线(机构卖出区域)
判断标准:
✓ 订单块后紧跟强势突破(≥3根连续同向K线)
✓ 价格回测订单块区域但未有效跌破
✓ 回测时成交量萎缩(无新卖压)
→ 信号:回测有效订单块 = 机构二次承接,高概率反弹
```
**公允价值缺口(Fair Value Gap / FVG):**
```
FVG形成条件:
K线N-1的高点 < K线N+1的低点(向上FVG)
K线N-1的低点 > K线N+1的高点(向下FVG)
交易逻辑:
机构制造的价格缺口具有强烈回填倾向(约70%概率)
方向性FVG:顺势回填后继续原方向运动
→ 信号:价格回测FVG下沿/上沿 = 潜在高胜率入场区
```
**流动性清扫(Liquidity Sweep):**
```
高点流动性:密集的止损单堆积在前高上方
低点流动性:密集的止损单堆积在前低下方
机构操作模式:
1. 拉升价格超越前高(触发多头止损 + 散户追涨)
2. 迅速拉回形成假突破(承接流动性)
3. 反向运动获利
→ 信号:假突破前高/前低后快速回落/反弹 + 成交量异常放大 = 流动性清扫完成,方向即将确认
```
---
### 4. MACD背离分析
```
顶背离(卖出信号):
价格创新高,MACD柱状线/DEA未创新高
→ 上涨动能衰竭,警惕顶部
强度:二次顶背离 > 一次顶背离
底背离(买入信号):
价格创新低,MACD柱状线/DEA未创新低
→ 下跌动能衰竭,注意底部反转
强度:三次底背离 > 二次 > 一次
量能背离(隐藏信号):
价格上涨 + 成交量萎缩 → 主力出货(量价背离)
价格下跌 + 成交量萎缩 → 无量阴跌,主力未出(缩量调整)
```
---
### 5. 艾略特波浪理论(Elliott Wave)
```
当前浪级判断:
大级别浪(月线/周线)→ 判断长期趋势方位
中级别浪(日线)→ 确认波浪结构
小级别浪(小时线)→ 寻找精确入场点
推进浪信号(看涨):
3浪 = 最强最长的推进浪,确认突破后大幅加仓
5浪末 = 背离信号出现,即将进入ABC调整
调整浪结构(应对):
锯齿形(5-3-5)、平台形(3-3-5)、三角形(3-3-3-3-3)
→ 判断调整深度和时间,确定下一个推进浪起点
```
---
### 6. 量价关系综合判断
```
强势上涨特征:
✅ 放量突破 + 阳线实体大 + 上影线短
✅ 突破后缩量回踩支撑不破
✅ 换手率适中(3-8%),非极端换手
弱势下跌特征:
⚠️ 放量滞涨 + 上下影线长(出货形态)
⚠️ 缩量反弹 + 放量下跌(反弹无力)
⚠️ 高换手但价格不涨(主力出货)
底部反转量价特征:
🟢 地量地价 → 恐慌性抛售结束
🟢 量能温和放大 + 股价低位横盘
🟢 某日突然放量阳线,后续缩量维持
```
---
## STEP 2-B: 基本面分析矩阵
### 1. 杜邦分析体系(DuPont Decomposition)
```
ROE = 净利润率 × 资产周转率 × 权益乘数
质量判断:
高ROE来自高净利率 → 品牌/定价权驱动(最优)
高ROE来自高周转率 → 规模效率驱动(良好)
高ROE来自高杠杆 → 财务风险驱动(需警惕)
趋势分析:
ROE连续3年 >15% 且稳定 → 优质竞争护城河
ROE下滑趋势 → 竞争加剧或经营效率下降信号
```
### 2. 财务健康度评估
**盈利质量检验:**
```
现金流质量 = 经营现金流 / 净利润
> 1.0 → 盈利质量高(利润已变现)
0.5~1.0 → 一般
< 0.5 → 警惕利润操控或应收账款风险
自由现金流(FCF) = 经营现金流 - 资本支出
持续为正 → 可自我造血,分红/回购能力强
持续为负 → 需外部融资,稀释风险
```
**债务安全边际:**
```
资产负债率:
制造业 < 60% 安全;金融行业特殊
流动比率 > 2、速动比率 > 1 → 短期偿债无忧
带息负债/EBITDA < 3 → 财务安全
利息覆盖率 > 5 → 偿息压力小
```
**财务预警信号(雷区):**
```
⚠️ 应收账款增速 >> 营收增速(虚增收入)
⚠️ 存货大幅增加 + 毛利率下降(滞销)
⚠️ 商誉占净资产 > 30%(减值风险)
⚠️ 经营现金流为负 + 大量外部融资
⚠️ 关联交易占比高(利益输送嫌疑)
```
### 3. 估值分析矩阵
**相对估值(横向比较):**
```
市盈率(PE)分析:
TTM PE vs 历史分位(过去5年)
PE vs 行业平均 → 溢价/折价多少
PEG = PE / 净利润增长率(<1 = 低估,>2 = 高估)
市净率(PB)分析:
适用行业:银行、地产、重资产制造
PB < 1 → 破净,需判断是价值陷阱还是底部机会
PB vs ROE:高ROE对应高PB合理
EV/EBITDA:
剔除资本结构差异,跨国/跨行业比较更公平
消费行业 < 15x 合理;科技行业可适当更高
```
**绝对估值(DCF内在价值):**
```
DCF简化框架:
1. 预测未来5年自由现金流(基于历史增速 + 行业趋势)
2. 永续增长率:成熟行业 2-3%;成长行业 3-5%
3. 折现率(WACC):无风险利率 + 风险溢价(A股约8-12%)
4. 内在价值 vs 当前市价 = 安全边际
安全边际判断:
现价 < 内在价值 × 70% → 显著低估,强买入
现价 ≈ 内在价值 × 80-100% → 合理
现价 > 内在价值 × 120% → 高估,谨慎
```
### 4. 行业竞争分析(波特五力)
```
① 现有竞争者威胁
- 集中度(CR4/HHI指数):越高越好
- 价格战烈度:毛利率稳定性检验
② 新进入者威胁
- 资质壁垒(牌照、专利、政策保护)
- 规模壁垒(网络效应、品牌效应)
- 资金壁垒(重资产行业天然护城河)
③ 替代品威胁
- 技术迭代速度:越快,替代风险越高
- 客户转换成本:越高,护城河越宽
④ 供应商议价能力
- 原材料集中度 vs 公司采购份额
- 核心技术自研 vs 外购依赖
⑤ 客户议价能力
- 客户集中度:前五大客户占比 < 30% 较健康
- 产品差异化程度:越高,议价能力越强
```
---
## STEP 2-C: 量化因子矩阵
### Alpha因子评分(参考 Alpha101 体系)
计算并评估以下因子信号方向(+正向/-负向):
```
动量因子:
12个月价格动量(排除最近1个月) → 中期趋势
1个月短期反转 → 超跌反弹机会
价值因子:
EP(市盈率倒数) → 估值吸引力
BP(市净率倒数) → 资产折价程度
成长因子:
营收增速 YoY → 业务扩张速度
ROE变化趋势 → 盈利能力改善
资金流向因子:
大单净流入/总成交量(3日/5日/10日均值)
北向资金净买入(A股专项)
融资余额变化趋势
质量因子:
毛利率稳定性 → 定价权
经营现金流/净利润 → 盈利质量
资产负债率变化趋势 → 杠杆风险
波动率因子:
特质波动率(β剔除系统风险后的残差) → 低特质波动溢价
最大回撤(近1年)
```
### 综合评分计算
```
技术面得分(40%权重):
筹码结构 + 威科夫阶段 + SMC信号 + 量价关系 + 均线排列
基本面得分(40%权重):
ROE质量 + 财务健康 + 估值水平 + 行业竞争力
量化因子得分(20%权重):
多因子Alpha得分均值
最终综合评分 = 加权平均(0~100分)
```
---
## STEP 3: 信号生成与风险评级
### 买卖信号分级
```
🟢 强烈买入(≥75分 + 多个框架共振)
威科夫Spring + SMC多头OB确认 + 底背离 + 基本面低估
→ 置信度 HIGH,建议分批买入
🔵 温和买入(60-75分)
2个以上技术信号一致 + 基本面无重大问题
→ 置信度 MEDIUM,轻仓试探
⚪ 持续观望(40-60分)
信号混沌,多空博弈激烈
→ 等待方向明朗
🟡 减仓/保持(40分以下 + 风险信号)
→ 置信度 MEDIUM,分批减仓
🔴 强烈卖出(基本面恶化 OR 威科夫派发Phase C+D OR 多个顶背离)
→ 置信度 HIGH,及时止损
止损参考:
短线:最近低点下方 3-5%
中线:主力成本区 / 关键支撑 -5%
长线:DCF估值下限
```
### 风险等级评估
```
🔴 高风险(任何一条触发):
- 财务预警信号触发(虚增收入/现金流异常)
- 资产负债率 > 70%(非金融行业)
- 处于威科夫派发Phase C/D
- 流动性极差(日均成交额 < 3000万)
🟡 中风险:
- 估值处于历史80%分位以上
- 行业处于下行周期
- 技术面技术位处于中段,上下空间均等
🟢 低风险:
- 基本面扎实 + 估值处于历史底部
- 机构持仓分散 + 无高比例质押
- 威科夫吸筹阶段 + 安全边际充足(>30%)
```
---
## STEP 4: HTML 仪表盘输出规范
生成一个完整的单文件 HTML 分析报告,包含以下模块:
### 视觉设计标准
```
配色方案:专业金融风格
背景:深色 #0d1117 或浅色 #f8f9fa(可切换)
主色:金融蓝 #1e3a5f + 金色 #c9a54e
上涨:翠绿 #00b96b
下跌:赤红 #ff4d4f
中性:灰银 #8c8c8c
信号高亮:强买入用绿色光晕,强卖出用红色光晕
字体:
数字/代码:Fira Code / JetBrains Mono(等宽)
正文:PingFang SC / Noto Sans SC(中文友好)
标题:Bold 无衬线
布局:
顶部:股票基本信息条(代码/名称/现价/涨跌幅/量价)
左栏:技术面分析(筹码图/K线信号/威科夫阶段/SMC标注)
右栏:基本面摘要(财务评分/估值雷达图/风险指标)
底部:综合评分卡 + 操作建议 + 风险提示
```
### 必须包含的信息模块
**模块 1:股票信息头部**
```html
股票代码 | 公司名称 | 所属行业 | 当前价格 | 涨跌幅 | 成交量
市值 | PE(TTM) | PB | 52周高/低 | 换手率
```
**模块 2:技术面分析卡片**
- 筹码分布状态(文字描述 + 关键价位)
- 威科夫阶段判断(当前处于哪个Phase)
- SMC关键位(Order Block / FVG 位置)
- 波浪结构(当前浪级判断)
- MACD背离状态
- 量价综合信号
**模块 3:基本面评分卡片**
- 盈利能力(ROE/ROA/毛利率/净利率)
- 财务健康(负债率/现金流质量)
- 成长性(营收/利润3年CAGR)
- 估值水平(PE历史分位/PB/DCF安全边际)
- 行业竞争力(护城河评级)
**模块 4:量化因子热力图**
动量 / 价值 / 成长 / 质量 / 资金流 → 各因子方向和强度
**模块 5:综合评分与操作建议**
```
综合评分仪表盘(0-100圆形仪表)
信号级别(强烈买入/温和买入/观望/减仓/强烈卖出)
置信度(HIGH/MEDIUM/LOW)
风险等级(低/中/高 + 触发理由)
关键支撑位 / 阻力位
止损建议
目标价(基于DCF/技术面双重验证)
```
**模块 6:风险免责声明**
```
⚠️ 本分析仅供参考,不构成任何投资建议。
股市有风险,投资需谨慎。过往表现不代表未来。
请结合自身风险承受能力做出投资决策。
```
---
## 特殊场景处理
### 上证指数整体分析
当用户要求分析上证/大盘时,额外执行:
```
搜集:
WebSearch: "上证指数 今日行情 主力资金北向资金"
WebSearch: "A股融资余额 市场情绪指标"
WebSearch: "当前A股板块轮动 热点资金动向"
分析框架:
1. 大盘筹码分布(机构持仓成本区 vs 现价)
2. 市场情绪指标(恐惧贪婪指数/涨跌停比/融资余额)
3. 板块轮动阶段(哪个行业在主升,哪个在滞涨)
4. 北向资金流向(外资行为往往领先)
5. 威科夫大盘周期判断
6. 历史估值分位(全A市盈率/市净率历史位置)
```
### 财务报告综合解读
当用户提供或要求分析财报时:
```
解读流程:
1. 营收结构拆解(分业务线/分地区)
2. 利润质量检验(现金流验证)
3. 杜邦三因子分解(ROE来源)
4. 同比/环比关键变化(亮点 + 隐忧)
5. 管理层指引可信度评估(历史预期兑现率)
6. 与市场预期对比(超预期/符合/不及预期)
7. 估值影响评估(业绩变化后的合理估值区间重算)
```
### 行业深度分析
当用户要求行业分析时:
```
分析框架:
1. 行业生命周期(导入期/成长期/成熟期/衰退期)
2. 行业景气度(订单/产能利用率/PPI/库存周期)
3. 政策催化剂(监管趋向/补贴政策/战略定位)
4. 供需格局演变(产能扩张速度 vs 需求增速)
5. 龙头竞争优势量化(市占率/毛利率/研发投入)
6. 海外可比公司估值参照(溢价/折价分析)
```
---
## 语言与呈现规范
1. **语言跟随用户**:用中文问就中文答,用英文问就英文答
2. **专业但易懂**:专业术语需配简短解释(括号内标注)
3. **数据来源标注**:明确注明数据获取渠道和时效
4. **不确定性诚实**:数据不可获得时说明,不编造数据
5. **风险提示前置**:操作建议前必须有风险等级标注
6. **结论先行**:总结放最前,详细分析随后展开
---
## 免责声明(每次分析必须包含)
> **免责声明**:本分析基于公开可获得的信息,结合多种技术/基本面/量化分析框架生成,
> 仅供参考和学习研究,**不构成任何投资建议**。
> 股票市场存在风险,过往表现不代表未来收益。
> 投资者应结合自身风险承受能力、财务状况和投资目标做出独立判断。
> **本工具不对任何投资损失承担责任。**
FILE:_meta.json
{
"ownerId": "kn79bebfnwg15sb0g7cj5z5nyd83gxh0",
"slug": "moneybigA",
"version": "1.0.0",
"publishedAt": null
}
FILE:package.json
{
"name": "moneybigA",
"version": "1.0.0",
"description": "机构级 A股/港股/美股多维分析引擎。融合筹码分布、威科夫方法、SMC智能资金、波浪理论、MACD背离、杜邦分析、DCF估值、多因子Alpha等先进框架。输出交互式 HTML 仪表盘,含买卖信号、风险评级、置信度评分。",
"author": "Cosmos Fang",
"license": "MIT",
"keywords": [
"stock", "finance", "A-share", "technical-analysis", "fundamental-analysis",
"smart-money", "wyckoff", "SMC", "elliott-wave", "chip-distribution",
"quantitative", "alpha-factor", "DCF", "valuation"
],
"scripts": {
"start": "echo 'moneybigA skill loaded'"
}
}
小红书全链路分析 skill。数据采集(SeleniumBase XHR拦截)+ 博主深度分析(三型分类 + 五层模型 + hsword内核三段论)+ 爆款选题公式(6模型 + 30选题方向)+ 结构化报告 + 黑体Word交付。 整合 xhscosmoskill(采集引擎)+ xhsfenxi(分析框架)+ h...
---
name: xhsfenxi-pro
version: 2.0.0
description: |
小红书全链路分析 skill。数据采集(SeleniumBase XHR拦截)+ 博主深度分析(三型分类 + 五层模型 + hsword内核三段论)+ 爆款选题公式(6模型 + 30选题方向)+ 结构化报告 + 黑体Word交付。
整合 xhscosmoskill(采集引擎)+ xhsfenxi(分析框架)+ hsword(实战案例库)。
keywords:
- xiaohongshu
- 小红书分析
- 博主分析
- 爆款选题公式
- 三型博主
- 选题模型
- Word报告
- 账号拆解
---
# xhs-cosmo — 小红书全链路分析 Skill
> 数据采集 × 三型分类 × 内核三段论 × 6模型爆款选题公式 × 黑体Word交付
---
## 工具路径
```
LIB_ROOT: /Users/zezedabaobei/Desktop/cosmocloud/Deeplumen/cosmowork/xiaohongshu_new
COOKIES: /Users/zezedabaobei/Desktop/cosmocloud/Deeplumen/cosmowork/shopify-marketing/xhs_cookies.json
COOKIES_ALT: /Users/zezedabaobei/Desktop/cosmocloud/Deeplumen/cosmowork/xiaohongshu_new/xhs_cookies.json
DATA_DIR: xhscosmoskill/data/ (archetypes.json, bloggers.json)
HSWORD_REF: openclaw_cosmo/afa/hsword/ (实战案例库)
BUILD_DOCX: xhscosmoskill/scripts/build_docx.py
```
---
## 能力总览
| 层 | 来源 | 功能 |
|----|------|------|
| **数据采集** | xhscosmoskill | 用户主页笔记、关键词搜索、笔记详情、评论 |
| **分析框架** | xhsfenxi | 三型分类、五层账号模型、证据分级 |
| **内核三段论** | hsword | 外壳/真正内核/三层人设结构 |
| **爆款选题公式** | hsword | 6模型 × 可套用句式 × 30个选题方向 |
| **可迁移框架** | hsword | 如何把博主方法论迁移到自己账号 |
| **报告生成** | 综合 | 结构化报告 + 爆款选题公式 + 多账号对比 |
| **Word交付** | scripts/build_docx.py | 全黑体样式 + 绿色装饰线 |
---
## 三型博主分类系统
### Type A — 荒诞美学型
- **内核:** 荒诞幽默包裹哲学内核,品牌符号统一(如"(劲爆)")
- **公式:** 荒诞场景 × 品牌符号 × 哲思轻量化 → 审美共鸣
- **代表:** 井越
### Type B — 共鸣命名型
- **内核:** 私人经历 → 普世命题,给模糊情绪命名,`*` 号品牌符号
- **公式:** 私人场景 × 命题化 × 诗意命名 × `*` 印章 → 普世共鸣
- **代表:** xixiCharon、橘一橙NiceFriend
### Type C — 现实策略型
- **内核:** 打破潜规则,提供可执行向上策略,反体面表达
- **公式:** 困境 → 说破规则 → 提供策略 → 爽感执行
- **代表:** 丑穷女孩陈浪浪
### 混合型(最强组合)
- **B+A:** 既"给情绪命名"又"有审美质感" — xixiCharon
- **B+C:** 既"懂你"又"告诉你下一步怎么做"
---
## hsword 内核三段论(必做步骤)
每次分析必须明确三层:
```
外壳是什么?(表面看起来像什么博主)
↓
真正的内核是什么?(一句话,带""引号的精炼)
↓
三层人设结构:
表层标签 → 身份/场景/标签
中层特质 → 性格/能力/气质
深层价值观 → 鼓励什么/认可什么/传递什么
```
**关键洞察:** 真正让账号成立的是第三层。第一层可模仿,第二层可包装,但第三层必须靠长期内容一致才能被用户相信。
---
## 爆款选题公式体系(6大模型)
### 模型1:场景 × 哲思型(B型均赞最高)
```
[具体地点/场景] + [在这里感受到的哲学状态] + [品牌符号]
```
机制:地点宏大 × 感受日常 × 语言诗意 = 三重张力
### 模型2:状态命名型(高收藏率)
```
[擅长/习惯做X的人] + 对[某事物]的感知是[新的理解]的 + [品牌符号]
```
机制:把混沌状态翻译成可被理解的概念 → 用户"被命名"的满足感
### 模型3:阶段宣言型(节点必出)
```
[时间节点/年龄阶段] + [这个阶段的成长判断] + [品牌符号]
```
机制:时间节点触发情绪浓度 × 个人叙事 × 同龄共鸣
### 模型4:独行宣言型(独立女性共鸣)
```
[我独自/一个人] + [行动] + [反预期结果]
```
机制:独立女性认同 × 行动力展示 × 反预期 = 三重共鸣
### 模型5:情绪逆转型(治愈系高收藏)
```
[消极状态] + 其实是/让我明白了 + [正向领悟] + [品牌符号]
```
机制:情绪低谷共鸣 + 逆转出口 = "被治愈的可能性"
### 模型6:世界观输出型(最强粘性)
```
[持续做X的人/坚持Y的意义] + [是如何理解Z的] + [品牌符号]
```
机制:用户收藏的是"世界观",会持续追更
---
## 完整分析管道(Full Pipeline)
```
Step 0 Cookie 健康检查
↓
Step 1 解析输入(URL/ID/名称)→ 检查数据库是否已有记录
↓
Step 2 数据采集(get_user_notes, limit=50, scroll_times=10)
↓
Step 3 基础统计(compute_stats)
↓
Step 4 三型分类(classify_archetype)
↓
Step 5 内核三段论(build_five_layers + 手动补充外壳/内核/深层价值观)
↓
Step 6 爆款选题公式生成(generate_formula_report)
↓
Step 7 生成结构化报告 Markdown(mode='full')
↓
Step 8 外部文档合并(如有用户提供额外分析文档)
↓
Step 9 写入博主数据库(save_blogger)
↓
Step 10 生成 Word(scripts/build_docx.py)
```
---
## 数据采集 API
```python
import sys
sys.path.insert(0, "/Users/zezedabaobei/Desktop/cosmocloud/Deeplumen/cosmowork/xiaohongshu_new")
from xhscosmoskill import XhsClient
from xhscosmoskill.analyzer import analyze_account, classify_archetype, compute_stats, build_five_layers
from xhscosmoskill.formula import generate_formula_report
from xhscosmoskill.archetype_registry import save_blogger, list_archetypes, get_blogger
COOKIES = "/Users/zezedabaobei/Desktop/cosmocloud/Deeplumen/cosmowork/shopify-marketing/xhs_cookies.json"
with XhsClient(cookies_file=COOKIES, headless=True, scroll_times=10) as xhs:
notes = xhs.get_user_notes(user_id, limit=50)
report = xhs.analyze_account(notes, creator_name=name, mode="full")
```
---
## 报告生成 API
| 函数 | 说明 |
|------|------|
| `analyze_account(notes, creator_name, mode)` | 主入口,mode: full/formula/snapshot |
| `classify_archetype(notes)` | 三型分类,返回类型+置信度 |
| `build_five_layers(notes, archetype)` | 五层账号模型 |
| `compute_stats(notes)` | 基础统计 |
| `generate_formula_report(notes, creator_name, archetype)` | 生成爆款选题公式报告 |
---
## Word 生成
```python
# 单命令生成黑体Word
python3 scripts/build_docx.py <md_path> <out_path> <title> <subtitle>
```
或在代码中:
```python
from xhscosmoskill.scripts.build_docx import build_word
build_word(md_path="/tmp/report.md", out_path="/tmp/report.docx",
title="xixiCharon", subtitle="爆款选题公式")
```
---
## Cookie 管理
```python
# 优先使用(最新)
COOKIES = ".../shopify-marketing/xhs_cookies.json"
# 备用
COOKIES_ALT = ".../xiaohongshu_new/xhs_cookies.json"
# Cookie 健康检查
from xhscosmoskill.utils import check_cookies
status = check_cookies(COOKIES) # 返回 {valid: bool, expired_keys: list}
```
**Cookie 过期标志:** notes 返回 ≤ 1 条 → 提示重新运行 `xhs_login.py`
---
## 数据库操作
```python
from xhscosmoskill.archetype_registry import (
save_blogger, # 写入/更新博主记录
get_blogger, # 按名称查询
list_bloggers, # 列出所有博主
list_archetypes, # 查看当前类型库
add_archetype, # 新增自定义类型
update_archetype_signals # 迭代更新类型信号词
)
```
---
## 交付物规范
| 文件 | 格式 | 说明 |
|------|------|------|
| `{博主名}-结构化总结报告.md/.docx` | Markdown + Word | 单账号深度分析(15节)|
| `{博主名}-爆款选题公式.md/.docx` | Markdown + Word | 6模型 + 30选题方向 |
| `选题公式学习-综合版.md/.docx` | Markdown + Word | 多账号对比 |
---
## 证据分级
| 级别 | 来源 | 使用方式 |
|------|------|---------|
| A1 | 小红书公开主页可见数据 | 直接陈述 |
| A2 | 用户提供截图 | 直接陈述 |
| B1 | 第三方公开资料(采访/新榜/百科)| 作为背景补充 |
| C1 | 综合推断 | 明确标注为"推断" |
---
## 参考资源
- **实战案例库(hsword):** `openclaw_cosmo/afa/hsword/`
- 井越(Type A):荒诞美学型完整报告 + 爆款公式
- 橘一橙NiceFriend(Type B):共鸣命名型完整报告 + 爆款公式
- 丑穷女孩陈浪浪(Type C):现实策略型完整报告 + 爆款公式
- 选题公式学习-综合版:双系统 + 混合公式 + 6种标题公式
- **分析框架参考:** `references/workflow.md`
- **报告模板:** `references/templates.md`
- **hsword框架:** `references/hsword-frameworks.md`
- **新榜数据:** `https://www.newrank.cn/profile/xiaohongshu/{user_id}`
---
## 已分析博主档案
| 博主 | 类型 | 最高赞 | 均赞 | 分析日期 |
|------|------|--------|------|---------|
| xixiCharon | B+A | 10万+ | 3,148 | 2026-04-23 |
*更多博主将在 `/xhsfx` 调用后自动写入 data/bloggers.json*
---
*整合自 xhscosmoskill v1.0 + xhsfenxi v2.1 + hsword实战案例 · 版本 2.0.0 · 2026-04-23*
---
## Purpose & Capability
**Xhsfenxi Pro** is a full-stack Xiaohongshu (小红书) blogger analysis skill. It combines automated data collection with a structured deep-analysis framework derived from real-world case studies (hsword archive).
| Capability | Description |
|-----------|-------------|
| Data Collection | Scrape user notes via SeleniumBase XHR interception — no API key required |
| Three-Archetype Classification | Classify bloggers as Type A (Absurdist Aesthetics) / B (Resonance Naming) / C (Reality Strategy) or hybrid |
| Core Identity Analysis (hsword) | Three-layer persona deconstruction: surface labels / mid-layer traits / deep values |
| Viral Topic Formula | 6-model formula system with reusable sentence templates and 30 ready-to-use topic directions |
| Structured Reports | Full 15-section analysis report in Markdown |
| Word Output | Black Heiti-font Word documents via `scripts/build_docx.py` |
| Iterative Archetype DB | `data/archetypes.json` evolves as more bloggers are analyzed |
**Does NOT:**
- Require any Xiaohongshu API token (uses browser-based XHR interception)
- Access private/protected notes or accounts
- Store or transmit user credentials
- Generate fake engagement data or fabricate analysis results
---
## Instruction Scope
**In scope — will handle:**
- "Analyze this Xiaohongshu blogger URL"
- "Generate viral topic formula for this account"
- "What archetype is this blogger?"
- "Produce a Word report for this creator"
- "Compare two bloggers"
- Any `/xhsfx` command invocation
**Out of scope — will not handle:**
- Accessing private accounts or bypassing platform security
- Publishing or posting content to Xiaohongshu on behalf of users
- Real-time follower/engagement data (uses public page data only)
- Non-Xiaohongshu platforms
**When cookies expire:**
The browser session cookie file expires approximately every 30 days. If `notes` returns ≤ 1 result, prompt the user to re-run `python3 xhs_login.py` to refresh cookies. The skill will not silently fail — it will detect and report the expired state.
---
## Credentials
This skill uses **no API tokens or platform credentials for analysis**.
| Action | Credential | Scope |
|--------|-----------|-------|
| Data collection | Xiaohongshu session cookie (browser-based) | Local file only — `xhs_cookies.json` |
| Report generation | None | Local file writes only |
| Word output | None | Local file writes only |
Cookie file locations (in priority order):
1. `shopify-marketing/xhs_cookies.json` (preferred — most recent)
2. `xiaohongshu_new/xhs_cookies.json` (fallback)
**Does NOT hardcode tokens, API keys, or account credentials.**
Cookie files are local only and never transmitted.
---
## Persistence & Privilege
| Path | Content | When written |
|------|---------|-------------|
| `data/archetypes.json` | Archetype registry — evolves as bloggers are analyzed | On each `save_blogger()` call |
| `data/bloggers.json` | Analyzed blogger database | On each `save_blogger()` call |
| `/tmp/{creator}-*.md` | Markdown analysis reports | On report generation |
| `/tmp/{creator}-*.docx` | Word documents | On `build_word()` call |
**Does NOT write to:**
- Any system directories outside the skill directory and `/tmp/`
- Shell configuration files (`~/.zshrc` etc.)
- Xiaohongshu platform (read-only)
**Uninstall:** Delete the skill directory. Cookie files at `xhs_cookies.json` paths can be deleted separately.
---
## Install Mechanism
```bash
clawhub install xhsfenxi-pro
```
**Prerequisites:**
```bash
pip install seleniumbase python-docx
```
**First-time cookie setup:**
```bash
python3 xhs_login.py
# Opens browser → log in to Xiaohongshu → cookies saved automatically
```
**Verify installation:**
```python
import sys
sys.path.insert(0, "/path/to/xhscosmoskill/..")
import xhscosmoskill
print(xhscosmoskill.__version__) # should print: 2.0.0
from xhscosmoskill import print_cookie_status
print_cookie_status() # ✅ 全部有效 / ❌ 已过期
```
**Quick analysis:**
```python
from xhscosmoskill import XhsClient, analyze_account
with XhsClient() as xhs:
notes = xhs.get_user_notes("USER_ID_HERE", limit=50)
report = xhs.analyze_account(notes, creator_name="BloggerName")
print(report)
```
FILE:README.md
# xhscosmoskill — 小红书数据抓取工具
> Agent 可直接调用的小红书内容抓取客户端。
> 基于 SeleniumBase UC Mode + XHR 拦截,绕过反爬检测,无需逆向签名算法。
---
## 核心原理
```
用户登录一次 → 保存 Cookie
↓
SeleniumBase UC Mode 打开浏览器(绕过 headless 检测)
↓
注入 JS 拦截器(劫持 fetch/XHR)
↓
浏览器自动带签名请求小红书 API
↓
拦截器捕获 API 响应 JSON → 返回结构化数据
```
不需要逆向 X-s/X-t 签名,因为我们让浏览器自己完成签名,然后从内存中读取响应。
---
## 前置步骤(只需一次)
```bash
# 1. 安装依赖
pip install seleniumbase
# 2. 登录小红书,保存 cookie
cd /Users/zezedabaobei/Desktop/cosmocloud/Deeplumen/cosmowork/xiaohongshu_new
python3 xhs_login.py
# → 浏览器弹出,手动登录,按 Enter 保存
# → 生成 xhs_cookies.json(有效期约 30 天)
```
---
## 快速使用
```python
import sys
sys.path.insert(0, "/Users/zezedabaobei/Desktop/cosmocloud/Deeplumen/cosmowork/xiaohongshu_new")
from xhscosmoskill import XhsClient
with XhsClient() as xhs:
# 搜索关键词
notes = xhs.search("咖啡", limit=20)
# 获取用户所有笔记
notes = xhs.get_user_notes("5b07eb49e8ac2b5fe1e7de09", limit=50)
# 获取单篇详情(正文 + 评论)
note = xhs.get_note_detail("https://www.xiaohongshu.com/explore/xxx")
# 批量补充详情
notes = xhs.batch_get_details(notes, delay=2.0)
# 保存
xhs.save(notes, "output.json")
```
---
## API 文档
### `XhsClient(cookies_file, headless, scroll_times)`
| 参数 | 类型 | 默认 | 说明 |
|------|------|------|------|
| cookies_file | str | `../xhs_cookies.json` | Cookie 文件路径 |
| headless | bool | False | 无头模式(稳定后可开启) |
| scroll_times | int | 5 | 默认滚动次数(影响加载量) |
---
### `xhs.search(keyword, limit, scroll_times) → List[Note]`
搜索笔记。
```python
notes = xhs.search("咖啡", limit=30)
# notes[0].title, notes[0].likes, notes[0].url, notes[0].type
```
---
### `xhs.get_user_notes(user_id, limit, scroll_times) → List[Note]`
获取用户主页所有笔记。`user_id` 是 URL 中的 hex 字符串。
```python
# URL: xiaohongshu.com/user/profile/5b07eb49e8ac2b5fe1e7de09
notes = xhs.get_user_notes("5b07eb49e8ac2b5fe1e7de09", limit=100)
```
---
### `xhs.get_note_detail(note_url) → Note | None`
获取单篇笔记详情,包含正文、评论、互动数据。
```python
note = xhs.get_note_detail("https://www.xiaohongshu.com/explore/xxx")
print(note.content) # 正文全文
print(note.comments) # List[Comment]
```
---
### `xhs.batch_get_details(notes, delay) → List[Note]`
在搜索/列表结果基础上批量补充正文和评论。
```python
notes = xhs.search("咖啡", limit=20)
notes = xhs.batch_get_details(notes, delay=2.0) # delay 建议 1.5~3 秒
```
---
### `xhs.save(notes, filepath)`
保存为 JSON 文件。
---
## 数据模型
### `Note`
```
id str 笔记 ID
title str 标题
content str 正文全文
url str 完整 URL
author str 作者昵称
author_id str 作者 ID
likes str 点赞数(字符串,如 "3万")
collects str 收藏数
comments_count str 评论数
type str "video" | "normal"
images list 图片 URL 列表
tags list 话题标签列表
comments list List[Comment]
```
### `Comment`
```
user str 评论者昵称
text str 评论内容
likes str 评论点赞数
time str 评论时间
```
---
## 稳定性说明
| 场景 | 稳定性 | 说明 |
|------|--------|------|
| 搜索列表 | ✅ 稳定 | DOM 兜底,基本必成 |
| 用户主页列表 | ✅ 稳定 | 同上 |
| 笔记详情正文 | ⚠️ 较好 | API 拦截优先,DOM 兜底 |
| 评论数据 | ⚠️ 较好 | 依赖 API 拦截成功 |
- Cookie 有效期约 30 天,过期后重跑 `xhs_login.py`
- 建议单账号每次请求间隔 1.5 秒以上,避免风控
- 首次使用建议 `headless=False` 观察浏览器行为
---
## 文件结构
```
xhscosmoskill/
├── __init__.py 入口,导出 XhsClient
├── client.py 主客户端类(对外 API)
├── browser.py 浏览器会话 + XHR 拦截器
├── parser.py API JSON / DOM 解析器
├── models.py 数据模型(Note / Comment / UserProfile)
├── README.md 本文件
└── examples/
├── search_notes.py 搜索示例
├── user_profile.py 用户主页示例
└── note_detail.py 单篇详情示例
```
---
## 常见问题
**Q: `Cookie 文件不存在` 报错**
A: 运行 `python3 xhs_login.py` 先登录一次。
**Q: content 字段为空**
A: 该笔记的详情页 API 未被拦截到。可尝试增加 `wait` 时间,或该笔记需要登录才可见。
**Q: 运行很慢**
A: 正常,每次操作需要等待页面渲染。可在稳定后开启 `headless=True`。
**Q: 被封号怎么办**
A: 降低频率(`delay=3`),换账号重新 `xhs_login.py`。
FILE:__init__.py
from .client import XhsClient
from .analyzer import analyze_account, classify_archetype, compute_stats, build_five_layers
from .formula import generate_formula_report
from .archetype_registry import (
save_blogger, get_blogger, list_bloggers,
list_archetypes, add_archetype, update_archetype_signals,
)
from .utils import check_cookies, get_best_cookies, print_cookie_status
__all__ = [
"XhsClient",
"analyze_account", "classify_archetype", "compute_stats", "build_five_layers",
"generate_formula_report",
"save_blogger", "get_blogger", "list_bloggers",
"list_archetypes", "add_archetype", "update_archetype_signals",
"check_cookies", "get_best_cookies", "print_cookie_status",
]
__version__ = "2.0.0"
FILE:_meta.json
{
"ownerId": "kn79bebfnwg15sb0g7cj5z5nyd83gxh0",
"slug": "xhsfenxi-pro",
"version": "2.0.0",
"publishedAt": null
}
FILE:analyzer.py
"""
analyzer.py — 小红书博主分析引擎
整合 xhsfenxi 三型博主分类体系 + 五层账号模型
可与 XhsClient 采集结果直接对接
用法:
from xhscosmoskill import XhsClient
from xhscosmoskill.analyzer import analyze_account
with XhsClient() as xhs:
notes = xhs.get_user_notes("user_id", limit=50)
report = analyze_account(notes, creator_name="xixiCharon")
print(report)
"""
from __future__ import annotations
from typing import List, Dict, Any
import re
from collections import Counter
# ── 三型博主分类系统 ──────────────────────────────────────────
ARCHETYPE_SIGNALS = {
"A": {
"name": "荒诞美学型",
"desc": "用荒诞幽默包裹哲学内核,高质感视觉,品牌符号统一",
"title_signals": ["(劲爆)", "劲爆", "*", "*", "·"],
"content_signals": ["哲", "美学", "荒诞", "飞", "高山", "宇宙", "奖励", "永恒"],
"commercial": "高端生活方式、旅行、摄影、轻奢品牌",
"difficulty": "高 — 依赖长期审美积累",
},
"B": {
"name": "共鸣命名型",
"desc": "把私人经历转化为普世命题,给模糊情绪命名",
"title_signals": ["*", "为什么", "是什么", "原来", "才知道", "命题", "灯塔", "解药"],
"content_signals": ["成长", "情绪", "留学", "记录", "年末", "不确定", "命题", "感受", "理解", "接纳"],
"commercial": "成长/教育/创意平台、中端生活方式",
"difficulty": "中 — 方法可学,需要真实观察力",
},
"C": {
"name": "现实策略型",
"desc": "打破职场/人际/金钱潜规则,提供可执行的向上策略",
"title_signals": ["骗子", "不要脸", "装", "穷", "规则", "策略", "大法"],
"content_signals": ["策略", "规则", "执行", "调整", "快速", "现实", "向上", "方法"],
"commercial": "大众消费、职场、电商、实用工具",
"difficulty": "中 — 框架可学,切忌复制表面攻击性",
},
}
def classify_archetype(notes: List[Any]) -> Dict[str, Any]:
"""
基于笔记标题关键词,初步判断博主类型。
返回: { "type": "B", "name": "共鸣命名型", "scores": {...}, "rationale": [...] }
"""
scores = {"A": 0, "B": 0, "C": 0}
evidence = {"A": [], "B": [], "C": []}
for note in notes:
title = note.title or "" if hasattr(note, "title") else note.get("title", "")
content = note.content or "" if hasattr(note, "content") else note.get("content", "")
text = title + " " + content
for typ, cfg in ARCHETYPE_SIGNALS.items():
for sig in cfg["title_signals"]:
if sig in title:
scores[typ] += 2
evidence[typ].append(f"标题含「{sig}」: {title[:30]}")
for sig in cfg["content_signals"]:
if sig in text:
scores[typ] += 1
# 归一化
total = sum(scores.values()) or 1
pct = {k: round(v / total * 100) for k, v in scores.items()}
best = max(scores, key=scores.get)
second = sorted(scores, key=scores.get, reverse=True)[1]
# 混合判断
is_mixed = scores[second] >= scores[best] * 0.6
if is_mixed:
archetype_type = f"{best}+{second}"
archetype_name = f"{ARCHETYPE_SIGNALS[best]['name']} × {ARCHETYPE_SIGNALS[second]['name']}"
else:
archetype_type = best
archetype_name = ARCHETYPE_SIGNALS[best]["name"]
rationale = evidence[best][:3] + (evidence[second][:2] if is_mixed else [])
return {
"type": archetype_type,
"name": archetype_name,
"desc": ARCHETYPE_SIGNALS[best]["desc"],
"scores": scores,
"pct": pct,
"rationale": rationale,
"commercial": ARCHETYPE_SIGNALS[best]["commercial"],
"difficulty": ARCHETYPE_SIGNALS[best]["difficulty"],
"is_mixed": is_mixed,
}
# ── 数据工具 ──────────────────────────────────────────────────
def _parse_likes(v) -> int:
if not v:
return 0
s = str(v).replace(",", "").strip()
if "万" in s:
return int(float(s.replace("万", "")) * 10000)
try:
return int(s)
except ValueError:
return 0
def _engagement_tier(likes: int) -> str:
if likes >= 50000:
return "爆款 🔥"
if likes >= 10000:
return "高量 ⭐"
if likes >= 5000:
return "中高量"
if likes >= 1000:
return "稳定量"
return "普通"
# ── 五层账号模型 ──────────────────────────────────────────────
def build_five_layers(notes: List[Any], archetype: Dict) -> Dict[str, Any]:
"""
提取五层账号模型:
1. Identity — 博主是谁(人设定位)
2. Contract — 用户为什么关注(情感契约)
3. Topic — 反复覆盖的问题/欲望(选题体系)
4. Expression — 标题/开头公式、品牌符号
5. Transfer — 可学习点 vs 不能硬抄
"""
titles = []
likes_list = []
for n in notes:
t = n.title if hasattr(n, "title") else n.get("title", "")
lk = _parse_likes(n.likes if hasattr(n, "likes") else n.get("likes"))
if t:
titles.append((t, lk))
likes_list.append(lk)
# 选题体系:基于标题词频聚类
topic_keywords = {
"自然/户外": ["山", "自然", "户外", "攀岩", "跑", "溪", "飞", "森林", "海"],
"留学/海外": ["留学", "巴黎", "欧洲", "外国", "服装生", "独居", "课后"],
"情绪/成长": ["情绪", "成长", "失败", "不确定", "淤青", "消极", "烦恼", "困境"],
"旅行/探索": ["旅行", "solotrip", "solo", "gap", "世界", "探索"],
"生活哲学": ["生活", "命题", "本质", "人生", "记录", "计划", "感受", "存在", "宇宙"],
"创作/记录": ["记录", "vlog", "日记", "分享", "创造力"],
}
topic_dist: Dict[str, List] = {k: [] for k in topic_keywords}
for title, lk in titles:
for topic, kws in topic_keywords.items():
if any(kw in title for kw in kws):
topic_dist[topic].append((title, lk))
best_topic = max(topic_dist, key=lambda k: sum(x[1] for x in topic_dist[k]) / max(len(topic_dist[k]), 1))
# 标题模式分析
has_asterisk = sum(1 for t, _ in titles if "*" in t or "*" in t)
has_question = sum(1 for t, _ in titles if "为什么" in t or "如何" in t or "?" in t)
has_contrast = sum(1 for t, _ in titles if "但" in t or "," in t)
has_emotion_label = sum(1 for t, _ in titles if any(w in t for w in ["模糊", "流动", "感受", "治愈", "珍贵"]))
# 爆款标题
top_titles = sorted(titles, key=lambda x: -x[1])[:5]
return {
"identity": _infer_identity(titles, archetype),
"contract": _infer_contract(archetype),
"topic_system": {k: len(v) for k, v in topic_dist.items() if v},
"best_topic": best_topic,
"best_topic_avg": round(
sum(x[1] for x in topic_dist[best_topic]) / max(len(topic_dist[best_topic]), 1)
),
"expression": {
"asterisk_ratio": f"{has_asterisk}/{len(titles)}",
"question_titles": has_question,
"contrast_titles": has_contrast,
"emotion_labels": has_emotion_label,
"top_titles": top_titles,
},
"transfer": _infer_transfer(archetype),
}
def _infer_identity(titles, archetype) -> str:
typ = archetype["type"][0]
if typ == "A":
return "用荒诞美学构建独特视角的创作者,以品牌符号建立强识别度"
if typ == "B":
return "把私人经历转化为公共命题、擅长给模糊情绪命名的思考者"
return "洞察现实规则、提供可执行策略的实用派创作者"
def _infer_contract(archetype) -> str:
typ = archetype["type"][0]
if typ == "A":
return "看了就愉快 + 被美学震撼 + 看到了另一种看待生活的方式"
if typ == "B":
return "被理解 + 获得命名 + 对自己的状态有了新的视角"
return "被验证 + 被激活 + 获得可执行的向上移动路径"
def _infer_transfer(archetype) -> Dict[str, List[str]]:
typ = archetype["type"][0]
if typ == "A":
return {
"可学": ["建立自己的品牌符号", "练习反差命名而非描述地点", "荒诞框架选题"],
"不能硬抄": ["视觉审美需长期积累", "语气独特性不可复制"],
}
if typ == "B":
return {
"可学": ["命题化思维:私人经历→公共命题", "建立个人概念词汇库", "五步公式:经历→命题→命名→判断→共鸣"],
"不能硬抄": ["真实性是基础,无法凭空虚构", "语气温度需与真实人设一致"],
}
return {
"可学": ["找到自己的现实母题", "冲突词标题练习", "公式:困境→说破→规则→策略→爽感"],
"不能硬抄": ["表面攻击性语气", "特定人设的羞耻感先夺法"],
}
# ── 数据统计 ──────────────────────────────────────────────────
def compute_stats(notes: List[Any]) -> Dict[str, Any]:
likes = [_parse_likes(n.likes if hasattr(n, "likes") else n.get("likes")) for n in notes]
types = Counter(
(n.type if hasattr(n, "type") else n.get("type", "unknown")) for n in notes
)
brackets = {"1万+": 0, "5000-9999": 0, "1000-4999": 0, "500-999": 0, "<500": 0}
for lk in likes:
if lk >= 10000:
brackets["1万+"] += 1
elif lk >= 5000:
brackets["5000-9999"] += 1
elif lk >= 1000:
brackets["1000-4999"] += 1
elif lk >= 500:
brackets["500-999"] += 1
else:
brackets["<500"] += 1
top = sorted(
[(n.title if hasattr(n, "title") else n.get("title", ""), _parse_likes(n.likes if hasattr(n, "likes") else n.get("likes"))) for n in notes],
key=lambda x: -x[1],
)[:10]
return {
"total": len(notes),
"video_count": types.get("video", 0),
"normal_count": types.get("normal", 0),
"total_likes": sum(likes),
"avg_likes": round(sum(likes) / len(likes)) if likes else 0,
"max_likes": max(likes) if likes else 0,
"brackets": brackets,
"top10": top,
}
# ── 主入口 ────────────────────────────────────────────────────
def analyze_account(
notes: List[Any],
creator_name: str = "未知博主",
mode: str = "full",
) -> str:
"""
对采集到的笔记列表做完整分析,返回 Markdown 报告。
参数:
notes — XhsClient.get_user_notes() 的返回值
creator_name — 博主名称
mode — 'full' 完整报告 | 'formula' 只输出选题公式 | 'snapshot' 快速摘要
"""
stats = compute_stats(notes)
archetype = classify_archetype(notes)
five = build_five_layers(notes, archetype)
if mode == "snapshot":
return _render_snapshot(creator_name, stats, archetype)
if mode == "formula":
return _render_formula(creator_name, stats, archetype, five)
return _render_full_report(creator_name, stats, archetype, five)
# ── 渲染函数 ──────────────────────────────────────────────────
def _render_snapshot(name, stats, archetype) -> str:
return f"""# {name} — 快速摘要
| 指标 | 数值 |
|------|------|
| 分析笔记 | {stats['total']} 篇 |
| 视频占比 | {stats['video_count']}/{stats['total']} |
| 平均点赞 | {stats['avg_likes']:,} |
| 最高单篇 | {stats['max_likes']:,} |
| 博主类型 | **{archetype['name']}** ({archetype['type']}) |
| 商业适配 | {archetype['commercial']} |
"""
def _render_formula(name, stats, archetype, five) -> str:
top_topic = five["best_topic"]
top_avg = five["best_topic_avg"]
transfer = five["transfer"]
top_titles_md = "\n".join(
f"- 👍{lk:,} {t[:50]}" for t, lk in five["expression"]["top_titles"]
)
can_learn = "\n".join(f"- ✅ {x}" for x in transfer["可学"])
cant_copy = "\n".join(f"- ❌ {x}" for x in transfer["不能硬抄"])
return f"""# {name} — 爆款选题公式
> 类型:{archetype['name']} | 平均点赞:{stats['avg_likes']:,} | 最高:{stats['max_likes']:,}
## 一、为什么能做出爆款?
博主类型:**{archetype['name']}**
{archetype['desc']}
核心公式:{_get_core_formula(archetype['type'][0])}
## 二、最强选题方向
最高互动主题:**{top_topic}**(均赞 {top_avg:,})
## 三、爆款标题参考
{top_titles_md}
## 四、标题模式
- 含 * 符号:{five['expression']['asterisk_ratio']}(情感标记)
- 疑问句式:{five['expression']['question_titles']} 篇
- 情绪词命名:{five['expression']['emotion_labels']} 篇
## 五、可学 / 不能硬抄
{can_learn}
{cant_copy}
"""
def _render_full_report(name, stats, archetype, five) -> str:
topic_table = "\n".join(
f"| {t} | {c}篇 |"
for t, c in sorted(five["topic_system"].items(), key=lambda x: -x[1])
)
brackets_md = "\n".join(
f"| {k} | {'█' * v} {v}篇 |"
for k, v in stats["brackets"].items()
)
top10_md = "\n".join(
f"| {i+1} | 👍{lk:,} | {_engagement_tier(lk)} | {t[:45]} |"
for i, (t, lk) in enumerate(stats["top10"])
)
rationale_md = "\n".join(f"- {r}" for r in archetype["rationale"]) or "- 基于内容风格综合判断"
can_learn = "\n".join(f"- ✅ {x}" for x in five["transfer"]["可学"])
cant_copy = "\n".join(f"- ❌ {x}" for x in five["transfer"]["不能硬抄"])
return f"""# {name} — 结构化分析报告
> 数据来源:小红书公开主页,样本量 {stats['total']} 篇
---
## 一、账号快照
| 指标 | 数值 |
|------|------|
| 分析样本 | {stats['total']} 篇 |
| 视频/图文 | {stats['video_count']} / {stats['normal_count']} |
| 总点赞 | {stats['total_likes']:,} |
| 平均点赞 | {stats['avg_likes']:,} |
| 最高单篇 | {stats['max_likes']:,} |
| 爆款(1万+) | {stats['brackets']['1万+']} 篇 |
---
## 二、账号类型判断
**类型:** {archetype['name']} ({archetype['type']})
**核心内核:** {archetype['desc']}
**判断依据:**
{rationale_md}
---
## 三、人设定位(Identity)
{five['identity']}
---
## 四、用户情感契约(Audience Contract)
用户关注这个博主,是为了:
{five['contract']}
---
## 五、选题体系(Topic System)
| 主题方向 | 篇数 |
|----------|------|
{topic_table}
**最强方向:** {five['best_topic']}(均赞 {five['best_topic_avg']:,})
---
## 六、表达体系(Expression System)
| 维度 | 数据 |
|------|------|
| 含 * 情感符号 | {five['expression']['asterisk_ratio']} 篇 |
| 疑问句标题 | {five['expression']['question_titles']} 篇 |
| 情绪词命名 | {five['expression']['emotion_labels']} 篇 |
**核心公式:** {_get_core_formula(archetype['type'][0])}
---
## 七、高互动内容 Top 10
| # | 点赞 | 量级 | 标题 |
|---|------|------|------|
{top10_md}
---
## 八、点赞分布
| 段位 | 分布 |
|------|------|
{brackets_md}
---
## 九、商业化判断
- **适合方向:** {archetype['commercial']}
- **复制难度:** {archetype['difficulty']}
---
## 十、可学 / 不能硬抄
**可以学习:**
{can_learn}
**不能硬抄:**
{cant_copy}
---
## 十一、后续建议
- 路径 A:深入拆解 3-5 篇高互动笔记的正文结构
- 路径 B:制作专项爆款选题公式(`mode='formula'`)
- 路径 C:引入对标账号做多账号综合对比
"""
def _get_core_formula(typ: str) -> str:
formulas = {
"A": "荒诞场景 × 品牌符号 × 哲思轻量化 → 审美共鸣",
"B": "私人经历 → 命题化 → 给模糊状态命名 → 普世共鸣",
"C": "困境 → 说破规则 → 提供策略 → 爽感执行",
}
return formulas.get(typ, "内容积累 → 风格稳定 → 用户粘性")
FILE:archetype_registry.py
"""
archetype_registry.py — 可迭代博主类型注册表
随分析案例积累,自动更新类型数据库,支持添加新类型
"""
from __future__ import annotations
import json
import os
from datetime import date
from typing import Dict, Any, Optional
_BASE = os.path.dirname(__file__)
ARCHETYPES_FILE = os.path.join(_BASE, "data", "archetypes.json")
BLOGGERS_FILE = os.path.join(_BASE, "data", "bloggers.json")
# ── 读写工具 ──────────────────────────────────────────────────
def _load(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
def _save(path: str, data: dict):
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# ── 类型注册表操作 ────────────────────────────────────────────
def load_archetypes() -> Dict[str, Any]:
"""加载当前所有类型(内置 + 自定义)"""
db = _load(ARCHETYPES_FILE)
merged = {**db["archetypes"], **db.get("custom_archetypes", {})}
return merged
def add_archetype(key: str, name: str, desc: str, formula: str,
title_signals: list, content_signals: list,
commercial: str, difficulty: str = "中") -> str:
"""
添加新博主类型(当现有三型无法覆盖时)
参数:
key — 类型代码,如 "D"、"E" 或自定义字符串
name — 类型名,如 "知识科普型"
...
返回: 成功提示
"""
db = _load(ARCHETYPES_FILE)
if key in db["archetypes"]:
return f"⚠️ 类型 {key} 已存在于内置类型,请用其他 key"
db["custom_archetypes"][key] = {
"name": name,
"desc": desc,
"formula": formula,
"title_signals": title_signals,
"content_signals": content_signals,
"commercial": commercial,
"difficulty": difficulty,
"examples": [],
"confirmed_by": 0,
}
db["_meta"]["last_updated"] = str(date.today())
_save(ARCHETYPES_FILE, db)
return f"✅ 新增类型 {key}「{name}」"
def update_archetype_signals(key: str, new_title_signals: list = None,
new_content_signals: list = None) -> str:
"""从新分析的博主身上迭代更新信号词"""
db = _load(ARCHETYPES_FILE)
target = db["archetypes"].get(key) or db["custom_archetypes"].get(key)
if not target:
return f"❌ 类型 {key} 不存在"
if new_title_signals:
existing = set(target["title_signals"])
added = [s for s in new_title_signals if s not in existing]
target["title_signals"].extend(added)
if new_content_signals:
existing = set(target["content_signals"])
added = [s for s in new_content_signals if s not in existing]
target["content_signals"].extend(added)
target["confirmed_by"] = target.get("confirmed_by", 0) + 1
db["_meta"]["last_updated"] = str(date.today())
_save(ARCHETYPES_FILE, db)
return f"✅ 类型 {key} 信号词已更新"
def list_archetypes() -> str:
"""打印当前所有类型(用于 /xhsfx 开头展示)"""
db = _load(ARCHETYPES_FILE)
lines = [f"📊 当前博主类型库({db['_meta']['last_updated']} 更新)\n"]
for key, cfg in db["archetypes"].items():
ex = f" — 案例: {', '.join(cfg['examples'][:2])}" if cfg["examples"] else ""
lines.append(f" **{key}** {cfg['name']} × {cfg['confirmed_by']}个案例{ex}")
custom = db.get("custom_archetypes", {})
if custom:
lines.append("\n **自定义类型:**")
for key, cfg in custom.items():
lines.append(f" **{key}** {cfg['name']} × {cfg['confirmed_by']}个案例")
return "\n".join(lines)
# ── 博主数据库操作 ────────────────────────────────────────────
def save_blogger(creator_name: str, user_id: str, archetype: dict,
stats: dict, best_topic: str, best_topic_avg: int,
tags: list = None, formula: str = "") -> str:
"""
分析完成后写入博主数据库,并更新对应类型的案例数
"""
db = _load(BLOGGERS_FILE)
# 检查是否已存在
existing = next((b for b in db["bloggers"] if b["user_id"] == user_id), None)
if existing:
existing.update({
"analyzed_at": str(date.today()),
"archetype": archetype["type"],
"archetype_name": archetype["name"],
"stats": stats,
"best_topic": best_topic,
"best_topic_avg": best_topic_avg,
})
action = "更新"
else:
db["bloggers"].append({
"creator_name": creator_name,
"user_id": user_id,
"analyzed_at": str(date.today()),
"archetype": archetype["type"],
"archetype_name": archetype["name"],
"stats": stats,
"best_topic": best_topic,
"best_topic_avg": best_topic_avg,
"tags": tags or [],
"formula": formula,
})
db["_meta"]["total"] = len(db["bloggers"])
action = "新增"
_save(BLOGGERS_FILE, db)
# 同步更新类型案例
_update_archetype_example(archetype["type"][0], creator_name)
return f"✅ 博主「{creator_name}」已{action}到数据库(共 {db['_meta']['total']} 位)"
def _update_archetype_example(type_key: str, creator_name: str):
"""把博主名写入对应类型的 examples 列表"""
db = _load(ARCHETYPES_FILE)
target = db["archetypes"].get(type_key) or db["custom_archetypes"].get(type_key)
if target and creator_name not in target["examples"]:
target["examples"].append(creator_name)
target["confirmed_by"] = len(target["examples"])
db["_meta"]["total_analyzed"] = sum(
len(v["examples"]) for v in {**db["archetypes"], **db.get("custom_archetypes", {})}.values()
)
_save(ARCHETYPES_FILE, db)
def list_bloggers(archetype_filter: str = None) -> str:
"""列出已分析博主,可按类型过滤"""
db = _load(BLOGGERS_FILE)
bloggers = db["bloggers"]
if archetype_filter:
bloggers = [b for b in bloggers if archetype_filter in b["archetype"]]
lines = [f"📋 已分析博主(共 {len(bloggers)} 位)\n"]
for b in bloggers:
lines.append(
f" · **{b['creator_name']}** [{b['archetype_name']}] "
f"均赞{b['stats'].get('avg_likes',0):,} 最高{b['stats'].get('max_likes',0):,} "
f"| {b['analyzed_at']}"
)
return "\n".join(lines)
def get_blogger(creator_name: str) -> Optional[dict]:
"""按名称查找已分析博主记录"""
db = _load(BLOGGERS_FILE)
return next((b for b in db["bloggers"] if b["creator_name"] == creator_name), None)
FILE:browser.py
"""
浏览器引擎:SeleniumBase UC Mode + XHR 拦截
核心思路:注入 JS 拦截 fetch/XHR,捕获小红书真实 API 响应 JSON
不需要逆向签名算法,直接从浏览器内存中读取已签名的响应
"""
import json
import time
import os
from seleniumbase import SB
# 注入到页面的 JS 拦截器
_INTERCEPT_JS = """
(function() {
if (window.__xhs_interceptor_installed) return;
window.__xhs_interceptor_installed = true;
window.__xhs_api_cache = {};
// 拦截 fetch
const _fetch = window.fetch;
window.fetch = async function(...args) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
const res = await _fetch.apply(this, args);
if (url.includes('/api/sns/') || url.includes('edith.xiaohongshu')) {
try {
const clone = res.clone();
clone.json().then(data => {
window.__xhs_api_cache[url] = data;
}).catch(() => {});
} catch(e) {}
}
return res;
};
// 拦截 XHR
const _open = XMLHttpRequest.prototype.open;
const _send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {
this.__url = url;
return _open.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function() {
this.addEventListener('load', function() {
if (this.__url && (this.__url.includes('/api/sns/') || this.__url.includes('edith.xiaohongshu'))) {
try {
window.__xhs_api_cache[this.__url] = JSON.parse(this.responseText);
} catch(e) {}
}
});
return _send.apply(this, arguments);
};
})();
"""
class BrowserSession:
def __init__(self, cookies_file: str, headless: bool = False):
self.cookies_file = cookies_file
self.headless = headless
self._sb = None
self._ctx = None
def __enter__(self):
self._ctx = SB(uc=True, headless2=self.headless)
self._sb = self._ctx.__enter__()
self._init()
return self
def __exit__(self, *args):
if self._ctx:
self._ctx.__exit__(*args)
def _init(self):
self._sb.uc_open_with_reconnect("https://www.xiaohongshu.com", 4)
time.sleep(2)
self._inject_cookies()
# Register CDP script BEFORE refresh so it runs on reload
self._register_cdp_interceptor()
self._sb.refresh()
time.sleep(2)
self._sb.execute_script(_INTERCEPT_JS)
def _inject_cookies(self):
if not os.path.exists(self.cookies_file):
raise FileNotFoundError(f"Cookie 文件不存在: {self.cookies_file}\n请先运行 xhs_login.py")
with open(self.cookies_file, encoding="utf-8") as f:
cookies = json.load(f)
for c in cookies:
c.pop("sameSite", None)
c.pop("httpOnly", None)
try:
self._sb.driver.add_cookie(c)
except Exception:
pass
def _register_cdp_interceptor(self):
"""注册 CDP onNewDocument 脚本,每次页面加载前执行(捕获 page-load API)"""
try:
self._sb.driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": _INTERCEPT_JS
})
except Exception:
pass
def _install_interceptor(self):
self._sb.execute_script(_INTERCEPT_JS)
def navigate(self, url: str, wait: float = 3.0):
"""导航到页面:用 driver.get 保留 CDP 会话(使拦截器在 page-load 前运行)"""
# Re-register in case CDP session was reset
self._register_cdp_interceptor()
# Use driver.get to keep CDP alive (interceptor runs before page JS)
self._sb.driver.get(url)
time.sleep(wait)
# Also inject immediately for any late-arriving race conditions
self._install_interceptor()
def scroll_and_collect(self, times: int = 3, gap: float = 2.0):
"""滚动页面触发懒加载 API"""
for _ in range(times):
self._sb.execute_script("window.scrollBy(0, 1500)")
time.sleep(gap)
def get_api_cache(self) -> dict:
"""读取所有已拦截的 API 响应"""
try:
return self._sb.execute_script("return window.__xhs_api_cache || {}") or {}
except Exception:
return {}
def clear_cache(self):
self._sb.execute_script("window.__xhs_api_cache = {}")
def get_page_source(self) -> str:
return self._sb.get_page_source()
def find_elements(self, selector: str):
return self._sb.find_elements(selector)
def execute_script(self, script: str):
return self._sb.execute_script(script)
@property
def driver(self):
return self._sb.driver
FILE:client.py
"""
XhsClient — 小红书数据抓取 + 分析客户端
基于 SeleniumBase UC Mode + XHR 拦截,无需逆向签名算法
整合 xhsfenxi 三型博主分类体系 + 五层账号模型
用法:
from xhscosmoskill import XhsClient
with XhsClient() as xhs:
notes = xhs.search("咖啡", limit=30)
user_notes = xhs.get_user_notes("5b07eb49e8ac2b5fe1e7de09")
report = xhs.analyze_account(user_notes, creator_name="博主名")
detail = xhs.get_note_detail("https://www.xiaohongshu.com/explore/xxx")
"""
import json
import time
import os
from typing import List, Optional
from .browser import BrowserSession
from .models import Note, UserProfile
from .parser import (
parse_search_response,
parse_user_notes_response,
parse_note_detail_response,
parse_comment_page_response,
parse_dom_notes,
)
from .analyzer import analyze_account as _analyze_account
DEFAULT_COOKIES = os.path.join(os.path.dirname(__file__), "..", "xhs_cookies.json")
class XhsClient:
"""
小红书数据抓取客户端
参数:
cookies_file: cookie 文件路径(运行 xhs_login.py 生成)
headless: 是否无头模式(默认 False,首次建议 False 观察行为)
scroll_times: 每次操作滚动次数(影响加载数量)
支持 context manager(with 语句)或手动 open()/close()
"""
def __init__(
self,
cookies_file: str = DEFAULT_COOKIES,
headless: bool = True,
scroll_times: int = 5,
):
self.cookies_file = os.path.abspath(cookies_file)
self.headless = headless
self.scroll_times = scroll_times
self._session: Optional[BrowserSession] = None
def open(self):
self._session = BrowserSession(self.cookies_file, self.headless)
self._session.__enter__()
return self
def close(self):
if self._session:
self._session.__exit__(None, None, None)
self._session = None
def __enter__(self):
return self.open()
def __exit__(self, *args):
self.close()
# ─── 公开接口 ──────────────────────────────────────────────
def search(self, keyword: str, limit: int = 20, scroll_times: int = None) -> List[Note]:
"""
搜索笔记
参数:
keyword: 搜索关键词
limit: 最大返回数量
scroll_times: 滚动次数(越多加载越多,默认用初始化值)
返回: List[Note]
"""
self._ensure_session()
url = f"https://www.xiaohongshu.com/search_result?keyword={keyword}&source=web_explore_feed"
self._session.clear_cache()
self._session.navigate(url, wait=3)
self._session.scroll_and_collect(scroll_times or self.scroll_times)
cache = self._session.get_api_cache()
notes = parse_search_response(cache)
# API 拦截失败时 fallback 到 DOM
if not notes:
notes = parse_dom_notes(self._session)
return notes[:limit]
def get_user_notes(self, user_id: str, limit: int = 50, scroll_times: int = None) -> List[Note]:
"""
获取用户主页所有笔记
参数:
user_id: 小红书用户 ID(URL 中的那串 hex)
limit: 最大返回数量
scroll_times: 滚动次数
返回: List[Note]
"""
self._ensure_session()
url = f"https://www.xiaohongshu.com/user/profile/{user_id}"
self._session.clear_cache()
self._session.navigate(url, wait=4)
seen_ids = set()
notes = []
n_scroll = scroll_times or self.scroll_times
for _ in range(n_scroll):
self._session.execute_script("window.scrollBy(0, 1500)")
time.sleep(2)
cache = self._session.get_api_cache()
batch = parse_user_notes_response(cache)
# DOM fallback if API empty
if not batch:
batch = parse_dom_notes(self._session)
for n in batch:
key = n.id or n.url or n.title
if key and key not in seen_ids:
seen_ids.add(key)
notes.append(n)
if len(notes) >= limit:
break
return notes[:limit]
def get_note_detail(self, note_url: str) -> Optional[Note]:
"""
获取单篇笔记详情(正文、评论、互动数据)
参数:
note_url: 笔记完整 URL
返回: Note 或 None
"""
self._ensure_session()
self._session.clear_cache()
self._session.navigate(note_url, wait=4)
cache = self._session.get_api_cache()
note = parse_note_detail_response(cache)
# API 失败时从 DOM 提取正文和标题
if not note:
note = Note()
note.url = note_url
for sel in ["#detail-desc", "span#detail-desc", ".desc.expand", "[class*='desc']"]:
try:
el = self._session.find_elements(sel)
if el and el[0].text.strip():
note.content = el[0].text.strip()
break
except Exception:
pass
for sel in ["#detail-title", ".note-content .title", "h1", "[class*='title']"]:
try:
el = self._session.find_elements(sel)
if el and el[0].text.strip():
note.title = el[0].text.strip()
break
except Exception:
pass
# 无论 API 是否成功,都尝试从 comment/page 补充评论
if not note.comments:
note.comments = parse_comment_page_response(cache)
return note if (note and (note.content or note.title)) else None
def post_comment(self, note_url: str, text: str) -> bool:
"""
在笔记下发表评论
参数:
note_url: 笔记完整 URL
text: 评论内容
返回: True 成功 / False 失败
"""
self._ensure_session()
self._session.navigate(note_url, wait=4)
try:
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
driver = self._session.driver
# 1. JS 点击评论区占位符,激活输入框
activate_selectors = [
".comment-input-inner-container",
"[class*='comment-input']",
".input-inner-container",
"[class*='commentInput']",
"[class*='input-box']",
]
activated = False
for sel in activate_selectors:
els = self._session.find_elements(sel)
if els:
driver.execute_script("arguments[0].scrollIntoView({block:'center'})", els[0])
time.sleep(0.5)
driver.execute_script("arguments[0].click()", els[0])
activated = True
time.sleep(1)
break
if not activated:
print(" ✗ 找不到评论输入框")
return False
# 2. 找激活后的 contenteditable 或 textarea
editable_selectors = [
"[contenteditable='true']",
"textarea",
"[class*='ql-editor']",
"[class*='editor']",
]
editable = None
for sel in editable_selectors:
els = self._session.find_elements(sel)
if els:
editable = els[0]
break
if editable:
ActionChains(driver).move_to_element(editable).click().send_keys(text).perform()
else:
# fallback: 直接用 active element
ActionChains(driver).send_keys(text).perform()
time.sleep(1)
# 3. 点击发送按钮
submit_selectors = [
"[class*='submit']",
"[class*='send']",
"button.submit",
"[class*='confirm']",
]
submitted = False
for sel in submit_selectors:
els = self._session.find_elements(sel)
# 过滤不可见元素
for el in els:
try:
if el.is_displayed() and el.is_enabled():
driver.execute_script("arguments[0].click()", el)
submitted = True
break
except Exception:
continue
if submitted:
break
if not submitted:
# 最后 fallback: Enter
ActionChains(driver).send_keys(Keys.ENTER).perform()
time.sleep(2)
print(f" ✓ 评论已发送: {text[:30]}")
return True
except Exception as e:
print(f" ✗ 评论失败: {e}")
return False
def get_note_comments(self, note_url: str, limit: int = 20) -> List[dict]:
"""
单独获取笔记评论
参数:
note_url: 笔记 URL
limit: 最大评论数
返回: List[dict] 含 user/text/likes 字段
"""
note = self.get_note_detail(note_url)
if note:
return [c.__dict__ for c in note.comments[:limit]]
return []
def batch_get_details(self, notes: List[Note], delay: float = 1.5) -> List[Note]:
"""
批量获取笔记详情(在已有列表基础上补充正文和评论)
参数:
notes: search() 或 get_user_notes() 返回的列表
delay: 每篇请求间隔秒数(建议 1.5~3)
返回: 补充了 content/comments 的 Notes 列表
"""
enriched = []
for i, note in enumerate(notes):
if not note.url:
enriched.append(note)
continue
print(f" [{i+1}/{len(notes)}] {note.title[:30] or note.url[:40]}")
detail = self.get_note_detail(note.url)
if detail:
detail.title = detail.title or note.title
detail.likes = detail.likes or note.likes
detail.type = detail.type or note.type
enriched.append(detail)
else:
enriched.append(note)
time.sleep(delay)
return enriched
# ─── 工具方法 ───────────────────────────────────────────────
def save(self, notes: List[Note], filepath: str):
"""保存结果到 JSON 文件"""
data = [n.to_dict() for n in notes]
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"已保存 {len(data)} 条到 {filepath}")
def analyze_account(
self,
notes: List[Note],
creator_name: str = "未知博主",
mode: str = "full",
save_to: str = None,
) -> str:
"""
对已采集的笔记列表做完整分析,返回 Markdown 报告。
参数:
notes — get_user_notes() 的返回值
creator_name — 博主昵称(用于报告标题)
mode — 'full' 完整报告 | 'formula' 选题公式 | 'snapshot' 快速摘要
save_to — 可选,保存报告到文件路径(.md)
返回: Markdown 字符串
"""
report = _analyze_account(notes, creator_name=creator_name, mode=mode)
if save_to:
with open(save_to, "w", encoding="utf-8") as f:
f.write(report)
print(f"报告已保存到 {save_to}")
return report
def _ensure_session(self):
if not self._session:
raise RuntimeError("请先调用 open() 或使用 with 语句")
FILE:data/archetypes.json
{
"_meta": {
"version": "1.0",
"description": "可迭代博主类型注册表 — 随分析案例自动演化",
"last_updated": "2026-04-23",
"total_analyzed": 0
},
"archetypes": {
"A": {
"name": "荒诞美学型",
"desc": "用荒诞幽默包裹哲学内核,高质感视觉,品牌符号统一",
"formula": "荒诞场景 × 品牌符号 × 哲思轻量化 → 审美共鸣",
"title_signals": ["(劲爆)", "劲爆", "*", "·"],
"content_signals": ["哲", "美学", "荒诞", "飞", "高山", "宇宙", "奖励", "永恒"],
"commercial": "高端生活方式、旅行、摄影、轻奢品牌",
"difficulty": "高",
"examples": [],
"confirmed_by": 0
},
"B": {
"name": "共鸣命名型",
"desc": "把私人经历转化为普世命题,给模糊情绪命名",
"formula": "私人经历 → 命题化 → 给模糊状态命名 → 普世共鸣",
"title_signals": ["*", "为什么", "是什么", "原来", "才知道", "命题", "灯塔", "解药"],
"content_signals": ["成长", "情绪", "留学", "记录", "年末", "不确定", "命题", "感受", "理解", "接纳"],
"commercial": "成长/教育/创意平台、中端生活方式",
"difficulty": "中",
"examples": ["xixiCharon"],
"confirmed_by": 1
},
"C": {
"name": "现实策略型",
"desc": "打破职场/人际/金钱潜规则,提供可执行的向上策略",
"formula": "困境 → 说破规则 → 提供策略 → 爽感执行",
"title_signals": ["骗子", "不要脸", "装", "穷", "规则", "策略", "大法"],
"content_signals": ["策略", "规则", "执行", "调整", "快速", "现实", "向上", "方法"],
"commercial": "大众消费、职场、电商、实用工具",
"difficulty": "中",
"examples": [],
"confirmed_by": 0
}
},
"custom_archetypes": {}
}
FILE:data/bloggers.json
{
"_meta": {
"description": "已分析博主数据库 — 每次 /xhsfx 分析后自动写入",
"total": 1
},
"bloggers": [
{
"creator_name": "xixiCharon",
"user_id": "5f94fa23000000000100bced",
"analyzed_at": "2026-04-23",
"archetype": "B+A",
"archetype_name": "共鸣命名型 × 荒诞美学型",
"stats": {
"avg_likes": 3148,
"max_likes": 60000,
"sample_size": 50,
"viral_count": 3,
"video_ratio": "46/50"
},
"best_topic": "自然/户外",
"best_topic_avg": 9069,
"tags": [
"留学",
"巴黎",
"自然",
"情绪成长",
"INFJ"
],
"commercial": "成长/教育/创意平台、中端生活方式",
"formula": "私人经历(留学/山野) → 命题化 → 给模糊情绪命名 → 共鸣"
}
]
}
FILE:examples/note_detail.py
"""
示例:抓取单篇笔记详情(正文 + 评论)
"""
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
from xhscosmoskill import XhsClient
NOTE_URL = "https://www.xiaohongshu.com/explore/6912a3c8000000000f03e0a5"
with XhsClient() as xhs:
note = xhs.get_note_detail(NOTE_URL)
if note:
print(f"标题: {note.title}")
print(f"作者: {note.author}")
print(f"赞/藏/评: {note.likes}/{note.collects}/{note.comments_count}")
print(f"正文:\n{note.content}")
print(f"\n评论 ({len(note.comments)}条):")
for c in note.comments[:5]:
print(f" @{c.user}: {c.text}")
else:
print("未能抓取到内容")
FILE:examples/search_notes.py
"""
示例:搜索关键词,抓取笔记列表 + 详情
"""
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
from xhscosmoskill import XhsClient
KEYWORD = "咖啡"
LIMIT = 20
with XhsClient() as xhs:
print(f"搜索: {KEYWORD}")
notes = xhs.search(KEYWORD, limit=LIMIT)
print(f"找到 {len(notes)} 篇笔记")
for i, n in enumerate(notes[:5], 1):
print(f"[{i}] {n.title} | 赞:{n.likes} | {n.type}")
# 补充详情
print("\n抓取详情...")
notes = xhs.batch_get_details(notes, delay=1.5)
xhs.save(notes, "search_results.json")
FILE:examples/user_profile.py
"""
示例:抓取指定用户所有笔记(含详情)
"""
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
from xhscosmoskill import XhsClient
# 井越的用户 ID
USER_ID = "5b07eb49e8ac2b5fe1e7de09"
with XhsClient(scroll_times=8) as xhs:
print(f"抓取用户主页: {USER_ID}")
notes = xhs.get_user_notes(USER_ID, limit=100)
print(f"列表: {len(notes)} 篇")
print("\n抓取每篇详情...")
notes = xhs.batch_get_details(notes, delay=2.0)
xhs.save(notes, f"user_{USER_ID}.json")
print(f"\n完成,正文有内容: {len([n for n in notes if n.content])} 篇")
FILE:formula.py
"""
formula.py — 爆款选题公式生成器
基于 hsword 实战案例库提炼的6大模型
支持 B型(共鸣命名)/ A型(荒诞美学)/ C型(现实策略)/ 混合型
用法:
from xhscosmoskill.formula import generate_formula_report
md = generate_formula_report(notes, creator_name="xixiCharon", archetype=archetype)
"""
from __future__ import annotations
from typing import List, Any, Dict
import re
# ── B型(共鸣命名型)6大模型 ──────────────────────────────────
B_MODELS = [
{
"id": "B1",
"name": "场景 × 哲思型",
"tag": "均赞最高,优先做",
"formula": "[具体地点/场景] + [在这里感受到的哲学状态] + [品牌符号]",
"mechanism": "地点宏大 × 感受日常 × 语言诗意 = 三重张力,用户既被画面吸引,又被感受击中",
"templates": [
"在[地点]的那几天,[发现了什么]*",
"[地点]让我第一次理解,什么叫[哲学状态]*",
"人在[场景]里是[动态状态]的,[哲学命题]*",
"去完[地点]之后,我开始相信[新的观念]*",
"[地点]很[表面感受],但其实很[真实感受]*",
],
"themes": ["山野/户外/自然", "独旅领悟", "城市角落的哲思", "跨文化感受"],
},
{
"id": "B2",
"name": "状态命名型",
"tag": "高收藏率,深度粉丝",
"formula": "[擅长X的人],对[Y]的感知是[Z]的 + [品牌符号]",
"mechanism": "把混沌状态翻译成可被理解的概念,用户获得「被命名」的满足感",
"templates": [
"原来[做X的人],对[Y]的感知都是[Z的]*",
"我终于知道为什么[某类人]总会[某种感受]*",
"擅长[X]的人,是怎么感受[时间/当下/变化]的*",
"有一类人,[描述],她们把[X]当成[Y]*",
"为什么你总是[状态]?因为你其实是[新定义的人]*",
],
"themes": ["记录/创作/表达", "INFJ/i人/高敏感特质", "时间感/当下感", "女性自我认知"],
},
{
"id": "B3",
"name": "阶段宣言型",
"tag": "节点内容,年终必出",
"formula": "[时间节点/年龄阶段] + [这个阶段的成长判断] + [品牌符号]",
"mechanism": "时间节点天然触发情绪浓度,个人叙事 × 普世经历 = 同龄共鸣",
"templates": [
"[年份]年终[身份]汇报:[这一年的判断]*",
"第[N]个[季节/夏末/生日],[感受]*",
"[岁数]岁的[不确定/清醒/成长],是[如何]的存在*",
"在[X]结束的前一天,我终于理解了[Y]*",
"[时间段]里,我做了[事],发现[领悟]*",
],
"themes": ["年末/年初/生日", "留学生活阶段总结", "特定年龄节点", "gap年/毕业/转折"],
},
{
"id": "B4",
"name": "独行宣言型",
"tag": "独立女性共鸣",
"formula": "[我独自/一个人] + [行动] + [反预期结果]",
"mechanism": "独立女性认同 × 行动力展示 × 反预期结果 = 三重共鸣",
"templates": [
"我独自[行动],[结果超出预期的感受]*",
"一个人[场景],其实[反预期的发现]*",
"每一个[做X的女孩/人],都是我*",
"[场景下的]一个人,是[新的理解]*",
"有些事,只有一个人做了,才能[领悟]*",
],
"themes": ["solo旅行/独处", "一个人的山野/城市", "独居生活", "户外/徒步"],
},
{
"id": "B5",
"name": "情绪逆转型",
"tag": "治愈系高收藏",
"formula": "[消极状态] + 其实是 + [正向领悟] + [品牌符号]",
"mechanism": "情绪低谷共鸣 + 逆转出口 = 「被治愈的可能性」,用户收藏的是出口本身",
"templates": [
"[消极状态],其实是[正向新解]*",
"[看似坏事]的那段时间,反而让我[领悟]*",
"[不确定/失去/放弃],是像[比喻]一样的存在*",
"当[困境]来的时候,我发现[反预期的事]*",
"长出[新特质],是从[某个经历]开始的*",
],
"themes": ["情绪低谷/调整", "不确定阶段", "成长的代价", "留学/独居压力"],
},
{
"id": "B6",
"name": "世界观输出型",
"tag": "最强粘性,深度内容",
"formula": "[持续做X的人/坚持Y的意义] + [是如何理解Z的] + [品牌符号]",
"mechanism": "用户收藏的是「世界观」,会持续追更,是最强粘性内容",
"templates": [
"[持续做X]的人,是在[更大的事情]上做投资*",
"出发/记录/独处的意义,不是[表面],而是[深层]*",
"[这件事],其实是[更大命题]的另一种表达*",
"生活的本质,是[新定义]*",
"我们[做X],是为了[更深的东西]*",
],
"themes": ["记录的意义", "出发/旅行的意义", "独立/自我的定义", "女性成长哲学"],
},
]
# ── A型(荒诞美学型)公式 ─────────────────────────────────────
A_MODELS = [
{
"id": "A1",
"name": "地点 + 反差动作/感受",
"formula": "[地点的宏大] × [动作的日常] = 反差萌",
"templates": ["在[高原/远方]做[日常小事]!(品牌符号)", "去[地方]的[超日常行为]!"],
},
{
"id": "A2",
"name": "场景 + 情感类比",
"formula": "[地点] × [文学化情感命名] = 高级感",
"templates": ["[地点]有[文学类比]般的[时间/氛围](品牌符号)", "这跟走进[情感类比]有什么区别!"],
},
{
"id": "A3",
"name": "荒诞日常",
"formula": "[荒唐问题/行为] × [认真叙述] = 喜剧感",
"templates": ["大家都是怎么忍住不[荒诞行为]的(品牌符号)", "我在[地方]必须[荒诞行为]"],
},
{
"id": "A4",
"name": "轻哲思命题",
"formula": "[严肃命题] × [轻盈表达] = 有深度但不沉重",
"templates": ["关于[严肃话题]的奇怪故事(品牌符号)", "什么是[抽象命题](品牌符号)"],
},
]
# ── C型(现实策略型)公式 ─────────────────────────────────────
C_MODELS = [
{
"id": "C1",
"name": "困境说破型",
"formula": "困境 → 说破 → 规则 → 策略 → 爽感",
"templates": ["为什么[Y]都喜欢[X]?", "普通女孩怎么在[X]里拿回主动权?"],
},
{
"id": "C2",
"name": "信息差揭秘型",
"formula": "[表面现象] + [背后规则] + [可执行策略]",
"templates": ["对普通人来说,[X]比努力更重要", "没有[资源]的人,怎么[目标]?"],
},
]
def _parse_likes(v) -> int:
if not v:
return 0
s = str(v).replace(",", "").strip()
if "万" in s:
return int(float(s.replace("万", "")) * 10000)
try:
return int(s)
except ValueError:
return 0
def _get_top_notes(notes: List[Any], n: int = 5):
def get_title(n): return n.title if hasattr(n, "title") else n.get("title", "")
def get_likes(n): return _parse_likes(n.likes if hasattr(n, "likes") else n.get("likes"))
return sorted(notes, key=lambda n: -get_likes(n))[:n]
def _build_30_topics(creator_name: str, archetype_type: str) -> str:
"""生成30个可用选题方向"""
typ = archetype_type[0] if archetype_type else "B"
if typ == "B":
return f"""### A 组:自然/户外类(均赞最高,优先做)
1. 在[山/海/草原]的那几天,[感受到了什么]*
2. [某种自然景象],让我第一次理解什么叫[情绪状态]*
3. 去[目的地]之前我以为[X],回来之后我只记得[Y]*
4. 一个人徒步,[具体发现]*
5. 冬天的[地点],有[文学化情感命名]的质地*
6. 到[自然场景]里走走,才发现[领悟]*
### B 组:留学/海外类(基础盘,稳定输出)
7. 在巴黎的第[N]年,我终于理解了[某事]*
8. 留学以后,[某种改变]*
9. 一个在海外的中国女生,怎么理解[某个普世命题]*
10. 巴黎教会我的,不是[表面],而是[真正的东西]*
11. [年份]年的留学,[年度总结词]*
12. 在异乡过[节日/特殊时刻],[感受]*
### C 组:记录/创作类(高收藏,深度粉丝)
13. 为什么要频繁记录?[自己的答案]*
14. 擅长[记录/写作/拍照]的人,[是什么样的]*
15. [某件事]让我第一次真的开始记录*
16. 记录的意义,不是留住过去,而是[新定义]*
17. vlog 教会我的最重要一件事:[命题]*
18. 如果你也是创作者,关于[某个困惑]的答案*
### D 组:成长/情绪类(共鸣最广,拓展受众)
19. 二十[几]岁的[某种状态],是像[比喻]一样的存在*
20. 那段[困难]时期,让我长出了[新特质]*
21. [某类人]是如何对待自己的消极情绪的*
22. 当你开始接受[某件事],会发现[领悟]*
23. 我不再[某种行为]以后,[变化]*
24. 给所有正在[某个阶段]的人:[一句话]*
### E 组:哲学/世界观类(最高传播潜力)
25. 生活的本质,是[你的定义]*
26. 持续[做某事],是在[更大意义上]做什么*
27. 为什么[出发/记录/独处]?因为[真正的答案]*
28. [某种看起来无用的事],其实是[意外的价值]*
29. 我们出发,不是为了[表面],而是为了[深层]*
30. 一个人能给自己最好的礼物,是[你的答案]*"""
elif typ == "A":
return """(A型:荒诞美学型30个选题方向)
基础模式:地点反差 × 荒诞日常 × 轻哲思 × 品牌符号统一
1-10:地点 + 反差动作系列
11-20:场景 + 情感类比系列
21-30:荒诞命题 + 认真叙述系列
具体选题请根据博主真实经历填入[地点]和[动作]。"""
else: # C型
return """(C型:现实策略型30个选题方向)
基础模式:困境说破 × 信息差揭秘 × 可执行策略
1-10:职场潜规则系列
11-20:消费/财务博弈系列
21-30:普通人向上移动系列"""
def generate_formula_report(
notes: List[Any],
creator_name: str,
archetype: Dict,
) -> str:
"""
生成爆款选题公式 Markdown 报告
参数:
notes — get_user_notes() 返回值
creator_name — 博主昵称
archetype — classify_archetype() 返回值
返回:Markdown 字符串
"""
typ = archetype.get("type", "B")
name = archetype.get("name", "共鸣命名型")
desc = archetype.get("desc", "")
top5 = _get_top_notes(notes, 5)
def get_title(n): return n.title if hasattr(n, "title") else n.get("title", "")
def get_likes(n): return _parse_likes(n.likes if hasattr(n, "likes") else n.get("likes"))
top5_md = "\n".join(
f"- 👍{get_likes(n):,} {get_title(n)[:50]}"
for n in top5
)
# 选择对应的模型
models = B_MODELS if typ[0] == "B" else (A_MODELS if typ[0] == "A" else C_MODELS)
models_md = ""
for m in models:
templates = "\n".join(f" - {t}" for t in m.get("templates", []))
themes_str = "、".join(m.get("themes", []))
models_md += f"""
### 模型 {m['id']}:{m['name']} {m.get('tag','')}
**公式:** {m['formula']}
**为什么有效:** {m.get('mechanism', '')}
**可套用句式:**
{templates}
**适合主题:** {themes_str}
---
"""
topics_30 = _build_30_topics(creator_name, typ)
return f"""# {creator_name} — 爆款选题公式
> 说明:本文件不是复刻 {creator_name} 的表面语气,而是提炼她真正有效的**选题逻辑、标题机制、传播结构和可迁移方法**。
---
## 一、先说结论:为什么能做出爆款?
**账号类型:** {name}({typ})
**核心内核:** {desc}
爆款公式:
> **私人场景 × 命题化 × 诗意命名 × 品牌符号 × 普世共鸣**
**高表现代表作:**
{top5_md}
---
## 二、总公式
```
一个真实经历 / 一个具体场景 / 一个模糊的情绪状态
↓
提炼成普遍的人生命题
↓
用诗意语言给这个状态一个更精准的说法
↓
品牌符号收尾(如 *)
↓
用户:就是这个感觉!
```
---
## 三、最常见的{len(models)}类爆款选题模型
{models_md}
---
## 四、隐形公式(最值钱的底层打法)
**隐形公式1:先给感受,再给命题**
不要用命题开头,先描述具体画面,再让命题从画面里生长出来。
- 错误:「论记录的意义」
- 正确:「那天我在山里,突然理解了为什么要记录」*
**隐形公式2:动词比名词强**
「流动」比「自由」有力;「后置」比「延迟」有力。
动词给人画面,名词只给人概念。
**隐形公式3:品牌符号的系统性**
品牌符号(如 `*`)不是随便加的,它是人格锚点。长期累积会形成"一看就知道是谁"的识别度。
**隐形公式4:自然场景是放大器**
自然场景天然携带"宏大感",能让任何情绪命题被放大10倍。
---
## 五、如何迁移到自己的账号
**最优学习路径:**
```
你的真实场景(不必模仿博主的具体场景)
↓
找到那个你"说不清楚"的感受
↓
用一句话把它命名(用动词,不用形容词)
↓
写成:[场景] + [命题] + [你的标识符]
↓
发布,看哪句话在评论区被用户引用
```
**标题模板直接套用:**
1. 在[地点]的时候,我第一次理解了什么叫________[你的标识符]
2. [做X]的人,对[Y]的感知是________的[标识符]
3. 我独自[行动],[反预期的发现][标识符]
4. 原来[做某事],是一种[新的定义][标识符]
5. 第[N]个[时间节点],[这个阶段的感受][标识符]
6. 终于知道了,为什么[某类人]总会[某种感受][标识符]
---
## 六、可直接复用的30个选题方向
{topics_30}
---
## 七、哪些能学,哪些不能硬抄
**能学的:**
- 命题化思维:私人经历 → 公共命题
- 动词命名法:用动词描述情绪
- 场景 × 哲思的意象对冲结构
- 品牌符号系统(找到你自己的"印章")
**不能硬抄的:**
- 语气温度(来自真实人设,非技巧)
- 具体生活场景(需要真实经历支撑)
- 品牌符号本身(需先建立人格认同)
---
## 八、最终总结
**一句话:** {creator_name} 的爆款公式,核心只有一句话:
> **把说不清楚的感受,用诗意的语言说清楚。**
---
*基于 xhscosmoskill + hsword框架提炼 · {creator_name} · 2026-04-23*
"""
FILE:models.py
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class Comment:
user: str = ""
text: str = ""
likes: str = ""
time: str = ""
@dataclass
class Note:
id: str = ""
title: str = ""
content: str = ""
url: str = ""
author: str = ""
author_id: str = ""
likes: str = ""
collects: str = ""
comments_count: str = ""
type: str = "" # "video" | "normal"
images: List[str] = field(default_factory=list)
tags: List[str] = field(default_factory=list)
comments: List[Comment] = field(default_factory=list)
raw: dict = field(default_factory=dict)
def to_dict(self):
return {
"id": self.id,
"title": self.title,
"content": self.content,
"url": self.url,
"author": self.author,
"author_id": self.author_id,
"likes": self.likes,
"collects": self.collects,
"comments_count": self.comments_count,
"type": self.type,
"images": self.images,
"tags": self.tags,
"comments": [c.__dict__ for c in self.comments],
}
@dataclass
class UserProfile:
user_id: str = ""
nickname: str = ""
description: str = ""
followers: str = ""
following: str = ""
likes_collected: str = ""
notes_count: str = ""
avatar: str = ""
FILE:package.json
{
"name": "xhsfenxi-pro",
"version": "2.0.0",
"description": "小红书博主全链路深度分析:数据采集 + 三型分类 + 内核三段论 + 爆款选题公式",
"scripts": {},
"keywords": [
"xiaohongshu",
"xhsfenxi",
"博主分析",
"爆款选题"
],
"author": "Cosmofang",
"license": "MIT"
}
FILE:parser.py
"""
解析器:从拦截到的 API JSON 或 DOM 中提取结构化数据
兼容两种来源:API JSON(优先)和 DOM fallback
"""
from __future__ import annotations
from typing import List, Optional
from .models import Note, Comment, UserProfile
def parse_note_from_api(raw: dict) -> Note:
"""从 API JSON 解析笔记"""
note_card = raw.get("note_card") or raw
basic = note_card.get("basic_info") or {}
interact = note_card.get("interact_info") or {}
user = note_card.get("user") or {}
note = Note()
note.id = (
raw.get("id")
or note_card.get("note_id")
or basic.get("note_id", "")
)
note.title = (
note_card.get("display_title")
or note_card.get("title")
or basic.get("title", "")
)
note.content = note_card.get("desc") or basic.get("desc", "")
note.author = user.get("nickname", "")
note.author_id = user.get("user_id", "")
note.likes = str(interact.get("liked_count", ""))
note.collects = str(interact.get("collected_count", ""))
note.comments_count = str(interact.get("comment_count", ""))
note.type = "video" if note_card.get("type") == "video" else "normal"
# 图片
image_list = note_card.get("image_list") or []
note.images = [img.get("url", "") for img in image_list if img.get("url")]
# 标签
tag_list = note_card.get("tag_list") or []
note.tags = [t.get("name", "") for t in tag_list if t.get("name")]
note.raw = raw
return note
def parse_search_response(api_cache: dict) -> List[Note]:
"""从拦截的搜索 API 响应中提取笔记列表"""
notes = []
for url, data in api_cache.items():
if "search" not in url:
continue
items = (
data.get("data", {}).get("items")
or data.get("items")
or []
)
for item in items:
note_raw = item.get("note_card") or item
if not note_raw:
continue
n = parse_note_from_api({"id": item.get("id", ""), "note_card": note_raw})
xsec = item.get("xsec_token", "")
note_id = item.get("id") or note_raw.get("note_id") or n.id
if note_id:
n.url = f"https://www.xiaohongshu.com/explore/{note_id}?xsec_token={xsec}&xsec_source=pc_search"
notes.append(n)
return notes
def parse_user_notes_response(api_cache: dict) -> List[Note]:
"""从拦截的用户主页 API 响应中提取笔记列表"""
notes = []
for url, data in api_cache.items():
if "user_posted" not in url and "user/notes" not in url:
continue
items = (
data.get("data", {}).get("notes")
or data.get("notes")
or []
)
for item in items:
n = parse_note_from_api(item)
note_id = n.id or item.get("note_id", "")
if note_id:
n.url = f"https://www.xiaohongshu.com/explore/{note_id}"
notes.append(n)
return notes
def parse_note_detail_response(api_cache: dict) -> Optional[Note]:
"""从拦截的笔记详情 API 响应中提取单篇笔记"""
for url, data in api_cache.items():
if "feed" not in url and "note/detail" not in url:
continue
items = data.get("data", {}).get("items") or []
for item in items:
note_card = item.get("note_card") or {}
if note_card:
n = parse_note_from_api({"note_card": note_card})
# 评论
comments_data = data.get("data", {}).get("comments") or []
for c in comments_data:
comment = Comment(
user=c.get("user_info", {}).get("nickname", ""),
text=c.get("content", ""),
likes=str(c.get("like_count", "")),
time=c.get("create_time", ""),
)
n.comments.append(comment)
return n
return None
def parse_comment_page_response(api_cache: dict) -> List[Comment]:
"""从 v2/comment/page API 解析评论列表"""
comments = []
for url, data in api_cache.items():
if "comment" not in url:
continue
items = (
data.get("data", {}).get("comments")
or data.get("comments")
or []
)
for c in items:
user_info = c.get("user_info") or {}
comment = Comment(
user=user_info.get("nickname", ""),
text=c.get("content", ""),
likes=str(c.get("like_count", "")),
time=str(c.get("create_time", "")),
)
comments.append(comment)
return comments
def parse_dom_notes(browser) -> List[Note]:
"""DOM fallback:从页面元素中提取笔记(API 拦截失败时使用)"""
notes = []
selectors = ["section.note-item", ".note-item", "[class*='note-item']"]
items = []
for sel in selectors:
items = browser.find_elements(sel)
if items:
break
for item in items:
note = Note()
for sel in ["a.title span", ".title span", "span.title"]:
try:
note.title = item.find_element("css selector", sel).text.strip()
break
except Exception:
pass
try:
note.url = item.find_element("css selector", "a.cover").get_attribute("href")
except Exception:
pass
try:
note.likes = item.find_element("css selector", "span.count").text.strip()
except Exception:
pass
try:
item.find_element("css selector", ".play-icon")
note.type = "video"
except Exception:
note.type = "normal"
if note.title or note.url:
notes.append(note)
return notes
FILE:references/hsword-frameworks.md
# hsword 框架参考手册
> 来源:openclaw_cosmo/afa/hsword/ 实战案例库
> 版本:2026-04-23
---
## 一、内核三段论(每次分析必做)
### 框架结构
```
外壳是什么?
→ 表面看起来像什么博主(用户最先看到的标签)
真正的内核是什么?
→ 一句话精炼,带""引号
→ 格式:这是一个通过"X、Y、Z"来建立影响力的[定语]博主
三层人设结构:
表层标签 → 地点/身份/平台标签(可模仿)
中层特质 → 性格/能力/气质(可包装)
深层价值观 → 鼓励什么/认可什么/传递什么(必须靠长期一致才能被相信)
```
### 已验证案例
| 博主 | 外壳 | 真正内核 | 深层价值观 |
|------|------|---------|-----------|
| 井越(A型)| 旅行vlogger / 文艺青年 | "荒诞滤镜下的生活哲学家" | 用奇怪但有质感的视角,记录在世界上存在过的痕迹 |
| 橘一橙(B型)| 旅行/成长博主 | "把私人经历转为普世命题的命名者" | 让年轻人获得"原来不止我这样"的理解 |
| 陈浪浪(C型)| 大厂博主/成长博主 | "普通女孩现实破局教练型博主" | 普通女性如何在不体面的现实里最大化利用手上的牌 |
| xixiCharon(B+A)| 留学vlogger | "把说不清楚的感受用诗意语言说清楚的命名者" | 鼓励女性关注自己内心,认可流动与不确定性 |
---
## 二、三型博主爆款逻辑对比
| 维度 | A型(荒诞美学)| B型(共鸣命名)| C型(现实策略)|
|------|------------|------------|------------|
| 爆款驱动 | 审美震撼 + 品牌符号 | 被命名的满足感 | 说破现实的爽感 |
| 标题风格 | 反差张力(宏大×日常)| 命题感+隐喻+判断 | 冲突词+反常识 |
| 用户获得感 | 愉悦+审美+新视角 | 被理解+被命名 | 被激活+被赋能 |
| 品牌符号 | "(劲爆)"统一后缀 | `*` 情感印章 | 自我命名先夺话语权 |
| 商业适配 | 高端/旅行/轻奢 | 成长/教育/中端生活 | 大众消费/职场/电商 |
| 复制难度 | 高(视觉审美积累)| 中(方法可学)| 中(框架可学)|
---
## 三、爆款选题公式体系(6大模型)
### B型(共鸣命名型)——xixiCharon 实证
#### 模型1:场景 × 哲思型
```
公式:[地点] + [哲学感悟] + [品牌符号]
机制:地点宏大 × 感受日常 × 语言诗意 = 三重张力
实例:人在山里是流动的,不存在于过去也不在未来*(6万赞)
```
**可套用句式:**
- 在[地点]的那几天,[发现了什么]*
- [地点]让我第一次理解,什么叫[哲学状态]*
- 人在[场景]里是[动态状态]的,[哲学命题]*
#### 模型2:状态命名型
```
公式:[擅长X的人],对[Y]的感知是[Z]的 + [品牌符号]
机制:把混沌状态翻译成可被理解的概念
实例:擅长记录的人对当下的感知是后置的*(10万+)
```
**可套用句式:**
- 原来[做X的人],对[Y]的感知都是[Z的]*
- 擅长[X]的人,是怎么感受[时间/当下/变化]的*
- 有一类人,[描述],她们把[X]当成[Y]*
#### 模型3:阶段宣言型
```
公式:[时间节点] + [成长判断] + [品牌符号]
机制:时间节点触发情绪 × 个人叙事 × 同龄共鸣
实例:2025年终留学汇报:今年似乎迈了一大步*(1.9万)
```
#### 模型4:独行宣言型
```
公式:[我独自/一个人] + [行动] + [反预期结果]
机制:独立女性认同 × 行动力 × 反预期
实例:我独自出发,先斩后奏,斩了也不奏*(8813赞)
```
#### 模型5:情绪逆转型
```
公式:[消极状态] + 其实是 + [正向领悟] + [品牌符号]
机制:情绪低谷共鸣 + 逆转出口
实例:二十岁的不确定是像灯塔一样的存在*(2999赞)
```
#### 模型6:世界观输出型
```
公式:[持续做X] + 是在[更大意义上]做什么 + [品牌符号]
机制:用户收藏"世界观",最强粘性内容
实例:记录和表达如此重要,于是有了写作和vlog*(3.2万)
```
### A型(荒诞美学型)——井越 实证
#### 公式1:地点 + 反差动作/感受
```
机制:地点的宏大 × 动作的日常 = 反差萌
实例:在帕米尔高原摘豌豆!(劲爆)(5151赞)
```
#### 公式2:场景 + 情感类比
```
机制:地点 × 文学化情感命名 = 高级感
实例:北海道有爵士乐般的冬日(劲爆)(912赞)
```
#### 公式3:荒诞日常
```
机制:荒唐问题/行为 × 认真叙述 = 喜剧感
实例:大家都是怎么忍住不亲马鼻孔的(劲爆)
```
### C型(现实策略型)——陈浪浪 实证
#### 核心公式
```
困境 → 说破 → 规则 → 策略 → 爽感
机制:先夺走羞耻感 → 说破现实 → 给出可执行策略
```
---
## 四、综合版"双系统选题公式"
```
系统A(共鸣命名型):
经历 → 命题 → 命名 → 判断 → 共鸣
系统B(现实策略型):
困境 → 说破 → 规则 → 策略 → 爽感
混合公式(最强):
真实困境 + 可命名的阶段/处境 + 说破现实的判断 + 可执行动作建议
= 既被理解,又能行动
```
---
## 五、标题公式快查表(hsword综合版)
| 公式 | 模板 | 适用类型 |
|------|------|---------|
| 为什么总像 | 为什么[X]总像[Y]? | B型共鸣 |
| 帮大家试过了 | 帮大家试过了,[X]到底值不值得 | B型验证 |
| 为什么都喜欢 | 为什么[Y]都喜欢[X]? | C型说破规则 |
| 普通人/普女怎么 | 普通女孩怎么在[X]里拿回主动权? | C型策略 |
| 当一个人…会发生什么 | 当一个人不再[X],会发生什么? | B+C混合 |
| 原来不是而是 | 原来[X]不是[Y],而是[Z] | B型重新定义 |
---
## 六、可借鉴 vs 不能盲抄
### 所有类型通用
| 可借鉴 | 不能盲抄 |
|-------|---------|
| 选题的命题感 | 表面的生活样本/场景 |
| 标题的气质感 | 语气温度和个人语言习惯 |
| 内容的人格统一性 | 品牌符号本身(需先建立人格) |
| 把个人经历做成公共内容 | 没有真实经历的虚构叙述 |
### 可迁移内容模板(通用)
```
[你的真实场景]
↓
[找到说不清楚的感受]
↓
[用动词而不是形容词命名]
↓
[写成:场景 + 命题 + 你的标识符]
↓
[发布,看哪句话在评论区被引用]
```
---
## 七、参考数据来源
- **新榜:** `https://www.newrank.cn/profile/xiaohongshu/{user_id}`
- **第三方公开站:** TooBigData、热浪数据
- **小红书搜索页:** 可见搜索结果中的账号卡片数据(A1级)
- **公开访谈/播客:** 知乎、澎湃、凤凰等(B1级)
---
## 八、分析边界声明模板
```
本报告基于公开网页可见信息,数据级别:A1(主页可见)/ B1(第三方公开)/ C1(综合推断)。
未覆盖:全量历史笔记 / 评论区文本 / 收藏率完播率后台数据 / 商单实际转化数据。
适合用途:账号定位 / 内容策略学习 / 竞品研究 / 对标选题学习。
不适合:财务审计 / 精确数据核算。
```
FILE:references/templates.md
# Templates Reference — xhsfenxi v2.0
---
## Output Files
Always produce as `.docx` directly. Three standard files:
| File | When |
|------|------|
| `账号名-结构化总结报告.docx` | Single creator analysis |
| `账号名-爆款选题公式.docx` | Single creator topic formula |
| `选题公式学习-综合版.docx` | Multi-creator comparison |
---
## Structured Report Skeleton (v2 — Archetype-Aware)
```markdown
# 账号名 结构化总结报告
> 说明:本报告基于[数据来源说明]。适合用于[账号定位/内容策略/竞品研究/爆款机制提炼]。
## 一、执行摘要
[2–4段: 博主是谁,核心特征,最值得记住的一句话]
## 二、账号基础信息
[表格: 昵称/小红书号/粉丝/获赞收藏/笔记数/内容类型/账号阶段]
[补充: 全平台历史数据(如有)]
## 三、博主背景(公开资料)
[人物背景、职业起点、创作理念(来源于公开访谈)]
## 四、账号类型判断
**类型:** Type A/B/C 或 混合型
**判断依据:** [2–3条可见信号]
**核心内核:** [一句话总结]
## 五、博主核心内核拆解
[外壳是什么 / 真正的内核是什么 / 人设三层结构 / 最强能力]
## 六、内容结构拆解
[内容支柱分布(表格)/ 内容类型比例 / 统一主题]
## 七、标题机制与爆点分析
[最重要特征 / 品牌符号分析 / 高热内容分析(表格)/ 标题公式归纳]
## 八、与其他博主的横向对比(可选)
[对比表格: 多维度 × 多博主]
## 九、商业化判断
[当前阶段 / 为什么品牌会找他 / 适合的合作方向 / 不适合的方向]
## 十、最值得学习的核心能力
[3–5个能力,每个含: 说明 + 可学习点]
## 十一、后续建议
[路径A: 深入单篇分析 / 路径B: 制作爆款选题公式 / 路径C: 跨博主综合版]
## 十二、最终总结
[1–2段核心结论]
## 附录:笔记完整列表(可选)
[表格: 排名 / 类型 / 标题 / 点赞]
```
---
## Viral Topic Formula Skeleton (v2)
```markdown
# 账号名 爆款选题公式
> 说明:本文件不是复刻[账号名]的表面语气,而是提炼真正有效的选题逻辑、标题机制、传播结构、人设打法与可迁移方法。
## 一、先说结论:为什么能做出爆款?
[账号类型 + 5个叠在一起的核心能力 + 总公式一句话]
## 二、总爆款公式
[5步骤公式 + 压缩版关键词]
## 三、最常见的 3–6 类爆款选题模型
[每个模型: 公式 / 典型表达方向 / 为什么有效 / 可套用句式 / 适合主题]
## 四、标题公式:可以直接拆用
[5–6个标题公式,每个含: 结构说明 + 套用模板]
## 五、内容结构公式
[2–3个结构公式,每个含: 步骤 / 适合做的主题]
## 六、隐形公式 / 最值钱的底层打法
[3–4个]
## 七、如何迁移到自己的内容里
[4个步骤: 找现实母题 / 改写生活故事 / 用冲突词 / 4问检验]
## 八、可直接复用的 20 个选题方向
[编号列表]
## 九、哪些能学,哪些不能硬抄
[可以学的 / 不能硬抄的]
## 十、最终总结
[一句话核心结论]
```
---
## Comparison Skeleton (v2 — Archetype Map)
```markdown
# 选题公式学习(综合版)
## ——结合[博主A]与[博主B](以及[博主C])的内容方法论
> 说明:这份文档不是简单拼接报告,而是把各博主最有效的内容能力抽取出来,重新整理成可学习、可迁移、可直接应用的选题方法。
## 一、为什么把这几位博主放在一起学?
[各博主关键词 / 各博主强项一句话 / 为什么组合更完整]
## 二、账号类型对照表
[表格: 博主 / 类型 / 核心方向 / 用户获得感 / 内容气质 / 标题风格 / 爆点来源]
## 三、共同底层:真正的选题不是题材,而是命题
[5个共同点 / 核心结论]
## 四、双系统(或多系统)选题公式
[系统A(共鸣命名型): 适用场景 / 核心公式 / 压缩版 / 标题类型]
[系统B(现实策略型): 同上]
[(如有)系统C(荒诞美学型): 同上]
## 五、混合公式
[混合公式定义 + 例子 + 为什么更强]
## 六、标题公式综合版
[6–8个跨类型标题公式]
## 七、内容结构综合版
[结构A / B / C,各含步骤和用户获得感]
## 八、可直接复用的 30 个综合选题方向
[A组(偏共鸣命名)/ B组(偏现实策略)/ C组(偏荒诞美学)/ D组(混合)]
## 九、结论与行动建议
[最重要的学习提醒 + 最终公式]
```
---
## Archetype Comparison Table Template
Use this table when comparing 2–3+ accounts:
```markdown
| 维度 | [博主A] | [博主B] | [博主C] |
|---|---|---|---|
| 账号类型 | Type A 荒诞美学 | Type B 共鸣命名 | Type C 现实策略 |
| 核心方向 | | | |
| 用户获得感 | | | |
| 内容气质 | | | |
| 标题风格 | | | |
| 品牌符号 | | | |
| 爆点来源 | | | |
| 商业化方向 | | | |
| 可复制难度 | | | |
```
---
## Evidence Label Reference
Use these labels in your internal working notes (do not need to show all in final report):
| Label | Meaning |
|-------|---------|
| `A1` | Xiaohongshu homepage visible fact |
| `A2` | Xiaohongshu screenshot fact |
| `B1` | Third-party public clue (interview, news, etc.) |
| `C1` | Synthesis / interpretation |
---
## Business Word Checklist
When building a business-style Word file:
- [ ] Cover page
- [ ] TOC with `_TocH1_XXX` bookmarks (internal jump, not external link)
- [ ] Page numbers
- [ ] Conclusion page
- [ ] Restrained typography (no loud decorative color blocks unless requested)
- [ ] Tested in Word: TOC entries jump correctly
- [ ] No duplicate headings accidentally inserted in body
---
## Brand Symbol Analysis Template
For Type A accounts or any account with a unified recurring symbol:
```markdown
### 品牌符号分析
**符号:** [(劲爆)/ 其他]
**出现频率:** [几乎所有 / 大部分 / 部分]
**品牌效果:**
- 识别度: [描述]
- 反差机制: [描述]
- 先发制人效果: [描述]
**可学习点:** [其他账号如何建立自己的品牌符号]
```
FILE:references/workflow-xhsfenxi-v2.md
# Workflow Reference — xhsfenxi v2.0
---
## Source Ladder
Use sources in this order whenever possible:
### Level A — Primary public evidence
Highest trust for visible claims:
- Xiaohongshu search result pages
- creator homepage/profile pages
- user-provided screenshots from Xiaohongshu
- user-provided post links that are actually accessible
What you can safely claim from Level A:
- visible follower / note counts shown on page
- visible account ID / self-introduction
- visible recent titles / visible public engagement numbers
- posting cadence clues visible on page
- obvious content categories
- brand symbols and signature phrases (like "(劲爆)")
### Level B — Secondary public clues
Use as support, never as backend truth:
- podcast interviews
- encyclopedia entries
- public creator bios elsewhere
- analytics websites / public creator databases
- public brand collaboration listings
What Level B is good for:
- filling identity / background context
- confirming repeated themes
- spotting public commercialization clues
- triangulating a bigger narrative
### Level C — Synthesis / inference
Allowed only when clearly framed as interpretation.
Examples:
- "This account behaves more like a Type A 荒诞美学 creator than a Type B 共鸣命名 creator."
- "The recurring topic logic appears to be: contrast × literary naming × unified symbol."
- "The transferable lesson is not the tone, but the framing system."
Never present Level C synthesis as raw observed fact.
---
## Standard Execution Runbook
### Step 1 — Confirm the deliverable
Choose one or more:
- structured report
- viral topic formula
- archetype classification
- comparison report
- customized learning report
- Word / business version
### Step 2 — Capture the minimum task brief
Minimum useful brief:
- creator name
- available links or screenshots
- target output format
- whether a business Word version is required
### Step 3 — Archetype pre-classification
Before deep analysis, make a preliminary archetype call using visible signals:
| Signal | Likely archetype |
|--------|-----------------|
| Unified brand symbol/tag on every post | Type A 荒诞美学型 |
| High-production video, absurdist or philosophical content | Type A 荒诞美学型 |
| Posts name vague emotions or life stages | Type B 共鸣命名型 |
| Titles feel like thoughtful questions or redefinitions | Type B 共鸣命名型 |
| Titles contain conflict words ("骗子", "不要脸", "装") | Type C 现实策略型 |
| Content breaks unspoken workplace/relationship/money rules | Type C 现实策略型 |
| Mix of resonance + strategy | Mixed B+C |
### Step 4 — Research in layers
1. Find the account and public homepage/search data
2. Save visible account facts (follower count, note count, visible titles, engagement)
3. Pull representative visible titles — note brand symbols, title patterns
4. Add external public clues only where helpful
5. Record limitations before interpretation
### Step 5 — Extract the account system (Five Layers)
Always map these five layers:
1. **Identity** — who the creator is framed as; any self-labeling strategy
2. **Audience contract** — why people follow them; what emotional/strategic need is met
3. **Topic system** — what recurring problems/desires they cover; 3–6 topic models
4. **Expression system** — title formulas, brand symbols, opening patterns
5. **Transferability** — what another account can actually learn; what must not be copied
### Step 6 — Produce the right file
#### Structured report
Recommended top-level sections:
1. Account snapshot
2. Archetype classification + rationale
3. Positioning judgment
4. Audience and demand
5. Content pillars
6. Title / hook system (including brand symbol analysis)
7. Narrative structure
8. Commercialization clues
9. What to learn
10. What not to copy
11. Conclusion
12. (Optional) Full note list sorted by engagement
#### Viral topic formula
Recommended top-level sections:
1. Why this creator produces strong topics (archetype-based)
2. Total formula
3. 3–6 recurring topic models
4. Title formulas
5. Body structure formulas
6. Distribution / comment trigger clues
7. How to migrate it to another account
8. 10–30 topic directions
#### Comparison report (2–3+ accounts)
Recommended top-level sections:
1. Why compare these creators together
2. Archetype map (each creator's type)
3. Shared foundations
4. Key differences
5. Hybrid formula
6. Which path fits which use case
7. Suggested topic directions
8. Final recommendation
---
## Archetype-Specific Analysis Lenses
### Type A — 荒诞美学型
Focus questions:
- What is the brand symbol? (unified tag, phrase, or visual marker)
- How does the title create contrast? (grand × mundane, serious × absurd)
- Where does the humor come from? (earnest treatment of absurd things)
- What is the underlying philosophical/emotional core?
- Why is replication hard? (visual sensibility, accumulated aesthetic)
Key learning for other accounts:
- Find your own "brand symbol" — a unifying tag that creates recognition
- Train "contrast naming" — don't describe the place, name the feeling
- Practice "absurdist framing" — pick topics that are ridiculous when taken seriously
---
### Type B — 共鸣命名型
Focus questions:
- What life stages or emotional states does the account name?
- How does private experience become universal proposition?
- What are the signature conceptual phrases?
- What is the "new way to understand yourself" users get?
Key learning for other accounts:
- Practice "命题化" — reframe personal experience as a universal human situation
- Build a personal vocabulary of named states ("Odyssey时期", etc.)
- The formula: experience → proposition → naming → judgment → resonance
---
### Type C — 现实策略型
Focus questions:
- What real-world rules does the account break open?
- How does the self-labeling strategy work? (antifragile identity)
- What is the conflict word in each title?
- What is the "can-do action" users walk away with?
Key learning for other accounts:
- Find your "reality母题" — the real困境 you speak to
- Practice rewriting weak framings into strong rule-breaking framings
- The formula: 困境 → 说破 → 规则 → 策略 → 爽感
---
## Limitation Language
Use direct wording like:
- "This round is based primarily on public homepage/search-page evidence."
- "Single-post detail pages were not stably accessible due to platform risk control."
- "Third-party figures are used as directional clues, not audited backend data."
- "Archetype classification is based on visible patterns; the creator may operate differently at the full content level."
---
## Deep-Dive Upgrade Path
If the user wants finer analysis, ask for 3–10 representative posts, ideally with:
- screenshots of cover + title
- screenshots of body pages / subtitles
- comments screenshots
- transcript or summary if video-based
Then upgrade from account-level to post-level analysis.
---
## Word Output Workflow
### Overview
Always generate Markdown first, then convert to Word. Iterating Markdown is cheaper than re-generating DOCX.
### Script Selection Guide
Use the workflow archive scripts at:
```
openclaw_cosmo/afa/小红书分析与工作流归档/02-Word生成与目录修复脚本/
```
| Script | Use when |
|--------|----------|
| `build_docx6.py` | Default — most stable, use this first |
| `build_docx.py` → `build_docx5.py` | Earlier iterations, kept for reference/debugging |
| `inspect_docx.py` | Inspect internal XML, bookmarks, link structure |
| `fix_final2.py` | Final TOC / attribute fix after build |
| `fix_attrs.py` | Fix XML namespace attribute writing errors |
| `fix_wrong.py` | Targeted corrections for known wrong output |
| `check_it.py` | Verify output correctness |
### Word TOC Fix Checklist
When a generated .docx has broken TOC links:
1. Run `inspect_docx.py` — check `word/document.xml` for bookmark names and link types
2. Verify heading bookmarks use format `_TocH1_XXX` (not `#_TocH1_XXX`)
3. Verify internal links use Word-native anchor style, not external URL rels
4. If `elem.set()` errors appear, use full namespace: `elem.set('{' + W_NS + '}id', value)`
5. Run `fix_final2.py` after any XML edits
6. Test by opening in Word — click TOC entries to verify jump
### Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| Bookmark name wrong format | Missing `_TocH1_` prefix | Rename via `fix_attrs.py` |
| `anchor` contains `#` | Wrong anchor format | Strip `#` prefix |
| TOC uses external link rels | Used `http://` link type for internal jump | Switch to `w:instr` field or anchor |
| Duplicate headings in body | TOC headings accidentally inserted into body | Use `fix_wrong.py` to clean |
| Namespace attribute error | Missing full namespace URI in `elem.set()` | Use `'{' + W_NS + '}id'` form |
### Best Practice
- Keep only `build_docx6.py` as the production script
- Keep all earlier versions as debugging reference, not for production
- One Markdown → one `build_docx6.py` call → inspect → fix if needed
FILE:references/workflow.md
# 完整分析工作流手册 v2.0
> 记录:xixiCharon 深度分析全过程(三份文档合并 + hsword框架应用)
> 原始 xhsfenxi 工作流见:workflow-xhsfenxi-v2.md
---
## 标准执行管道(Full Pipeline)
```
Step 0 Cookie 健康检查
Step 1 解析输入 → 检查数据库
Step 2 数据采集
Step 3 基础统计
Step 4 三型分类
Step 5 内核三段论(hsword框架)
Step 6 爆款选题公式(6模型)
Step 7 结构化报告 Markdown
Step 8 外部文档合并(如有)
Step 9 写入博主数据库
Step 10 生成黑体 Word
```
---
## Step 0 — Cookie 健康检查
```python
from xhscosmoskill import print_cookie_status, get_best_cookies
cookies = get_best_cookies()
print_cookie_status(cookies)
```
**Cookie 优先顺序:**
1. `shopify-marketing/xhs_cookies.json`(最新,优先)
2. `xiaohongshu_new/xhs_cookies.json`(备用)
**过期判断:** notes 返回 ≤ 1 条 → Cookie 失效 → 运行 `python3 xhs_login.py`
---
## Step 1 — 解析输入
```python
import re
user_id = re.search(r'/user/profile/([a-f0-9]+)', url).group(1)
from xhscosmoskill import get_blogger
existing = get_blogger(creator_name) # 检查是否已分析过
```
---
## Step 2 — 数据采集
```python
from xhscosmoskill import XhsClient
with XhsClient(cookies_file=cookies, headless=True, scroll_times=10) as xhs:
notes = xhs.get_user_notes(user_id, limit=50)
xhs.save(notes, f"/tmp/{creator_name}_notes.json")
```
---
## Step 3-4 — 统计 + 分类
```python
from xhscosmoskill import compute_stats, classify_archetype, build_five_layers
stats = compute_stats(notes)
archetype = classify_archetype(notes)
five = build_five_layers(notes, archetype)
```
---
## Step 5 — 内核三段论(hsword框架)
每次分析必须明确三层结构(参见 hsword-frameworks.md):
```
外壳是什么?→ 表面标签(可模仿)
真正的内核?→ 一句话,带""引号
三层人设:
表层标签 → 自动提取
中层特质 → 手动填写(性格/能力/气质)
深层价值观 → 手动填写(最重要,不可复制)
```
---
## Step 6 — 爆款选题公式
```python
from xhscosmoskill import generate_formula_report
from xhscosmoskill.utils import save_md
formula_md = generate_formula_report(notes, creator_name, archetype)
save_md(formula_md, f"/tmp/{creator_name}-爆款选题公式.md")
```
生成内容:总公式 + 6模型(每个含可套用句式)+ 30个选题方向 + 可迁移模板
---
## Step 7 — 结构化报告
```python
from xhscosmoskill import analyze_account
report_md = analyze_account(notes, creator_name=creator_name, mode="full")
save_md(report_md, f"/tmp/{creator_name}-结构化总结报告.md")
```
---
## Step 8 — 外部文档合并(如有)
用户提供额外分析文档时,按优先级 patch:
| 优先级 | 内容 |
|--------|------|
| 1 | 更高的点赞数(修正我们的数据)|
| 2 | 新笔记数据(我们50篇未收录的)|
| 3 | 新分析维度(我们没有的章节)|
| 4 | 更精炼的提炼(更好的一句话总结)|
**xixiCharon 合并经验:**
- 文档1(txt):补入 3.2万/7490/6231 等数据 + 视觉风格 + 风险分析
- 文档2(md):补入 8813赞 + 头部集中度38.15% + 可摘抄感 + 新榜链接
- 验证:patch后检查7个关键词确认写入
---
## Step 9 — 写入数据库
```python
from xhscosmoskill import save_blogger
save_blogger(
creator_name=creator_name,
user_id=user_id,
archetype=archetype,
stats={"avg_likes": stats["avg_likes"], "max_likes": stats["max_likes"],
"sample_size": stats["total"], "viral_count": stats["brackets"]["1万+"],
"video_ratio": f"{stats['video_count']}/{stats['total']}"},
best_topic=five["best_topic"],
best_topic_avg=five["best_topic_avg"],
formula="核心公式一句话",
)
```
---
## Step 10 — 生成黑体 Word
```python
from xhscosmoskill.scripts.build_docx import build_word
build_word(f"/tmp/{creator_name}-结构化总结报告.md",
f"/tmp/{creator_name}-结构化总结报告.docx",
title=creator_name, subtitle="小红书博主深度结构化分析报告")
build_word(f"/tmp/{creator_name}-爆款选题公式.md",
f"/tmp/{creator_name}-爆款选题公式.docx",
title=creator_name, subtitle="爆款选题公式 · 6大模型 · 30个选题方向")
```
---
## 证据分级
| 级别 | 来源 | 用法 |
|------|------|------|
| A1 | 小红书公开主页可见数据 | 直接陈述 |
| A2 | 用户提供截图 | 直接陈述 |
| B1 | 第三方公开(新榜/访谈)| 背景补充 |
| C1 | 综合推断 | 明确标注"推断" |
---
## 类型迭代协议
```python
from xhscosmoskill import add_archetype, update_archetype_signals
# 添加新类型(当最高分 < 10 时)
add_archetype(key="D", name="知识科普型", desc="...",
formula="...", title_signals=[], content_signals=[],
commercial="...", difficulty="中")
# 迭代已有类型信号词
update_archetype_signals("B", new_content_signals=["新词"])
```
---
## 已分析博主档案
| 博主 | user_id | 类型 | 最高赞 | 精神母题 |
|------|---------|------|--------|---------|
| xixiCharon | 5f94fa23000000000100bced | B+A | 10万+ | 一个女生如何在流动生活里确认自己 |
---
## 参考资源
- hsword 实战案例:`openclaw_cosmo/afa/hsword/`
- hsword 框架手册:`references/hsword-frameworks.md`
- 原始 xhsfenxi 工作流:`references/workflow-xhsfenxi-v2.md`
- Word 修复脚本:`openclaw_cosmo/afa/小红书分析与工作流归档/02-Word生成与目录修复脚本/`
- 新榜数据:`https://www.newrank.cn/profile/xiaohongshu/{user_id}`
FILE:scripts/archetype.js
#!/usr/bin/env node
/**
* xhsfenxi — archetype.js
* Purpose: Print the archetype classification prompt for a Xiaohongshu creator.
* Helps identify whether an account is Type A (荒诞美学), Type B (共鸣命名),
* Type C (现实策略), or a hybrid — based on the three proven archetypes
* distilled from real analyses of multiple Xiaohongshu creators.
* Usage:
* node scripts/archetype.js <creator-name>
* node scripts/archetype.js <creator-name> --quick
*/
const args = process.argv.slice(2);
const quick = args.includes('--quick');
const name = args.filter(a => !a.startsWith('--'))[0];
if (!name) {
console.error('Usage: node scripts/archetype.js <creator-name> [--quick]');
process.exit(1);
}
if (quick) {
console.log(`
=== xhsfenxi archetype cheatsheet ===
TYPE A — 荒诞美学型 (Absurdist Aesthetics)
Example: 荒诞美学博主(vlogger型)
Signals:
- Unified brand symbol/tag on every post (e.g. "(劲爆)")
- Absurdist or philosophical topics treated earnestly
- High-production video; literary title naming
- Serious × absurd contrast in every piece
Formula: 荒诞滤镜下的生活哲学
TYPE B — 共鸣命名型 (Resonance & Naming)
Example: 成长世界观表达博主
Signals:
- Posts name vague emotional states or life stages
- Private experience → universal proposition
- Titles feel like thoughtful questions or redefinitions
Formula: 经历 → 命题 → 命名 → 判断 → 共鸣
TYPE C — 现实策略型 (Reality & Strategy)
Example: 普通女孩上行策略博主
Signals:
- Titles contain conflict words ("骗子", "不要脸", "装")
- Content breaks unspoken workplace/relationship/money rules
- Self-labels with perceived weakness to pre-empt criticism
Formula: 困境 → 说破 → 规则 → 策略 → 爽感
MIXED B+C — Most powerful hybrid
Combines: resonance/naming × reality/strategy
= "You understand me" + "Now I know what to do"
`);
process.exit(0);
}
console.log(`
=== xhsfenxi archetype classification: name ===
TASK
Classify the Xiaohongshu account "name" into one of these three archetypes
(or a hybrid), based on visible public evidence.
─────────────────────────────────────────────
THREE ARCHETYPES
─────────────────────────────────────────────
TYPE A — 荒诞美学型 (Absurdist Aesthetics)
Core: Wraps philosophical/serious content in absurdist humor + high visual quality
Proven example: 荒诞美学vlogger型 (unified brand symbol; grand × mundane contrast; literary naming)
Key signals to look for:
- Does every post share a unified recurring symbol or phrase?
- Do titles contrast a grand/serious setting with a mundane/absurd action?
- Is the visual/production quality noticeably higher than peers?
- Are serious topics treated with humor and humor treated earnestly?
TYPE B — 共鸣命名型 (Resonance & Naming)
Core: Names vague emotions and life stages so users feel "finally, someone said it"
Proven example: 成长世界观表达型 (youth growth, worldview expression, concept naming)
Key signals to look for:
- Do posts give names or definitions to emotional states most people can't articulate?
- Does the account turn personal stories into universal life propositions?
- Do titles feel like thoughtful philosophical questions or redefinitions?
- Is the tone warm, perceptive, aesthetically refined?
TYPE C — 现实策略型 (Reality & Strategy)
Core: Breaks unspoken real-world rules; provides executable strategies for ordinary people
Proven example: 普通女孩上行策略型 (antifragile identity; rule-breaking; 普通人上行)
Key signals to look for:
- Do titles contain conflict words or counter-intuitive framing?
- Does the account self-label with a perceived weakness to pre-empt criticism?
- Does content expose hidden rules in workplace/relationships/money/consumption?
- Does every piece end with a clear, actionable conclusion or stance?
MIXED HYBRID
Some accounts combine two archetypes. Most powerful is B+C:
"I understand your situation (Type B) AND here's what you can actually do (Type C)"
─────────────────────────────────────────────
CLASSIFICATION PROMPT
─────────────────────────────────────────────
Based on publicly visible data for "name", answer:
1. Which archetype does this account most closely match? (A / B / C / Mixed)
2. What are 2–3 concrete visible signals that support this classification?
3. If mixed: what percentage is each type, and does one dominate?
4. What is the single sentence that describes this account's core "filter" on the world?
5. Is there a brand symbol or signature phrase? If yes, what is it and how does it function?
Then proceed with the full analysis using this archetype as the lens.
`);
FILE:scripts/build_docx.py
"""
build_docx.py — 通用黑体 Word 生成器
将 Markdown 文件转换为全黑体样式的 Word 文档
支持:标题层级 / 表格 / 列表 / 引用 / 代码块 / 分割线
用法(命令行):
python3 build_docx.py <md_path> <out_path> [title] [subtitle] [meta]
用法(Python):
from xhscosmoskill.scripts.build_docx import build_word
build_word("/tmp/report.md", "/tmp/report.docx",
title="xixiCharon", subtitle="爆款选题公式")
"""
import re
import sys
from pathlib import Path
try:
from docx import Document
from docx.shared import Pt, RGBColor, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
except ImportError:
print("请先安装:pip install python-docx")
sys.exit(1)
# ── 配色 ──────────────────────────────────────────────────────
C_BRAND = RGBColor(0x1A, 0x1A, 0x1A)
C_H1 = RGBColor(0x0D, 0x0D, 0x0D)
C_H2 = RGBColor(0x1A, 0x1A, 0x1A)
C_ACCENT = RGBColor(0x00, 0x80, 0x60) # Shopify 绿
C_LIGHT = RGBColor(0x6B, 0x72, 0x80)
C_TH_BG = RGBColor(0xF3, 0xF4, 0xF6)
C_LINE = RGBColor(0x22, 0xC5, 0x5E)
HEITI = "黑体"
# ── 工具函数 ──────────────────────────────────────────────────
def set_font(run, size, bold=False, color=None, italic=False):
"""全黑体设置(中英文统一)"""
run.font.size = Pt(size)
run.font.bold = bold
run.font.italic = italic
if color:
run.font.color.rgb = color
rPr = run._r.get_or_add_rPr()
rf = rPr.find(qn('w:rFonts'))
if rf is None:
rf = OxmlElement('w:rFonts')
rPr.insert(0, rf)
for attr in ('w:ascii', 'w:hAnsi', 'w:eastAsia', 'w:cs'):
rf.set(qn(attr), HEITI)
def clean(s):
for p, r in [
(r'\*\*(.+?)\*\*', r'\1'), (r'\*(.+?)\*', r'\1'),
(r'\[(.+?)\]\(.+?\)', r'\1'), (r'`(.+?)`', r'\1'),
(r'^#{1,6}\s+', ''),
]:
s = re.sub(p, r, s)
return s.strip()
def set_cell_bg(cell, rgb):
tcPr = cell._tc.get_or_add_tcPr()
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), f'{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}')
tcPr.append(shd)
def add_rule(doc, color=C_LINE):
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(1)
p.paragraph_format.space_after = Pt(6)
pPr = p._p.get_or_add_pPr()
pBdr = OxmlElement('w:pBdr')
bot = OxmlElement('w:bottom')
bot.set(qn('w:val'), 'single')
bot.set(qn('w:sz'), '4')
bot.set(qn('w:space'), '1')
bot.set(qn('w:color'), f'{color[0]:02X}{color[1]:02X}{color[2]:02X}')
pBdr.append(bot)
pPr.append(pBdr)
# ── 核心构建函数 ──────────────────────────────────────────────
def build_word(md_path: str, out_path: str,
title: str = "", subtitle: str = "", meta: str = ""):
"""
将 Markdown 转换为黑体 Word 文档
参数:
md_path — 输入 Markdown 文件路径
out_path — 输出 .docx 文件路径
title — 封面大标题(可选)
subtitle — 封面副标题(可选)
meta — 封面元数据行(可选)
"""
md_text = Path(md_path).read_text(encoding="utf-8")
doc = Document()
for sec in doc.sections:
sec.top_margin = Cm(2.5)
sec.bottom_margin = Cm(2.5)
sec.left_margin = Cm(3.0)
sec.right_margin = Cm(3.0)
# 默认样式
nml = doc.styles['Normal']
nml.font.name = HEITI
nml.font.size = Pt(10.5)
nml._element.rPr.rFonts.set(qn('w:eastAsia'), HEITI)
# 封面
if title:
doc.add_paragraph()
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
set_font(p.add_run(title), 28, bold=True, color=C_ACCENT)
if subtitle:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
set_font(p.add_run(subtitle), 14, color=C_LIGHT)
if meta:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
set_font(p.add_run(meta), 9, color=C_LIGHT)
if title:
doc.add_page_break()
# 解析 Markdown
lines = md_text.splitlines()
i = 0
while i < len(lines):
line = lines[i]
# H1
if re.match(r'^# [^#]', line):
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(16)
p.paragraph_format.space_after = Pt(4)
set_font(p.add_run(clean(line)), 20, bold=True, color=C_H1)
i += 1; continue
# H2
if re.match(r'^## [^#]', line):
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(18)
p.paragraph_format.space_after = Pt(2)
set_font(p.add_run(clean(line)), 13, bold=True, color=C_H2)
add_rule(doc)
i += 1; continue
# H3
if re.match(r'^### ', line):
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(10)
p.paragraph_format.space_after = Pt(3)
set_font(p.add_run(clean(line)), 11, bold=True, color=C_ACCENT)
i += 1; continue
# 引用
if line.startswith('> '):
p = doc.add_paragraph()
p.paragraph_format.left_indent = Cm(1)
p.paragraph_format.space_before = Pt(3)
p.paragraph_format.space_after = Pt(3)
set_font(p.add_run(line[2:].strip()), 10.5, italic=True, color=C_LIGHT)
i += 1; continue
# 代码块
if line.startswith('```'):
i += 1
code = []
while i < len(lines) and not lines[i].startswith('```'):
code.append(lines[i])
i += 1
p = doc.add_paragraph('\n'.join(code))
p.paragraph_format.left_indent = Cm(0.8)
p.paragraph_format.space_before = Pt(3)
p.paragraph_format.space_after = Pt(3)
for run in p.runs:
set_font(run, 9, color=RGBColor(0x37, 0x37, 0x37))
i += 1; continue
# 水平线
if line.strip() == '---':
add_rule(doc, C_TH_BG)
i += 1; continue
# 表格
if line.strip().startswith('|'):
tbl_lines = []
while i < len(lines) and lines[i].strip().startswith('|'):
if not re.match(r'^\|[\s|:-]+$', lines[i].strip()):
tbl_lines.append(lines[i])
i += 1
if not tbl_lines:
continue
rows = [
[c.strip() for c in tl.split('|')[1:-1]]
for tl in tbl_lines
]
rows = [r for r in rows if any(c for c in r)]
if not rows:
continue
ncols = max(len(r) for r in rows)
tbl = doc.add_table(rows=len(rows), cols=ncols)
tbl.style = 'Table Grid'
tbl.alignment = WD_TABLE_ALIGNMENT.LEFT
for ri, row in enumerate(rows):
for ci in range(ncols):
cell = tbl.cell(ri, ci)
txt = clean(row[ci]) if ci < len(row) else ''
p = cell.paragraphs[0]
p.clear()
run = p.add_run(txt)
set_font(run, 9.5,
bold=(ri == 0),
color=C_H2 if ri == 0 else C_BRAND)
p.paragraph_format.space_before = Pt(2)
p.paragraph_format.space_after = Pt(2)
if ri == 0:
set_cell_bg(cell, C_TH_BG)
doc.add_paragraph()
continue
# 列表
if re.match(r'^[-*] |^\d+\. ', line):
txt = re.sub(r'^[-*] |^\d+\. ', '', line)
p = doc.add_paragraph(style='List Bullet')
p.paragraph_format.left_indent = Cm(0.8)
p.paragraph_format.space_before = Pt(1)
p.paragraph_format.space_after = Pt(1)
set_font(p.add_run(clean(txt)), 10.5, color=C_BRAND)
i += 1; continue
# 空行
if not line.strip():
i += 1; continue
# 普通段落
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(2)
p.paragraph_format.space_after = Pt(4)
set_font(p.add_run(clean(line)), 10.5, color=C_BRAND)
i += 1
doc.save(out_path)
print(f"✅ Word 已生成:{out_path}")
return out_path
# ── 命令行入口 ────────────────────────────────────────────────
if __name__ == "__main__":
if len(sys.argv) < 3:
print("用法:python3 build_docx.py <md_path> <out_path> [title] [subtitle] [meta]")
sys.exit(1)
md = sys.argv[1]
out = sys.argv[2]
ttl = sys.argv[3] if len(sys.argv) > 3 else ""
sub = sys.argv[4] if len(sys.argv) > 4 else ""
meta = sys.argv[5] if len(sys.argv) > 5 else ""
build_word(md, out, ttl, sub, meta)
FILE:scripts/docx-plan.js
#!/usr/bin/env node
/**
* xhsfenxi — docx-plan.js
* Purpose: Print the Word generation plan for a completed Xiaohongshu analysis,
* including which build_docx*.py script to use, the full workflow steps,
* and TOC fix guidance from the workflow archive.
* Usage:
* node scripts/docx-plan.js <creator-name>
* node scripts/docx-plan.js <creator-name> --mode comparison
* node scripts/docx-plan.js --toc-fix
*/
const ARCHIVE_BASE = '~/Desktop/cosmocloud/Deeplumen/cosmowork/openclaw_cosmo/afa/小红书分析与工作流归档/02-Word生成与目录修复脚本';
const args = process.argv.slice(2);
const tocFix = args.includes('--toc-fix');
const modeIdx = args.indexOf('--mode');
const mode = modeIdx !== -1 ? args[modeIdx + 1] : 'single';
const name = args.filter((a, i) => {
if (a.startsWith('--')) return false;
if (modeIdx !== -1 && i === modeIdx + 1) return false;
return true;
})[0];
if (tocFix) {
console.log(`
=== xhsfenxi Word TOC Fix Guide ===
Archive scripts location:
ARCHIVE_BASE/
STEP-BY-STEP FIX PROCESS
1. Run inspect_docx.py to diagnose the issue:
python3 ARCHIVE_BASE/inspect_docx.py <your-file.docx>
→ Check: bookmark names, link types, heading structure
2. Verify the following are correct:
- Bookmark names follow format: _TocH1_XXX (NOT #_TocH1_XXX)
- Internal TOC links use Word-native anchor, NOT external http:// rels
- No duplicate headings accidentally inserted in document body
- Namespace attributes written as: elem.set('{' + W_NS + '}id', value)
3. Run the appropriate fix script:
- For attribute/namespace errors: python3 fix_attrs.py <file.docx>
- For final TOC + heading cleanup: python3 fix_final2.py <file.docx>
- For known wrong output patterns: python3 fix_wrong.py <file.docx>
4. Verify with check_it.py:
python3 ARCHIVE_BASE/check_it.py <your-fixed-file.docx>
5. Open in Word and test: click each TOC entry to confirm internal jump works.
COMMON ERROR TABLE
Bookmark format wrong → rename using fix_attrs.py
anchor contains # → strip # prefix in fix_final2.py
TOC uses external rels → switch to w:instr field or Word anchor
Duplicate headings → fix_wrong.py
XML namespace error → use full namespace URI in elem.set()
`);
process.exit(0);
}
if (!name && !tocFix) {
console.error('Usage: node scripts/docx-plan.js <creator-name> [--mode comparison]');
console.error(' node scripts/docx-plan.js --toc-fix');
process.exit(1);
}
const isComparison = mode === 'comparison';
const mdFile = isComparison
? `选题公式学习-综合版.md`
: `name-结构化总结报告.md`;
const docxFile = isComparison
? `选题公式学习-综合版.docx`
: `name-结构化总结报告-商业版.docx`;
console.log(`
=== xhsfenxi Word generation plan: name || 'comparison' ===
Mode: 'single creator'
─────────────────────────────────────────────
STEP 1 — Confirm Markdown is complete
─────────────────────────────────────────────
Make sure this file is finalized:
mdFile
─────────────────────────────────────────────
STEP 2 — Run build_docx6.py (recommended)
─────────────────────────────────────────────
Primary script (most stable):
python3 ARCHIVE_BASE/build_docx6.py mdFile docxFile
If build_docx6.py fails, fall back to earlier versions:
build_docx5.py → build_docx4.py → build_docx3.py
─────────────────────────────────────────────
STEP 3 — Inspect the output
─────────────────────────────────────────────
python3 ARCHIVE_BASE/inspect_docx.py docxFile
Check for:
✓ Headings have _TocH1_XXX bookmarks
✓ TOC links are internal (not external URL rels)
✓ No duplicate heading text in body
✓ Page count and section structure look correct
─────────────────────────────────────────────
STEP 4 — Fix if needed
─────────────────────────────────────────────
Run: node scripts/docx-plan.js --toc-fix
for the full TOC fix checklist.
Most common fix:
python3 ARCHIVE_BASE/fix_final2.py docxFile
─────────────────────────────────────────────
STEP 5 — Final check
─────────────────────────────────────────────
python3 ARCHIVE_BASE/check_it.py docxFile
→ Open in Word, click TOC entries, verify internal jumps work
─────────────────────────────────────────────
EXPECTED OUTPUT FILES
─────────────────────────────────────────────
docxFile
` ${name-结构化总结报告.md (keep as source of truth)\n name-爆款选题公式.md (if also produced)`}
─────────────────────────────────────────────
ARCHIVE LOCATION
─────────────────────────────────────────────
Scripts: ARCHIVE_BASE/
Stable production script: build_docx6.py
Inspection tool: inspect_docx.py
`);
FILE:scripts/intake.js
#!/usr/bin/env node
/**
* xhsfenxi — intake.js
* Purpose: Print a structured intake template for a Xiaohongshu creator-analysis task.
* Usage:
* node scripts/intake.js
* node scripts/intake.js --json
*/
const args = process.argv.slice(2);
const asJson = args.includes('--json');
const template = {
task: 'xiaohongshu-creator-analysis',
creatorNames: ['<creator-name>'],
links: ['<homepage-or-search-url>'],
screenshots: ['<optional-local-path-or-url>'],
deliverables: ['structured-report'],
outputDir: '<optional-output-dir>',
needWord: false,
needBusinessVersion: false,
needComparison: false,
notes: 'What exactly should be learned from this creator?'
};
if (asJson) {
console.log(JSON.stringify(template, null, 2));
process.exit(0);
}
console.log(`
=== xhsfenxi intake template ===
Provide or confirm the following before deep research:
- creator name(s)
- homepage/search URL(s)
- screenshots or links for representative posts (optional but helpful)
- requested deliverable(s): structured-report / topic-formula / comparison / business-word
- output directory
- any specific question to answer
JSON template:
JSON.stringify(template, null, 2)
`);
FILE:scripts/report-plan.js
#!/usr/bin/env node
/**
* xhsfenxi — report-plan.js
* Purpose: Print recommended deliverables and filenames for a Xiaohongshu analysis request.
* Usage:
* node scripts/report-plan.js <creator-name>
* node scripts/report-plan.js <creator-a> <creator-b> --mode compare
*/
const args = process.argv.slice(2);
const modeIdx = args.indexOf('--mode');
const mode = modeIdx !== -1 ? args[modeIdx + 1] : 'single';
const names = args.filter((a, i) => {
if (a === '--mode') return false;
if (modeIdx !== -1 && i === modeIdx + 1) return false;
return true;
});
if (!names.length) {
console.error('Usage: node scripts/report-plan.js <creator-name>');
console.error(' node scripts/report-plan.js <creator-a> <creator-b> --mode compare');
process.exit(1);
}
if (mode === 'compare') {
if (names.length < 2) {
console.error('Compare mode requires two creator names.');
process.exit(1);
}
const [a, b] = names;
const files = [
`选题公式学习-综合版.docx`
];
console.log(`
=== xhsfenxi report plan ===
Mode: comparison
Creators: a / b
Recommended deliverables:
- comparison report
- hybrid topic-formula study
- optional business Word version
Recommended filenames:
files.map(f => `- ${f`).join('\n')}
`);
process.exit(0);
}
const name = names[0];
const files = [
`name-结构化总结报告.docx`,
`name-爆款选题公式.docx`
];
console.log(`
=== xhsfenxi report plan ===
Mode: single creator
Creator: name
Recommended deliverables:
- structured report
- viral topic formula
- optional business Word version
Recommended filenames:
files.map(f => `- ${f`).join('\n')}
`);
FILE:utils.py
"""
utils.py — 工具函数集
包含:Cookie 健康检查 / 路径常量 / 输出工具
"""
import json
import os
import time
from typing import Dict, Any
_BASE = os.path.dirname(__file__)
# ── 路径常量 ──────────────────────────────────────────────────
COOKIES_PRIMARY = os.path.join(
_BASE, "..", "..", "..", "shopify-marketing", "xhs_cookies.json"
)
COOKIES_ALT = os.path.join(_BASE, "..", "xhs_cookies.json")
DATA_DIR = os.path.join(_BASE, "data")
ARCHETYPES_DB = os.path.join(DATA_DIR, "archetypes.json")
BLOGGERS_DB = os.path.join(DATA_DIR, "bloggers.json")
HSWORD_DIR = os.path.join(
_BASE, "..", "..", "..", "..", "openclaw_cosmo", "afa", "hsword"
)
# ── Cookie 健康检查 ───────────────────────────────────────────
def check_cookies(cookies_file: str = None) -> Dict[str, Any]:
"""
检查 Cookie 文件的有效性
返回:
{
"valid": bool,
"file": str,
"total": int,
"expired": list[str],
"expiring_soon": list[str], # 24小时内过期
"message": str
}
"""
path = cookies_file or _find_cookies()
if not path or not os.path.exists(path):
return {"valid": False, "file": path, "message": "Cookie 文件不存在"}
try:
with open(path, encoding="utf-8") as f:
cookies = json.load(f)
except Exception as e:
return {"valid": False, "file": path, "message": f"读取失败: {e}"}
now = int(time.time())
expired = []
soon = []
for c in cookies:
exp = c.get("expiry") or c.get("expires", 0)
if not exp:
continue
diff = exp - now
if diff <= 0:
expired.append(c["name"])
elif diff < 86400:
soon.append(c["name"])
valid = len(expired) == 0
parts = []
if expired:
parts.append(f"❌ 已过期:{', '.join(expired)}")
if soon:
parts.append(f"⚠️ 即将过期(24h内):{', '.join(soon)}")
if valid and not soon:
parts.append("✅ 全部有效")
return {
"valid": valid,
"file": path,
"total": len(cookies),
"expired": expired,
"expiring_soon": soon,
"message": " | ".join(parts),
}
def _find_cookies() -> str:
"""自动查找最新 Cookie 文件"""
candidates = [
os.path.abspath(COOKIES_PRIMARY),
os.path.abspath(COOKIES_ALT),
]
# 返回最近修改的有效文件
valid = [p for p in candidates if os.path.exists(p)]
if not valid:
return ""
return max(valid, key=os.path.getmtime)
def get_best_cookies() -> str:
"""获取当前最佳 Cookie 路径(优先使用最新且有效的)"""
for path in [os.path.abspath(COOKIES_PRIMARY), os.path.abspath(COOKIES_ALT)]:
if os.path.exists(path):
status = check_cookies(path)
if status["valid"]:
return path
# 如果都过期,返回最新修改的(并给出警告)
return _find_cookies()
# ── 输出工具 ──────────────────────────────────────────────────
def print_cookie_status(cookies_file: str = None):
"""打印 Cookie 状态(带颜色提示)"""
status = check_cookies(cookies_file)
print(f"Cookie 文件: {status['file']}")
print(f"状态: {status['message']}")
if not status["valid"]:
print("⚡ 请重新运行:python3 xhs_login.py")
return status["valid"]
def save_md(content: str, path: str):
"""保存 Markdown 文件"""
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print(f"📄 Markdown 已保存:{path}")
def parse_likes(v) -> int:
"""解析点赞数(支持'万'格式)"""
if not v:
return 0
s = str(v).replace(",", "").strip()
if "万" in s:
return int(float(s.replace("万", "")) * 10000)
try:
return int(s)
except ValueError:
return 0
FILE:xhsfenxi/SKILL.md
---
name: xhsfenxi
description: |
小红书博主分析全流程 skill — 拆解账号定位、提炼爆款选题公式、输出结构化报告和商业版 Word。内置三型博主分类体系(荒诞美学型 / 共鸣命名型 / 现实策略型),基于多位真实博主的深度分析沉淀。支持单账号深度拆解、多账号对比、自定义学习报告,证据分级(公开主页→截图→第三方公开资料→推断),所有交付物可一键转为 Markdown + 商业 Word。Use it when the user asks to analyze a Xiaohongshu blogger/account, extract viral topic formulas, compare creators, or produce business-grade deliverables.
keywords:
- xiaohongshu
- RED
- 小红书
- 小红书分析
- 博主分析
- 账号拆解
- 对标分析
- 爆款选题
- 爆款选题公式
- 内容策略
- 个人IP分析
- 创作者分析
- 三型博主
- 荒诞美学型
- 共鸣命名型
- 现实策略型
- 品牌符号
- 结构化总结报告
- Word报告
- 商业版报告
- 选题公式学习
- 博主拆解
- 小红书运营
- 内容账号分析
- 竞品分析
- 账号定位
- benchmark account
- topic formula
- creator research
- content audit
- account positioning
- competitor analysis
- archetype classification
- viral topic
- markdown report
- word report
- 小红书博主对比
- 爆款内容拆解
- 内容IP打法
metadata:
openclaw:
runtime:
node: ">=18"
---
# xhsfenxi — 小红书博主分析 Skill
> 公开页分析 · 三型博主分类 · 爆款选题公式提炼 · 多账号对比 · Markdown/商业Word交付
---
## When to Use
Use this skill when the user asks to:
- analyze a Xiaohongshu / RED creator account
- identify which archetype a creator belongs to (荒诞美学型 / 共鸣命名型 / 现实策略型)
- summarize an account's positioning, audience, style, or content pillars
- extract a **viral topic formula** or reusable content method
- compare two or more benchmark creators
- learn what can be copied vs what must stay differentiated
- turn the analysis into a **Markdown report**, **Word report**, or **business-style deliverable**
Typical trigger phrases:
- "分析这个小红书博主"
- "拆一下这个账号"
- "做爆款选题公式"
- "帮我做对标分析"
- "做成商业版 Word"
- "这个博主是哪一型?"
- "compare these two RED creators"
- "extract the topic formula from this account"
---
## Three-Archetype Classification System
Distilled from real analyses of multiple Xiaohongshu creators.
### Type A — 荒诞美学型 (Absurdist Aesthetics)
**Representative archetype:**
Core pattern: wraps serious or philosophical content in absurdist humor and high-quality visuals.
| Dimension | Characteristics |
|-----------|----------------|
| Content kernel | Absurdist filter on everyday life; philosophical undertone |
| What users get | Entertained + moved + aesthetically immersed |
| Title mechanism | Contrast (grand setting × mundane action) + unified brand symbol |
| Brand symbol | A single recurring tag, e.g. "(劲爆)" |
| Expression style | Humorous, high-production, literary naming |
| Commercial fit | High-end lifestyle, travel, photography, luxury brands |
| Replication difficulty | High — depends on accumulated visual aesthetic sensibility |
**Identifying markers:**
- All content tied together by a signature phrase or visual symbol
- Serious topics expressed lightly; absurd topics expressed earnestly
- Titles feel like literary naming, not just descriptions
---
### Type B — 共鸣命名型 (Resonance & Naming)
**Representative archetype:**
Core pattern: translates personal experience into universally relatable life-stage propositions, named in memorable ways.
| Dimension | Characteristics |
|-----------|----------------|
| Content kernel | Youth growth, worldview expression, life-stage naming |
| What users get | Understood + named + given a new perspective |
| Title mechanism | Proposition feeling + metaphor + judgment |
| Brand symbol | Signature conceptual phrases and naming patterns |
| Expression style | Warm, perceptive, aesthetically refined |
| Commercial fit | Growth/education/creative platforms, mid-range lifestyle |
| Replication difficulty | Medium — method learnable, requires genuine observation |
**Identifying markers:**
- Content "names" vague emotional states people couldn't articulate
- Private experience → universal proposition
- Titles read like thoughtful questions or redefinitions
---
### Type C — 现实策略型 (Reality & Strategy)
**Representative archetype:**
Core pattern: breaks down unspoken real-world rules and provides executable strategies for ordinary people to move upward.
| Dimension | Characteristics |
|-----------|----------------|
| Content kernel | Ordinary woman's upward mobility, reality rule-breaking |
| What users get | Validated + activated + empowered with actionable moves |
| Title mechanism | Conflict words + counter-intuitive framing + strong stance |
| Brand symbol | Self-labeling with perceived weakness (先夺走羞耻感) |
| Expression style | Sharp, realistic, strong-attitude conclusions |
| Commercial fit | Mass consumer, career, e-commerce, practical tools |
| Replication difficulty | Medium — framework learnable; don't copy the surface aggression |
**Identifying markers:**
- Titles feel slightly improper but undeniably accurate
- Each piece breaks an unspoken rule or collapses an information gap
- Users feel "刺但有用" — uncomfortable but useful
---
### Mixed Formula (Advanced)
The most powerful content often combines two archetypes:
> **Type B resonance/naming × Type C reality/strategy**
> = Content that both "understands you" and "tells you what to do next"
Use `scripts/archetype.js` to quickly identify which archetype(s) an account fits.
---
## Core Operating Rules
1. **Use public evidence first.** Prefer search pages, homepage/profile pages, and other publicly visible surfaces.
2. **Do not fake detail-page access.** If post detail pages are blocked by risk control, say so clearly.
3. **Grade evidence quality.** Treat sources in this order:
- Level A: visible Xiaohongshu public pages / user screenshots
- Level B: other public sources (podcasts, interviews, encyclopedias, analytics sites)
- Level C: synthesis / inference based on repeated visible patterns
4. **Do not present public estimates as backend truth.** Third-party follower or pricing data are supporting clues, not audited facts.
5. **Ask for 3–10 representative post links or screenshots** when the user wants deep post-level analysis.
6. **Deliver something useful even when blocked.** If detail pages fail, still produce a homepage-level strategic report.
7. **Always identify the archetype.** Every analysis should name which of the three types (or hybrid) the account belongs to — this frames the entire report.
---
## Default Workflow
### 1) Clarify the task shape
Lock the requested output mode before heavy research:
- Structured account report
- Viral topic formula
- Archetype identification
- Two-or-more-account comparison
- "How to learn from this creator without copying them"
- Business-style Word deliverable
Use `scripts/intake.js` for a fast intake template.
### 2) Gather source inputs
Collect whatever the user already has:
- account name
- Xiaohongshu homepage/search URL
- screenshots
- external links (podcasts, news, interviews)
- target output path
- whether they need Word / clickable TOC / business styling
### 3) Research with the source ladder
Read `references/workflow.md` for the full ladder.
Preferred tool pattern:
- `web_search` for public discovery
- `browser` or `browser-use` for homepage/search-page inspection
- `web_fetch` for readable public pages
- `image` for screenshot analysis
- `read` for any local Markdown reports already created
### 4) Classify the archetype
Before deep analysis, make a preliminary archetype call:
- Run `node scripts/archetype.js <creator-name>` to print the classification prompt
- Answer: Type A (荒诞美学) / Type B (共鸣命名) / Type C (现实策略) / Mixed
- The archetype frames the entire lens of the analysis
### 5) Build the account model
Extract and write down the five layers:
1. **Identity** — who the creator is framed as
2. **Audience contract** — why people follow them
3. **Topic system** — what recurring problems/desires they cover
4. **Expression system** — how titles and openings work, including any brand symbol
5. **Transferability** — what another account can actually learn
### 6) Produce the deliverables
Always output the three standard DOCX files directly — no intermediate Markdown step needed:
| Output file | When to produce |
|-------------|----------------|
| `账号名-结构化总结报告.docx` | Single creator analysis |
| `账号名-爆款选题公式.docx` | Single creator topic formula |
| `选题公式学习-综合版.docx` | Multi-creator comparison |
Use `scripts/docx-plan.js <creator-name>` to get the exact build command.
See `references/workflow.md` for the Word TOC fix checklist if needed.
---
## Deliverables
Three standard output files. Always produce as `.docx` directly.
### 账号名-结构化总结报告.docx
Single creator full breakdown. Sections:
1. Account snapshot
2. Archetype classification + rationale
3. Strategic positioning
4. Audience and emotional contract
5. Content pillars
6. Title / hook system + brand symbol analysis
7. Narrative structure
8. Commercialization clues
9. What to learn
10. What not to blindly copy
11. Conclusion
### 账号名-爆款选题公式.docx
Single creator topic formula. Sections:
1. Why this creator produces strong topics (archetype-based)
2. Total formula
3. 3–6 recurring topic models
4. Title formulas
5. Body structure formulas
6. Distribution / comment triggers
7. How to migrate to another account
8. 10–30 ready-to-use topic directions
### 选题公式学习-综合版.docx
Multi-creator comparison. Sections:
1. Why compare these creators together
2. Archetype map
3. Shared foundations + key differences
4. Hybrid formula
5. Which path fits which use case
6. 30 combined topic directions
7. Final recommendation
---
## Word Output
Run `node scripts/docx-plan.js <creator-name>` to get the exact build command.
Standard build: `python3 build_docx6.py <creator-name>`
If TOC / bookmark issues appear: run `inspect_docx.py` then `fix_final2.py`.
See `references/workflow.md` for the full fix checklist.
---
## Output Files
Three files, always `.docx`:
- `账号名-结构化总结报告.docx`
- `账号名-爆款选题公式.docx`
- `选题公式学习-综合版.docx` (comparison / multi-creator)
Use `scripts/report-plan.js <creator-name>` to confirm filenames.
---
## Scripts
| Command | Purpose |
|--------|---------|
| `node scripts/intake.js` | Print a structured intake template for a new analysis task |
| `node scripts/intake.js --json` | Same as above, JSON format |
| `node scripts/archetype.js <creator-name>` | Print the archetype classification prompt |
| `node scripts/archetype.js <creator-name> --quick` | Print a quick-read archetype cheatsheet |
| `node scripts/report-plan.js <creator-name>` | Print recommended deliverables and filenames |
| `node scripts/report-plan.js <A> <B> --mode compare` | Print a comparison deliverable plan |
| `node scripts/docx-plan.js <creator-name>` | Print Word generation plan using build_docx*.py |
---
## Limitation Policy
Always state which situation applies:
- **Homepage-level only**: enough for strategic analysis, not enough for exact post-body claims
- **Mixed-source analysis**: Xiaohongshu public pages + third-party public clues
- **Deep-dive mode**: based on user-provided screenshots / links / transcripts
If blocked by risk control, say so and continue with the strongest available public evidence.
---
## What Good Output Looks Like
A strong output is:
- structured
- archetype-aware (names and reasons the type)
- evidence-aware (grades sources)
- strategically useful
- honest about access limits
- directly reusable in future reports
Avoid vague praise, empty creator admiration, or unsupported claims about hidden metrics.
---
## References
Read only when needed:
- `references/workflow.md` — detailed evidence ladder, execution runbook, Word TOC fix guide
- `references/templates.md` — report templates, archetype-specific section structures, naming patterns
---
## Proven Analyses Archive
Three full analyses have been completed and archived in:
```
openclaw_cosmo/afa/小红书分析与工作流归档/01-分析报告与选题公式/
```
| Archetype | Type | Key Insight |
|-----------|------|-------------|
| 荒诞美学型 | Type A | 品牌符号 + 反差标题 + 哲思轻量化输出 |
| 共鸣命名型 | Type B | 私人经历→公共命题,给模糊状态命名 |
| 现实策略型 | Type C | 困境→说破→规则→策略→爽感 |
These archetypes are the ground truth for the classification system.
---
*Version: 2.1.0 · Created: 2026-04-08 · Updated: 2026-04-09*
FILE:xhsfenxi/_meta.json
{
"ownerId": "kn79bebfnwg15sb0g7cj5z5nyd83gxh0",
"slug": "xhsfenxi",
"version": "2.1.0",
"publishedAt": null
}
FILE:xhsfenxi/package.json
{
"name": "xhsfenxi",
"version": "2.1.0",
"description": "xhsfenxi — Xiaohongshu creator-account analysis workflow with three-archetype classification (荒诞美学型/共鸣命名型/现实策略型), viral topic formula extraction, comparison studies, and Markdown/Word deliverables.",
"keywords": [
"xiaohongshu",
"RED",
"小红书",
"博主分析",
"账号拆解",
"爆款选题",
"三型博主",
"荒诞美学",
"共鸣命名",
"现实策略",
"topic-formula",
"creator-research",
"content-strategy",
"benchmark-analysis",
"archetype-classification",
"word-report",
"business-report"
],
"author": "cosmofang",
"license": "MIT",
"scripts": {
"intake": "node scripts/intake.js",
"intake:json": "node scripts/intake.js --json",
"plan": "node scripts/report-plan.js",
"archetype": "node scripts/archetype.js",
"archetype:quick": "node scripts/archetype.js --quick",
"docx-plan": "node scripts/docx-plan.js",
"toc-fix": "node scripts/docx-plan.js --toc-fix"
}
}
FILE:xhsfenxi/references/templates.md
# Templates Reference — xhsfenxi v2.0
---
## Output Files
Always produce as `.docx` directly. Three standard files:
| File | When |
|------|------|
| `账号名-结构化总结报告.docx` | Single creator analysis |
| `账号名-爆款选题公式.docx` | Single creator topic formula |
| `选题公式学习-综合版.docx` | Multi-creator comparison |
---
## Structured Report Skeleton (v2 — Archetype-Aware)
```markdown
# 账号名 结构化总结报告
> 说明:本报告基于[数据来源说明]。适合用于[账号定位/内容策略/竞品研究/爆款机制提炼]。
## 一、执行摘要
[2–4段: 博主是谁,核心特征,最值得记住的一句话]
## 二、账号基础信息
[表格: 昵称/小红书号/粉丝/获赞收藏/笔记数/内容类型/账号阶段]
[补充: 全平台历史数据(如有)]
## 三、博主背景(公开资料)
[人物背景、职业起点、创作理念(来源于公开访谈)]
## 四、账号类型判断
**类型:** Type A/B/C 或 混合型
**判断依据:** [2–3条可见信号]
**核心内核:** [一句话总结]
## 五、博主核心内核拆解
[外壳是什么 / 真正的内核是什么 / 人设三层结构 / 最强能力]
## 六、内容结构拆解
[内容支柱分布(表格)/ 内容类型比例 / 统一主题]
## 七、标题机制与爆点分析
[最重要特征 / 品牌符号分析 / 高热内容分析(表格)/ 标题公式归纳]
## 八、与其他博主的横向对比(可选)
[对比表格: 多维度 × 多博主]
## 九、商业化判断
[当前阶段 / 为什么品牌会找他 / 适合的合作方向 / 不适合的方向]
## 十、最值得学习的核心能力
[3–5个能力,每个含: 说明 + 可学习点]
## 十一、后续建议
[路径A: 深入单篇分析 / 路径B: 制作爆款选题公式 / 路径C: 跨博主综合版]
## 十二、最终总结
[1–2段核心结论]
## 附录:笔记完整列表(可选)
[表格: 排名 / 类型 / 标题 / 点赞]
```
---
## Viral Topic Formula Skeleton (v2)
```markdown
# 账号名 爆款选题公式
> 说明:本文件不是复刻[账号名]的表面语气,而是提炼真正有效的选题逻辑、标题机制、传播结构、人设打法与可迁移方法。
## 一、先说结论:为什么能做出爆款?
[账号类型 + 5个叠在一起的核心能力 + 总公式一句话]
## 二、总爆款公式
[5步骤公式 + 压缩版关键词]
## 三、最常见的 3–6 类爆款选题模型
[每个模型: 公式 / 典型表达方向 / 为什么有效 / 可套用句式 / 适合主题]
## 四、标题公式:可以直接拆用
[5–6个标题公式,每个含: 结构说明 + 套用模板]
## 五、内容结构公式
[2–3个结构公式,每个含: 步骤 / 适合做的主题]
## 六、隐形公式 / 最值钱的底层打法
[3–4个]
## 七、如何迁移到自己的内容里
[4个步骤: 找现实母题 / 改写生活故事 / 用冲突词 / 4问检验]
## 八、可直接复用的 20 个选题方向
[编号列表]
## 九、哪些能学,哪些不能硬抄
[可以学的 / 不能硬抄的]
## 十、最终总结
[一句话核心结论]
```
---
## Comparison Skeleton (v2 — Archetype Map)
```markdown
# 选题公式学习(综合版)
## ——结合[博主A]与[博主B](以及[博主C])的内容方法论
> 说明:这份文档不是简单拼接报告,而是把各博主最有效的内容能力抽取出来,重新整理成可学习、可迁移、可直接应用的选题方法。
## 一、为什么把这几位博主放在一起学?
[各博主关键词 / 各博主强项一句话 / 为什么组合更完整]
## 二、账号类型对照表
[表格: 博主 / 类型 / 核心方向 / 用户获得感 / 内容气质 / 标题风格 / 爆点来源]
## 三、共同底层:真正的选题不是题材,而是命题
[5个共同点 / 核心结论]
## 四、双系统(或多系统)选题公式
[系统A(共鸣命名型): 适用场景 / 核心公式 / 压缩版 / 标题类型]
[系统B(现实策略型): 同上]
[(如有)系统C(荒诞美学型): 同上]
## 五、混合公式
[混合公式定义 + 例子 + 为什么更强]
## 六、标题公式综合版
[6–8个跨类型标题公式]
## 七、内容结构综合版
[结构A / B / C,各含步骤和用户获得感]
## 八、可直接复用的 30 个综合选题方向
[A组(偏共鸣命名)/ B组(偏现实策略)/ C组(偏荒诞美学)/ D组(混合)]
## 九、结论与行动建议
[最重要的学习提醒 + 最终公式]
```
---
## Archetype Comparison Table Template
Use this table when comparing 2–3+ accounts:
```markdown
| 维度 | [博主A] | [博主B] | [博主C] |
|---|---|---|---|
| 账号类型 | Type A 荒诞美学 | Type B 共鸣命名 | Type C 现实策略 |
| 核心方向 | | | |
| 用户获得感 | | | |
| 内容气质 | | | |
| 标题风格 | | | |
| 品牌符号 | | | |
| 爆点来源 | | | |
| 商业化方向 | | | |
| 可复制难度 | | | |
```
---
## Evidence Label Reference
Use these labels in your internal working notes (do not need to show all in final report):
| Label | Meaning |
|-------|---------|
| `A1` | Xiaohongshu homepage visible fact |
| `A2` | Xiaohongshu screenshot fact |
| `B1` | Third-party public clue (interview, news, etc.) |
| `C1` | Synthesis / interpretation |
---
## Business Word Checklist
When building a business-style Word file:
- [ ] Cover page
- [ ] TOC with `_TocH1_XXX` bookmarks (internal jump, not external link)
- [ ] Page numbers
- [ ] Conclusion page
- [ ] Restrained typography (no loud decorative color blocks unless requested)
- [ ] Tested in Word: TOC entries jump correctly
- [ ] No duplicate headings accidentally inserted in body
---
## Brand Symbol Analysis Template
For Type A accounts or any account with a unified recurring symbol:
```markdown
### 品牌符号分析
**符号:** [(劲爆)/ 其他]
**出现频率:** [几乎所有 / 大部分 / 部分]
**品牌效果:**
- 识别度: [描述]
- 反差机制: [描述]
- 先发制人效果: [描述]
**可学习点:** [其他账号如何建立自己的品牌符号]
```
FILE:xhsfenxi/references/workflow.md
# Workflow Reference — xhsfenxi v2.0
---
## Source Ladder
Use sources in this order whenever possible:
### Level A — Primary public evidence
Highest trust for visible claims:
- Xiaohongshu search result pages
- creator homepage/profile pages
- user-provided screenshots from Xiaohongshu
- user-provided post links that are actually accessible
What you can safely claim from Level A:
- visible follower / note counts shown on page
- visible account ID / self-introduction
- visible recent titles / visible public engagement numbers
- posting cadence clues visible on page
- obvious content categories
- brand symbols and signature phrases (like "(劲爆)")
### Level B — Secondary public clues
Use as support, never as backend truth:
- podcast interviews
- encyclopedia entries
- public creator bios elsewhere
- analytics websites / public creator databases
- public brand collaboration listings
What Level B is good for:
- filling identity / background context
- confirming repeated themes
- spotting public commercialization clues
- triangulating a bigger narrative
### Level C — Synthesis / inference
Allowed only when clearly framed as interpretation.
Examples:
- "This account behaves more like a Type A 荒诞美学 creator than a Type B 共鸣命名 creator."
- "The recurring topic logic appears to be: contrast × literary naming × unified symbol."
- "The transferable lesson is not the tone, but the framing system."
Never present Level C synthesis as raw observed fact.
---
## Standard Execution Runbook
### Step 1 — Confirm the deliverable
Choose one or more:
- structured report
- viral topic formula
- archetype classification
- comparison report
- customized learning report
- Word / business version
### Step 2 — Capture the minimum task brief
Minimum useful brief:
- creator name
- available links or screenshots
- target output format
- whether a business Word version is required
### Step 3 — Archetype pre-classification
Before deep analysis, make a preliminary archetype call using visible signals:
| Signal | Likely archetype |
|--------|-----------------|
| Unified brand symbol/tag on every post | Type A 荒诞美学型 |
| High-production video, absurdist or philosophical content | Type A 荒诞美学型 |
| Posts name vague emotions or life stages | Type B 共鸣命名型 |
| Titles feel like thoughtful questions or redefinitions | Type B 共鸣命名型 |
| Titles contain conflict words ("骗子", "不要脸", "装") | Type C 现实策略型 |
| Content breaks unspoken workplace/relationship/money rules | Type C 现实策略型 |
| Mix of resonance + strategy | Mixed B+C |
### Step 4 — Research in layers
1. Find the account and public homepage/search data
2. Save visible account facts (follower count, note count, visible titles, engagement)
3. Pull representative visible titles — note brand symbols, title patterns
4. Add external public clues only where helpful
5. Record limitations before interpretation
### Step 5 — Extract the account system (Five Layers)
Always map these five layers:
1. **Identity** — who the creator is framed as; any self-labeling strategy
2. **Audience contract** — why people follow them; what emotional/strategic need is met
3. **Topic system** — what recurring problems/desires they cover; 3–6 topic models
4. **Expression system** — title formulas, brand symbols, opening patterns
5. **Transferability** — what another account can actually learn; what must not be copied
### Step 6 — Produce the right file
#### Structured report
Recommended top-level sections:
1. Account snapshot
2. Archetype classification + rationale
3. Positioning judgment
4. Audience and demand
5. Content pillars
6. Title / hook system (including brand symbol analysis)
7. Narrative structure
8. Commercialization clues
9. What to learn
10. What not to copy
11. Conclusion
12. (Optional) Full note list sorted by engagement
#### Viral topic formula
Recommended top-level sections:
1. Why this creator produces strong topics (archetype-based)
2. Total formula
3. 3–6 recurring topic models
4. Title formulas
5. Body structure formulas
6. Distribution / comment trigger clues
7. How to migrate it to another account
8. 10–30 topic directions
#### Comparison report (2–3+ accounts)
Recommended top-level sections:
1. Why compare these creators together
2. Archetype map (each creator's type)
3. Shared foundations
4. Key differences
5. Hybrid formula
6. Which path fits which use case
7. Suggested topic directions
8. Final recommendation
---
## Archetype-Specific Analysis Lenses
### Type A — 荒诞美学型
Focus questions:
- What is the brand symbol? (unified tag, phrase, or visual marker)
- How does the title create contrast? (grand × mundane, serious × absurd)
- Where does the humor come from? (earnest treatment of absurd things)
- What is the underlying philosophical/emotional core?
- Why is replication hard? (visual sensibility, accumulated aesthetic)
Key learning for other accounts:
- Find your own "brand symbol" — a unifying tag that creates recognition
- Train "contrast naming" — don't describe the place, name the feeling
- Practice "absurdist framing" — pick topics that are ridiculous when taken seriously
---
### Type B — 共鸣命名型
Focus questions:
- What life stages or emotional states does the account name?
- How does private experience become universal proposition?
- What are the signature conceptual phrases?
- What is the "new way to understand yourself" users get?
Key learning for other accounts:
- Practice "命题化" — reframe personal experience as a universal human situation
- Build a personal vocabulary of named states ("Odyssey时期", etc.)
- The formula: experience → proposition → naming → judgment → resonance
---
### Type C — 现实策略型
Focus questions:
- What real-world rules does the account break open?
- How does the self-labeling strategy work? (antifragile identity)
- What is the conflict word in each title?
- What is the "can-do action" users walk away with?
Key learning for other accounts:
- Find your "reality母题" — the real困境 you speak to
- Practice rewriting weak framings into strong rule-breaking framings
- The formula: 困境 → 说破 → 规则 → 策略 → 爽感
---
## Limitation Language
Use direct wording like:
- "This round is based primarily on public homepage/search-page evidence."
- "Single-post detail pages were not stably accessible due to platform risk control."
- "Third-party figures are used as directional clues, not audited backend data."
- "Archetype classification is based on visible patterns; the creator may operate differently at the full content level."
---
## Deep-Dive Upgrade Path
If the user wants finer analysis, ask for 3–10 representative posts, ideally with:
- screenshots of cover + title
- screenshots of body pages / subtitles
- comments screenshots
- transcript or summary if video-based
Then upgrade from account-level to post-level analysis.
---
## Word Output Workflow
### Overview
Always generate Markdown first, then convert to Word. Iterating Markdown is cheaper than re-generating DOCX.
### Script Selection Guide
Use the workflow archive scripts at:
```
openclaw_cosmo/afa/小红书分析与工作流归档/02-Word生成与目录修复脚本/
```
| Script | Use when |
|--------|----------|
| `build_docx6.py` | Default — most stable, use this first |
| `build_docx.py` → `build_docx5.py` | Earlier iterations, kept for reference/debugging |
| `inspect_docx.py` | Inspect internal XML, bookmarks, link structure |
| `fix_final2.py` | Final TOC / attribute fix after build |
| `fix_attrs.py` | Fix XML namespace attribute writing errors |
| `fix_wrong.py` | Targeted corrections for known wrong output |
| `check_it.py` | Verify output correctness |
### Word TOC Fix Checklist
When a generated .docx has broken TOC links:
1. Run `inspect_docx.py` — check `word/document.xml` for bookmark names and link types
2. Verify heading bookmarks use format `_TocH1_XXX` (not `#_TocH1_XXX`)
3. Verify internal links use Word-native anchor style, not external URL rels
4. If `elem.set()` errors appear, use full namespace: `elem.set('{' + W_NS + '}id', value)`
5. Run `fix_final2.py` after any XML edits
6. Test by opening in Word — click TOC entries to verify jump
### Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| Bookmark name wrong format | Missing `_TocH1_` prefix | Rename via `fix_attrs.py` |
| `anchor` contains `#` | Wrong anchor format | Strip `#` prefix |
| TOC uses external link rels | Used `http://` link type for internal jump | Switch to `w:instr` field or anchor |
| Duplicate headings in body | TOC headings accidentally inserted into body | Use `fix_wrong.py` to clean |
| Namespace attribute error | Missing full namespace URI in `elem.set()` | Use `'{' + W_NS + '}id'` form |
### Best Practice
- Keep only `build_docx6.py` as the production script
- Keep all earlier versions as debugging reference, not for production
- One Markdown → one `build_docx6.py` call → inspect → fix if needed
FILE:xhsfenxi/scripts/archetype.js
#!/usr/bin/env node
/**
* xhsfenxi — archetype.js
* Purpose: Print the archetype classification prompt for a Xiaohongshu creator.
* Helps identify whether an account is Type A (荒诞美学), Type B (共鸣命名),
* Type C (现实策略), or a hybrid — based on the three proven archetypes
* distilled from real analyses of multiple Xiaohongshu creators.
* Usage:
* node scripts/archetype.js <creator-name>
* node scripts/archetype.js <creator-name> --quick
*/
const args = process.argv.slice(2);
const quick = args.includes('--quick');
const name = args.filter(a => !a.startsWith('--'))[0];
if (!name) {
console.error('Usage: node scripts/archetype.js <creator-name> [--quick]');
process.exit(1);
}
if (quick) {
console.log(`
=== xhsfenxi archetype cheatsheet ===
TYPE A — 荒诞美学型 (Absurdist Aesthetics)
Example: 荒诞美学博主(vlogger型)
Signals:
- Unified brand symbol/tag on every post (e.g. "(劲爆)")
- Absurdist or philosophical topics treated earnestly
- High-production video; literary title naming
- Serious × absurd contrast in every piece
Formula: 荒诞滤镜下的生活哲学
TYPE B — 共鸣命名型 (Resonance & Naming)
Example: 成长世界观表达博主
Signals:
- Posts name vague emotional states or life stages
- Private experience → universal proposition
- Titles feel like thoughtful questions or redefinitions
Formula: 经历 → 命题 → 命名 → 判断 → 共鸣
TYPE C — 现实策略型 (Reality & Strategy)
Example: 普通女孩上行策略博主
Signals:
- Titles contain conflict words ("骗子", "不要脸", "装")
- Content breaks unspoken workplace/relationship/money rules
- Self-labels with perceived weakness to pre-empt criticism
Formula: 困境 → 说破 → 规则 → 策略 → 爽感
MIXED B+C — Most powerful hybrid
Combines: resonance/naming × reality/strategy
= "You understand me" + "Now I know what to do"
`);
process.exit(0);
}
console.log(`
=== xhsfenxi archetype classification: name ===
TASK
Classify the Xiaohongshu account "name" into one of these three archetypes
(or a hybrid), based on visible public evidence.
─────────────────────────────────────────────
THREE ARCHETYPES
─────────────────────────────────────────────
TYPE A — 荒诞美学型 (Absurdist Aesthetics)
Core: Wraps philosophical/serious content in absurdist humor + high visual quality
Proven example: 荒诞美学vlogger型 (unified brand symbol; grand × mundane contrast; literary naming)
Key signals to look for:
- Does every post share a unified recurring symbol or phrase?
- Do titles contrast a grand/serious setting with a mundane/absurd action?
- Is the visual/production quality noticeably higher than peers?
- Are serious topics treated with humor and humor treated earnestly?
TYPE B — 共鸣命名型 (Resonance & Naming)
Core: Names vague emotions and life stages so users feel "finally, someone said it"
Proven example: 成长世界观表达型 (youth growth, worldview expression, concept naming)
Key signals to look for:
- Do posts give names or definitions to emotional states most people can't articulate?
- Does the account turn personal stories into universal life propositions?
- Do titles feel like thoughtful philosophical questions or redefinitions?
- Is the tone warm, perceptive, aesthetically refined?
TYPE C — 现实策略型 (Reality & Strategy)
Core: Breaks unspoken real-world rules; provides executable strategies for ordinary people
Proven example: 普通女孩上行策略型 (antifragile identity; rule-breaking; 普通人上行)
Key signals to look for:
- Do titles contain conflict words or counter-intuitive framing?
- Does the account self-label with a perceived weakness to pre-empt criticism?
- Does content expose hidden rules in workplace/relationships/money/consumption?
- Does every piece end with a clear, actionable conclusion or stance?
MIXED HYBRID
Some accounts combine two archetypes. Most powerful is B+C:
"I understand your situation (Type B) AND here's what you can actually do (Type C)"
─────────────────────────────────────────────
CLASSIFICATION PROMPT
─────────────────────────────────────────────
Based on publicly visible data for "name", answer:
1. Which archetype does this account most closely match? (A / B / C / Mixed)
2. What are 2–3 concrete visible signals that support this classification?
3. If mixed: what percentage is each type, and does one dominate?
4. What is the single sentence that describes this account's core "filter" on the world?
5. Is there a brand symbol or signature phrase? If yes, what is it and how does it function?
Then proceed with the full analysis using this archetype as the lens.
`);
FILE:xhsfenxi/scripts/docx-plan.js
#!/usr/bin/env node
/**
* xhsfenxi — docx-plan.js
* Purpose: Print the Word generation plan for a completed Xiaohongshu analysis,
* including which build_docx*.py script to use, the full workflow steps,
* and TOC fix guidance from the workflow archive.
* Usage:
* node scripts/docx-plan.js <creator-name>
* node scripts/docx-plan.js <creator-name> --mode comparison
* node scripts/docx-plan.js --toc-fix
*/
const ARCHIVE_BASE = '~/Desktop/cosmocloud/Deeplumen/cosmowork/openclaw_cosmo/afa/小红书分析与工作流归档/02-Word生成与目录修复脚本';
const args = process.argv.slice(2);
const tocFix = args.includes('--toc-fix');
const modeIdx = args.indexOf('--mode');
const mode = modeIdx !== -1 ? args[modeIdx + 1] : 'single';
const name = args.filter((a, i) => {
if (a.startsWith('--')) return false;
if (modeIdx !== -1 && i === modeIdx + 1) return false;
return true;
})[0];
if (tocFix) {
console.log(`
=== xhsfenxi Word TOC Fix Guide ===
Archive scripts location:
ARCHIVE_BASE/
STEP-BY-STEP FIX PROCESS
1. Run inspect_docx.py to diagnose the issue:
python3 ARCHIVE_BASE/inspect_docx.py <your-file.docx>
→ Check: bookmark names, link types, heading structure
2. Verify the following are correct:
- Bookmark names follow format: _TocH1_XXX (NOT #_TocH1_XXX)
- Internal TOC links use Word-native anchor, NOT external http:// rels
- No duplicate headings accidentally inserted in document body
- Namespace attributes written as: elem.set('{' + W_NS + '}id', value)
3. Run the appropriate fix script:
- For attribute/namespace errors: python3 fix_attrs.py <file.docx>
- For final TOC + heading cleanup: python3 fix_final2.py <file.docx>
- For known wrong output patterns: python3 fix_wrong.py <file.docx>
4. Verify with check_it.py:
python3 ARCHIVE_BASE/check_it.py <your-fixed-file.docx>
5. Open in Word and test: click each TOC entry to confirm internal jump works.
COMMON ERROR TABLE
Bookmark format wrong → rename using fix_attrs.py
anchor contains # → strip # prefix in fix_final2.py
TOC uses external rels → switch to w:instr field or Word anchor
Duplicate headings → fix_wrong.py
XML namespace error → use full namespace URI in elem.set()
`);
process.exit(0);
}
if (!name && !tocFix) {
console.error('Usage: node scripts/docx-plan.js <creator-name> [--mode comparison]');
console.error(' node scripts/docx-plan.js --toc-fix');
process.exit(1);
}
const isComparison = mode === 'comparison';
const mdFile = isComparison
? `选题公式学习-综合版.md`
: `name-结构化总结报告.md`;
const docxFile = isComparison
? `选题公式学习-综合版.docx`
: `name-结构化总结报告-商业版.docx`;
console.log(`
=== xhsfenxi Word generation plan: name || 'comparison' ===
Mode: 'single creator'
─────────────────────────────────────────────
STEP 1 — Confirm Markdown is complete
─────────────────────────────────────────────
Make sure this file is finalized:
mdFile
─────────────────────────────────────────────
STEP 2 — Run build_docx6.py (recommended)
─────────────────────────────────────────────
Primary script (most stable):
python3 ARCHIVE_BASE/build_docx6.py mdFile docxFile
If build_docx6.py fails, fall back to earlier versions:
build_docx5.py → build_docx4.py → build_docx3.py
─────────────────────────────────────────────
STEP 3 — Inspect the output
─────────────────────────────────────────────
python3 ARCHIVE_BASE/inspect_docx.py docxFile
Check for:
✓ Headings have _TocH1_XXX bookmarks
✓ TOC links are internal (not external URL rels)
✓ No duplicate heading text in body
✓ Page count and section structure look correct
─────────────────────────────────────────────
STEP 4 — Fix if needed
─────────────────────────────────────────────
Run: node scripts/docx-plan.js --toc-fix
for the full TOC fix checklist.
Most common fix:
python3 ARCHIVE_BASE/fix_final2.py docxFile
─────────────────────────────────────────────
STEP 5 — Final check
─────────────────────────────────────────────
python3 ARCHIVE_BASE/check_it.py docxFile
→ Open in Word, click TOC entries, verify internal jumps work
─────────────────────────────────────────────
EXPECTED OUTPUT FILES
─────────────────────────────────────────────
docxFile
` ${name-结构化总结报告.md (keep as source of truth)\n name-爆款选题公式.md (if also produced)`}
─────────────────────────────────────────────
ARCHIVE LOCATION
─────────────────────────────────────────────
Scripts: ARCHIVE_BASE/
Stable production script: build_docx6.py
Inspection tool: inspect_docx.py
`);
FILE:xhsfenxi/scripts/intake.js
#!/usr/bin/env node
/**
* xhsfenxi — intake.js
* Purpose: Print a structured intake template for a Xiaohongshu creator-analysis task.
* Usage:
* node scripts/intake.js
* node scripts/intake.js --json
*/
const args = process.argv.slice(2);
const asJson = args.includes('--json');
const template = {
task: 'xiaohongshu-creator-analysis',
creatorNames: ['<creator-name>'],
links: ['<homepage-or-search-url>'],
screenshots: ['<optional-local-path-or-url>'],
deliverables: ['structured-report'],
outputDir: '<optional-output-dir>',
needWord: false,
needBusinessVersion: false,
needComparison: false,
notes: 'What exactly should be learned from this creator?'
};
if (asJson) {
console.log(JSON.stringify(template, null, 2));
process.exit(0);
}
console.log(`
=== xhsfenxi intake template ===
Provide or confirm the following before deep research:
- creator name(s)
- homepage/search URL(s)
- screenshots or links for representative posts (optional but helpful)
- requested deliverable(s): structured-report / topic-formula / comparison / business-word
- output directory
- any specific question to answer
JSON template:
JSON.stringify(template, null, 2)
`);
FILE:xhsfenxi/scripts/report-plan.js
#!/usr/bin/env node
/**
* xhsfenxi — report-plan.js
* Purpose: Print recommended deliverables and filenames for a Xiaohongshu analysis request.
* Usage:
* node scripts/report-plan.js <creator-name>
* node scripts/report-plan.js <creator-a> <creator-b> --mode compare
*/
const args = process.argv.slice(2);
const modeIdx = args.indexOf('--mode');
const mode = modeIdx !== -1 ? args[modeIdx + 1] : 'single';
const names = args.filter((a, i) => {
if (a === '--mode') return false;
if (modeIdx !== -1 && i === modeIdx + 1) return false;
return true;
});
if (!names.length) {
console.error('Usage: node scripts/report-plan.js <creator-name>');
console.error(' node scripts/report-plan.js <creator-a> <creator-b> --mode compare');
process.exit(1);
}
if (mode === 'compare') {
if (names.length < 2) {
console.error('Compare mode requires two creator names.');
process.exit(1);
}
const [a, b] = names;
const files = [
`选题公式学习-综合版.docx`
];
console.log(`
=== xhsfenxi report plan ===
Mode: comparison
Creators: a / b
Recommended deliverables:
- comparison report
- hybrid topic-formula study
- optional business Word version
Recommended filenames:
files.map(f => `- ${f`).join('\n')}
`);
process.exit(0);
}
const name = names[0];
const files = [
`name-结构化总结报告.docx`,
`name-爆款选题公式.docx`
];
console.log(`
=== xhsfenxi report plan ===
Mode: single creator
Creator: name
Recommended deliverables:
- structured report
- viral topic formula
- optional business Word version
Recommended filenames:
files.map(f => `- ${f`).join('\n')}
`);
每日早晚全套推送 skill: - 08:00 早间简报(11个模块:早报/科技/财经/天气/运势/历史/菜谱/名言/正念/运动/英语) - 18:00 明日运势(结合八字个性化推算) 支持任意 agent 执行,首次使用可向用户询问开关偏好后自动注册。
---
name: jiajiaoy-morning
description: |
每日早晚全套推送 skill:
- 08:00 早间简报(11个模块:早报/科技/财经/天气/运势/历史/菜谱/名言/正念/运动/英语)
- 18:00 明日运势(结合八字个性化推算)
支持任意 agent 执行,首次使用可向用户询问开关偏好后自动注册。
keywords: 早报, 简报, 晚间推送, 运势, morning, evening, 每日推送, 定时推送
metadata:
openclaw:
runtime:
node: ">=18"
---
# jiajiaoy-morning — 每日早晚全套推送
## ⚠️ 首次使用:安装依赖 skill
本 skill 是**组合 skill**,依赖 11 个子 skill。首次使用前必须先安装依赖,否则脚本会报错。
**安装方法(在 skills 目录下执行):**
```bash
# 进入 skills 目录(jiajiaoy-morning 的上级目录)
cd <skills目录>
# 一键安装所有依赖
clawhub install newstoady
clawhub install dailytech
clawhub install dailyfinance
clawhub install weather-daily
clawhub install yunshi
clawhub install daily-history
clawhub install daily-recipe
clawhub install daily-quote
clawhub install daily-mindful
clawhub install daily-fitness
clawhub install english-daily
```
> 所有依赖均已发布在 clawhub registry,可直接安装。
> 安装后目录结构应为:`skills/newstoady/`、`skills/dailytech/` ... 与 `skills/jiajiaoy-morning/` 并列。
如果缺少依赖,运行 `node scripts/build-prompts.js` 时会自动提示缺少哪些 skill 及安装命令。
---
## 触发场景
| 触发词 | 执行动作 |
|--------|---------|
| "发早报" / "今天早报" / cron 08:00 | 执行早间简报 |
| "发晚报" / "明日运势" / cron 18:00 | 执行晚间运势 |
| "设置早报" / "我想订阅" / 首次接入 | 执行安装向导 |
| "查看我的设置" | 显示当前配置 |
---
## 脚本速查
```
skills/jiajiaoy-morning/scripts/
├── setup.js # 首次安装向导(输出问卷 / 保存配置)
├── build-prompts.js # 早间各模块 prompt 构建器
└── evening-push.js # 晚间明日运势 prompt 生成器
```
---
## 场景一:首次安装(用户没有配置文件)
**Step 1** — 输出问卷,向用户提问:
```bash
node scripts/setup.js
```
**Step 2** — 用户回答后,将答案整理为 JSON,保存配置:
```bash
node scripts/setup.js --save '{"userId":"<id>","name":"<名字>","city":"上海","morningChannel":"telegram","morningTo":"<id>","eveningChannel":"telegram","eveningTo":"<id>","modules":{"news":true,"tech":true,"finance":true,"weather":true,"yunshi":true,"history":true,"recipe":true,"quote":true,"mindful":true,"fitness":true,"english":true,"yunshi_tomorrow":true}}'
```
**Step 3** — 脚本会输出 cron 注册指令,按指令在 openclaw 中添加两个定时任务。
---
## 场景二:早间简报执行(cron 08:00 / 手动触发)
**Step 1** — 构建所有模块 prompt:
```bash
node scripts/build-prompts.js <userId>
```
输出 JSON 数组,每项字段:
- `key` — 模块标识
- `module` — 模块名称
- `emoji` — 图标
- `group` — 分组(1/2/3)
- `prompt` — 待执行的指令(null 表示脚本出错,跳过)
- `searchRequired` — 是否需要 WebSearch
- `error` — 报错信息(仅在失败时)
**Step 2** — 按 group 依次执行每个模块的 prompt,收集结果。
**Step 3** — 按 group 分 3 条消息发送:
| 消息 | 模块 | 说明 |
|------|------|------|
| 消息1(group=1) | 📰早报 + 💻科技 + 💰财经 + 🌤️天气 | 需要 WebSearch |
| 消息2(group=2) | 🔮运势 + 📅历史 + 🍳菜谱 | 部分需搜索 |
| 消息3(group=3) | 💬名言 + 🧘正念 + 💪运动 + 📚英语 | 纯生成 |
每条消息头部格式:
```
🌅 早安 <name>!<年月日 星期X>
```
**错误处理**:某模块 prompt=null 时跳过,其余照常发送。
---
## 场景三:晚间明日运势执行(cron 18:00 / 手动触发)
**Step 1** — 生成明日运势 prompt:
```bash
node scripts/evening-push.js <userId>
```
**Step 2** — 执行输出的 prompt,结合八字推算明日运势。
**Step 3** — 发送结果。
---
## 已注册用户
| userId | 名字 | 城市 | 早间渠道 | 晚间渠道 | 开启模块 |
|--------|------|------|---------|---------|---------|
| 8603011439 | 方靖 | 上海 | Telegram | Telegram | 全部 11 项 + 明日运势 |
---
## 错误处理规则
| 错误 | 处理方式 |
|------|---------|
| 某模块脚本报错 | 跳过该模块,其余正常发送 |
| WebSearch 不可用 | 模块内标注 ⚠️,降级为知识库内容 |
| 消息超 4096 字符 | 按 group 分组已控制,单组不会超限 |
| 超时(>480s) | 已完成的 group 先发送,未完成标注缺失 |
---
## 模块开关说明
用户配置存储于:
```
skills/jiajiaoy-morning/data/users/<userId>.json
```
`modules.morning` 中各 key 设为 `false` 可关闭对应模块;
`modules.evening.yunshi_tomorrow` 设为 `false` 关闭晚间运势。
查看当前配置:
```bash
node scripts/setup.js --show <userId>
```
FILE:_meta.json
{
"ownerId": "kn79bebfnwg15sb0g7cj5z5nyd83gxh0",
"slug": "jiajiaoy-morning",
"version": "1.1.1",
"publishedAt": 1776247200000
}
FILE:data/users/8603011439.json
{
"userId": "8603011439",
"name": "方靖",
"city": "上海",
"language": "zh",
"channels": {
"morning": {
"channel": "telegram",
"to": "8603011439"
},
"evening": {
"channel": "telegram",
"to": "8603011439"
}
},
"modules": {
"morning": {
"news": true,
"tech": true,
"finance": true,
"weather": true,
"yunshi": true,
"history": true,
"recipe": true,
"quote": true,
"mindful": true,
"fitness": true,
"english": true
},
"evening": {
"yunshi_tomorrow": true
}
},
"createdAt": "2026-04-15",
"updatedAt": "2026-04-15"
}
FILE:data/users/template.json
{
"userId": "",
"name": "",
"city": "上海",
"language": "zh",
"channels": {
"morning": { "channel": "telegram", "to": "" },
"evening": { "channel": "telegram", "to": "" }
},
"modules": {
"morning": {
"news": true,
"tech": true,
"finance": true,
"weather": true,
"yunshi": true,
"history": true,
"recipe": true,
"quote": true,
"mindful": true,
"fitness": true,
"english": true
},
"evening": {
"yunshi_tomorrow": true
}
},
"createdAt": "",
"updatedAt": ""
}
FILE:scripts/build-prompts.js
#!/usr/bin/env node
/**
* jiajiaoy-morning — 早间简报 prompt 构建器
*
* 读取用户配置,依次运行各模块脚本,输出 JSON 数组供 agent 执行。
*
* 用法:
* node build-prompts.js <userId> # 输出 JSON
* node build-prompts.js <userId> --cron-message # 输出单条 cron payload 消息
*/
'use strict';
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const USERS_DIR = path.join(__dirname, '../data/users');
const SKILLS_BASE = path.join(__dirname, '../../');
// ── 依赖检查 ──────────────────────────────────────────────────────────────────
const REQUIRED_SKILLS = [
{ slug: 'newstoady', script: 'scripts/morning-push.js' },
{ slug: 'dailytech', script: 'scripts/morning-push.js' },
{ slug: 'dailyfinance',script: 'scripts/morning-push.js' },
{ slug: 'weather-daily',script: 'scripts/morning-push.js' },
{ slug: 'yunshi', script: 'scripts/daily-push.js' },
{ slug: 'daily-history',script: 'scripts/morning-push.js' },
{ slug: 'daily-recipe',script: 'scripts/morning-push.js' },
{ slug: 'daily-quote', script: 'scripts/morning-push.js' },
{ slug: 'daily-mindful',script: 'scripts/morning-push.js' },
{ slug: 'daily-fitness',script: 'scripts/morning-push.js' },
{ slug: 'english-daily',script: 'scripts/daily-push.js' },
];
const missing = REQUIRED_SKILLS.filter(dep =>
!fs.existsSync(path.join(SKILLS_BASE, dep.slug, dep.script))
);
if (missing.length > 0) {
const skillsDir = path.resolve(SKILLS_BASE);
console.error('❌ 缺少依赖 skill,请先安装:\n');
console.error(`cd "skillsDir"\n`);
missing.forEach(dep => {
console.error(`clawhub install dep.slug`);
});
console.error('\n安装完成后重新运行本脚本。');
process.exit(1);
}
function sanitizeId(v) {
if (typeof v !== 'string' || !/^[a-zA-Z0-9_@-]{1,128}$/.test(v)) {
console.error('❌ 无效的 userId'); process.exit(1);
}
return v;
}
function safeUserPath(userId) {
const resolved = path.resolve(USERS_DIR, `userId.json`);
if (!resolved.startsWith(path.resolve(USERS_DIR) + path.sep)) {
console.error('❌ 非法路径'); process.exit(1);
}
return resolved;
}
const args = process.argv.slice(2);
const userId = sanitizeId(args[0] || '8603011439');
const cronMsg = args.includes('--cron-message');
// ── 读取用户配置 ──────────────────────────────────────────────────────────────
let config = null;
const configPath = safeUserPath(userId);
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
}
const mods = config?.modules?.morning || {};
// ── 模块定义(含开关 key) ─────────────────────────────────────────────────────
const ALL_MODULES = [
{
key: 'news',
name: '早报',
emoji: '📰',
script: `SKILLS_BASEnewstoady/scripts/morning-push.js`,
args: ['--lang', 'zh'],
searchRequired: true,
group: 1,
},
{
key: 'tech',
name: '科技新闻',
emoji: '💻',
script: `SKILLS_BASEdailytech/scripts/morning-push.js`,
args: [userId],
searchRequired: true,
group: 1,
},
{
key: 'finance',
name: '财经新闻',
emoji: '💰',
script: `SKILLS_BASEdailyfinance/scripts/morning-push.js`,
args: [userId],
searchRequired: true,
group: 1,
},
{
key: 'weather',
name: '天气',
emoji: '🌤️',
script: `SKILLS_BASEweather-daily/scripts/morning-push.js`,
args: [userId],
searchRequired: true,
group: 1,
},
{
key: 'yunshi',
name: '今日运势',
emoji: '🔮',
script: `SKILLS_BASEyunshi/scripts/daily-push.js`,
args: ['--test', userId],
searchRequired: false,
group: 2,
},
{
key: 'history',
name: '历史上的今天',
emoji: '📅',
script: `SKILLS_BASEdaily-history/scripts/morning-push.js`,
args: [userId],
searchRequired: true,
group: 2,
},
{
key: 'recipe',
name: '今日菜谱',
emoji: '🍳',
script: `SKILLS_BASEdaily-recipe/scripts/morning-push.js`,
args: [userId],
searchRequired: false,
group: 2,
},
{
key: 'quote',
name: '每日名言',
emoji: '💬',
script: `SKILLS_BASEdaily-quote/scripts/morning-push.js`,
args: [userId],
searchRequired: false,
group: 3,
},
{
key: 'mindful',
name: '正念冥想',
emoji: '🧘',
script: `SKILLS_BASEdaily-mindful/scripts/morning-push.js`,
args: [userId],
searchRequired: false,
group: 3,
},
{
key: 'fitness',
name: '每日运动',
emoji: '💪',
script: `SKILLS_BASEdaily-fitness/scripts/morning-push.js`,
args: [userId],
searchRequired: false,
group: 3,
},
{
key: 'english',
name: '每日英语',
emoji: '📚',
script: `SKILLS_BASEenglish-daily/scripts/daily-push.js`,
args: [userId],
searchRequired: false,
group: 3,
},
];
// 如果有用户配置,按配置过滤;没有配置则全开
const MODULES = config
? ALL_MODULES.filter(m => mods[m.key] !== false)
: ALL_MODULES;
// ── 执行脚本 ──────────────────────────────────────────────────────────────────
function runScript(scriptPath, scriptArgs) {
try {
const cmd = `node "scriptPath" scriptArgs.map(a => `"${a"`).join(' ')}`;
const output = execSync(cmd, { timeout: 10000, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
return { ok: true, prompt: output.trim() };
} catch (err) {
const stderr = err.stderr ? err.stderr.toString().trim() : '';
return { ok: false, error: stderr || err.message };
}
}
// ── 执行所有模块 ──────────────────────────────────────────────────────────────
const results = [];
for (const mod of MODULES) {
const res = runScript(mod.script, mod.args);
results.push({
key: mod.key,
module: mod.name,
emoji: mod.emoji,
group: mod.group,
prompt: res.ok ? res.prompt : null,
searchRequired: mod.searchRequired,
error: res.ok ? undefined : res.error,
});
}
// ── 输出模式 ──────────────────────────────────────────────────────────────────
if (cronMsg) {
// --cron-message:输出供 cron payload 使用的 agent 指令
const now = new Date();
const WEEKDAYS = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六'];
const dateStr = `now.getFullYear()年now.getMonth()+1月now.getDate()日 WEEKDAYS[now.getDay()]`;
const name = config?.name || userId;
const groups = { 1: [], 2: [], 3: [] };
for (const r of results) {
if (r.prompt) groups[r.group].push(`r.emoji r.module\nr.prompt`);
else if (r.error) groups[r.group].push(`r.emoji r.module\n⚠️ 加载失败,跳过`);
}
const GROUP_LABELS = {
1: '📡 今日资讯',
2: '🌿 生活情报',
3: '✨ 成长能量',
};
let msgs = [];
for (const [g, items] of Object.entries(groups)) {
if (items.length === 0) continue;
msgs.push(`━━━━━ GROUP_LABELS[g] ━━━━━\nitems.join('\n\n')`);
}
console.log(`请执行每日早间简报(jiajiaoy-morning skill)。
步骤:
1. 运行脚本获取所有模块的执行指令:
\`\`\`bash
node __filename userId
\`\`\`
2. 将 JSON 中每个模块的 prompt 依次执行(searchRequired=true 的需要 WebSearch)
3. 按 group 字段分 3 组,每组汇总后各发送一条消息:
- 消息1(group=1):📰早报 + 💻科技 + 💰财经 + 🌤️天气
- 消息2(group=2):🔮运势 + 📅历史 + 🍳菜谱
- 消息3(group=3):💬名言 + 🧘正念 + 💪运动 + 📚英语
4. 每条消息头部加:🌅 早安 name!dateStr
某个模块报错时跳过,不影响其余正常发送。`);
} else {
// 默认:输出 JSON
process.stdout.write(JSON.stringify(results, null, 2));
}
FILE:scripts/evening-push.js
#!/usr/bin/env node
/**
* jiajiaoy-morning — 晚间明日运势 prompt 生成器
*
* 读取用户的八字档案,生成明日运势的分析指令
*
* 用法:
* node evening-push.js <userId>
*/
'use strict';
const fs = require('fs');
const path = require('path');
const USERS_DIR = path.join(__dirname, '../data/users');
const YUNSHI_PROFILES = path.join(__dirname, '../../yunshi/data/profiles');
function sanitizeId(v) {
if (typeof v !== 'string' || !/^[a-zA-Z0-9_@-]{1,128}$/.test(v)) {
console.error('❌ 无效的 userId'); process.exit(1);
}
return v;
}
function safeUserPath(dir, userId) {
const resolved = path.resolve(dir, `userId.json`);
if (!resolved.startsWith(path.resolve(dir) + path.sep)) {
console.error('❌ 非法路径'); process.exit(1);
}
return resolved;
}
const args = process.argv.slice(2);
if (!args[0]) {
console.error('用法: node evening-push.js <userId>');
process.exit(1);
}
const userId = sanitizeId(args[0]);
// 读取用户配置
const userConfigPath = safeUserPath(USERS_DIR, userId);
if (!fs.existsSync(userConfigPath)) {
console.error(`❌ 用户 userId 未注册。请先运行: node setup.js`);
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(userConfigPath, 'utf8'));
if (!config.modules?.evening?.yunshi_tomorrow) {
console.error(`ℹ️ 用户 userId 未开启晚间运势推送`);
process.exit(0);
}
// 读取用户八字档案(来自 yunshi skill)
let bazi = null;
const yunshiProfilePath = safeUserPath(YUNSHI_PROFILES, userId);
if (fs.existsSync(yunshiProfilePath)) {
const profile = JSON.parse(fs.readFileSync(yunshiProfilePath, 'utf8'));
bazi = profile.bazi || null;
}
// 计算明日日期
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const year = tomorrow.getFullYear();
const month = tomorrow.getMonth() + 1;
const day = tomorrow.getDate();
const WEEKDAYS = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六'];
const weekday = WEEKDAYS[tomorrow.getDay()];
const dateStr = `year年month月day日 weekday`;
const name = config.name || userId;
// 生成 prompt
if (bazi && bazi.year && bazi.month && bazi.day && bazi.hour) {
console.log(`请为 name 推算明天(dateStr)的详细运势。
用户八字:
- 年柱:bazi.year 月柱:bazi.month 日柱:bazi.day 时柱:bazi.hour
- 日主:bazi.dayStem || '乙' 生肖:bazi.zodiac || '虎'
请结合八字、紫微斗数和奇门遁甲,给出明日完整运程:
🔮 **明日运势 · dateStr**
**综合运势**:[★★★★☆ 整体评分和一句话概括]
**四大运势**:
- 💼 事业:[分析+建议]
- 💰 财运:[分析+建议]
- 💕 感情:[分析+建议]
- 🏥 健康:[分析+建议]
**时辰吉凶**:[最佳行事时间2-3个]
**宜忌**:
- 宜:[3项]
- 忌:[3项]
**幸运元素**:颜色 [X] · 数字 [X] · 方位 [X]
**明日提醒**:[一句最重要的个性化建议]`);
} else {
// 没有八字档案,输出通用提示
console.log(`请根据今天(new Date().toLocaleDateString('zh-CN'))的干支历法,推算明天(dateStr)的通用运势。
🔮 **明日运势 · dateStr**
请结合明日的天干地支、奇门遁甲等,给出:
- 综合运势评分(★)
- 事业、财运、感情、健康各方面简析
- 今日宜忌
- 幸运颜色/数字/方位
- 最佳行事时辰
直接输出运势内容,格式简洁清晰。`);
}
FILE:scripts/setup.js
#!/usr/bin/env node
/**
* jiajiaoy-morning — 首次安装向导
*
* 输出一段结构化问卷 prompt,由 agent 向用户提问后
* 将回答传回:node setup.js --save '<json>'
*
* 用法:
* node setup.js # 输出问卷 prompt(供 agent 展示给用户)
* node setup.js --save '<json>' # 保存用户回答并输出 cron 指令
* node setup.js --show <userId> # 查看用户当前配置
*/
'use strict';
const fs = require('fs');
const path = require('path');
const USERS_DIR = path.join(__dirname, '../data/users');
const TEMPLATE = path.join(USERS_DIR, 'template.json');
const SKILLS_BASE = path.join(__dirname, '../../');
// ── 依赖检查(与 build-prompts.js 保持一致)────────────────────────────────────
const REQUIRED_SKILLS = [
'newstoady', 'dailytech', 'dailyfinance', 'weather-daily',
'yunshi', 'daily-history', 'daily-recipe', 'daily-quote',
'daily-mindful', 'daily-fitness', 'english-daily',
];
const missingSkills = REQUIRED_SKILLS.filter(slug =>
!fs.existsSync(path.join(SKILLS_BASE, slug))
);
if (missingSkills.length > 0) {
const skillsDir = path.resolve(SKILLS_BASE);
console.error('❌ 缺少依赖 skill,请先安装:\n');
console.error(`cd "skillsDir"\n`);
missingSkills.forEach(slug => console.error(`clawhub install slug`));
console.error('\n安装完成后重新运行本脚本。');
process.exit(1);
}
function sanitizeId(v) {
if (typeof v !== 'string' || !/^[a-zA-Z0-9_@-]{1,128}$/.test(v)) {
console.error('❌ 无效的 userId'); process.exit(1);
}
return v;
}
function safeUserPath(userId) {
const resolved = path.resolve(USERS_DIR, `userId.json`);
if (!resolved.startsWith(path.resolve(USERS_DIR) + path.sep)) {
console.error('❌ 非法路径'); process.exit(1);
}
return resolved;
}
// ── 问卷 prompt(输出给 agent,让 agent 问用户)──────────────────────────────
function printSetupPrompt() {
console.log(`请按以下步骤帮用户完成 jiajiaoy-morning 早晚简报设置,**逐项询问**,收集完后用 JSON 汇总:
---
**📋 jiajiaoy-morning 设置向导**
请依次询问用户以下问题(可以一次性列出让用户回答):
**基本信息**
1. 你的名字是?(用于问候语)
2. 所在城市?(用于天气,默认:上海)
3. 早间简报接收渠道?(telegram / feishu,默认:telegram)
4. Telegram 用户ID 或 飞书 OpenID?
5. 晚间运势接收渠道?(同早间 / 单独设置,默认同早间)
**早间模块选择(08:00推送)**
6. 以下模块哪些要开启?(默认全开,可以说"全开"或列出不要的)
- 📰 早报(今日新闻)
- 💻 科技新闻
- 💰 财经新闻
- 🌤️ 天气
- 🔮 今日运势
- 📅 历史上的今天
- 🍳 今日菜谱
- 💬 每日名言
- 🧘 正念冥想
- 💪 每日运动
- 📚 每日英语
**晚间模块选择(18:00推送)**
7. 是否开启 🔮 明日运势(18:00推送)?(默认:开启)
---
收集完所有回答后,将答案整理为以下 JSON 格式,然后运行:
\`\`\`bash
node <skills目录>/jiajiaoy-morning/scripts/setup.js --save '<JSON>'
\`\`\`
JSON 格式:
{
"userId": "<telegram_id 或 feishu_openid>",
"name": "<名字>",
"city": "<城市>",
"morningChannel": "telegram 或 feishu",
"morningTo": "<接收ID>",
"eveningChannel": "telegram 或 feishu",
"eveningTo": "<接收ID>",
"modules": {
"news": true, "tech": true, "finance": true, "weather": true,
"yunshi": true, "history": true, "recipe": true, "quote": true,
"mindful": true, "fitness": true, "english": true,
"yunshi_tomorrow": true
}
}`);
}
// ── 保存配置并输出 cron 指令 ────────────────────────────────────────────────
function saveConfig(jsonStr) {
let input;
try {
input = JSON.parse(jsonStr);
} catch (e) {
console.error('❌ JSON 解析失败:', e.message);
process.exit(1);
}
const userId = sanitizeId(input.userId || '');
const template = JSON.parse(fs.readFileSync(TEMPLATE, 'utf8'));
const now = new Date().toISOString().split('T')[0];
const config = {
...template,
userId,
name: input.name || userId,
city: input.city || '上海',
language: input.language || 'zh',
channels: {
morning: { channel: input.morningChannel || 'telegram', to: input.morningTo || userId },
evening: { channel: input.eveningChannel || input.morningChannel || 'telegram', to: input.eveningTo || input.morningTo || userId },
},
modules: {
morning: {
news: input.modules?.news !== false,
tech: input.modules?.tech !== false,
finance: input.modules?.finance !== false,
weather: input.modules?.weather !== false,
yunshi: input.modules?.yunshi !== false,
history: input.modules?.history !== false,
recipe: input.modules?.recipe !== false,
quote: input.modules?.quote !== false,
mindful: input.modules?.mindful !== false,
fitness: input.modules?.fitness !== false,
english: input.modules?.english !== false,
},
evening: {
yunshi_tomorrow: input.modules?.yunshi_tomorrow !== false,
},
},
createdAt: now,
updatedAt: now,
};
fs.mkdirSync(USERS_DIR, { recursive: true });
fs.writeFileSync(safeUserPath(userId), JSON.stringify(config, null, 2));
// 输出 cron 指令供 agent 执行
const SKILL = __dirname;
const morningCh = config.channels.morning.channel;
const morningTo = config.channels.morning.to;
const eveningCh = config.channels.evening.channel;
const eveningTo = config.channels.evening.to;
console.log(`✅ 用户配置已保存:userId(config.name)\n`);
console.log(`📋 请执行以下命令注册定时任务:\n`);
console.log(`# 早间简报 08:00`);
console.log(`openclaw cron add \\`);
console.log(` --name "jiajiaoy-morning-userId" \\`);
console.log(` --agent cosmo \\`);
console.log(` --schedule "0 8 * * *" \\`);
console.log(` --tz "Asia/Shanghai" \\`);
console.log(` --timeout 480 \\`);
console.log(` --channel morningCh \\`);
console.log(` --to morningTo \\`);
console.log(` --message "$(node SKILL/build-prompts.js userId --cron-message)"\n`);
if (config.modules.evening.yunshi_tomorrow) {
console.log(`# 晚间明日运势 18:00`);
console.log(`openclaw cron add \\`);
console.log(` --name "jiajiaoy-evening-yunshi-userId" \\`);
console.log(` --agent cosmo \\`);
console.log(` --schedule "0 18 * * *" \\`);
console.log(` --tz "Asia/Shanghai" \\`);
console.log(` --timeout 120 \\`);
console.log(` --channel eveningCh \\`);
console.log(` --to eveningTo \\`);
console.log(` --message "$(node SKILL/evening-push.js userId)"\n`);
}
console.log(`\n已开启模块:`);
const mods = config.modules.morning;
const labels = { news:'📰早报', tech:'💻科技', finance:'💰财经', weather:'🌤️天气', yunshi:'🔮今日运势', history:'📅历史', recipe:'🍳菜谱', quote:'💬名言', mindful:'🧘正念', fitness:'💪运动', english:'📚英语' };
Object.entries(mods).forEach(([k, v]) => {
console.log(` '✗' labels[k] || k`);
});
if (config.modules.evening.yunshi_tomorrow) {
console.log(` ✓ 🔮明日运势(18:00)`);
}
}
// ── 查看用户配置 ──────────────────────────────────────────────────────────────
function showConfig(userId) {
const f = safeUserPath(sanitizeId(userId));
if (!fs.existsSync(f)) {
console.log(`❌ 用户 userId 未注册。运行 node setup.js 开始设置。`);
process.exit(1);
}
const c = JSON.parse(fs.readFileSync(f, 'utf8'));
console.log(`👤 c.name(c.userId)`);
console.log(`🏙️ 城市:c.city`);
console.log(`📡 早间:c.channels.morning.channel → c.channels.morning.to`);
console.log(`📡 晚间:c.channels.evening.channel → c.channels.evening.to`);
console.log(`\n已开启模块:`);
const labels = { news:'📰早报', tech:'💻科技', finance:'💰财经', weather:'🌤️天气', yunshi:'🔮今日运势', history:'📅历史', recipe:'🍳菜谱', quote:'💬名言', mindful:'🧘正念', fitness:'💪运动', english:'📚英语' };
Object.entries(c.modules.morning).forEach(([k, v]) => {
console.log(` '✗' labels[k] || k`);
});
console.log(` '✗' 🔮明日运势(18:00)`);
}
// ── 入口 ──────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
if (args[0] === '--save' && args[1]) {
saveConfig(args[1]);
} else if (args[0] === '--show' && args[1]) {
showConfig(args[1]);
} else {
printSetupPrompt();
}
每日脑力训练 — 逻辑推理、数学速算、记忆挑战、文字谜题,游戏化精美卡片呈现, 含难度自适应分级和连续打卡追踪。每天一道精选题目锻炼大脑,保持思维敏锐。 Daily brain training: logic puzzles, math speed drills, memory challenges, and w...
---
name: daily-brain
description: |
每日脑力训练 — 逻辑推理、数学速算、记忆挑战、文字谜题,游戏化精美卡片呈现,
含难度自适应分级和连续打卡追踪。每天一道精选题目锻炼大脑,保持思维敏锐。
Daily brain training: logic puzzles, math speed drills, memory challenges, and word games
with adaptive difficulty, streak tracking, and beautiful visual cards.
Trigger on:每日脑力、今日脑力、脑力训练、逻辑题、数学题、记忆挑战、
brain training、daily brain、daily puzzle、brain teaser、logic puzzle、
math challenge、word game、memory game、今天的题、做道题、脑筋急转弯。
keywords:
- daily-brain
- 每日脑力
- 脑力训练
- 逻辑推理
- 数学速算
- 记忆挑战
- 文字谜题
- brain training
- daily puzzle
- brain teaser
- logic puzzle
- math challenge
- word game
- memory game
- 脑筋急转弯
- 思维训练
- 认知训练
- 打卡
- streak
- 难度分级
- adaptive difficulty
- gamification
- 游戏化学习
- 每日挑战
- daily challenge
- cognitive training
- mental fitness
- 智力题
- puzzle of the day
- 益智游戏
- 头脑风暴
requirements:
node: ">=18"
---
# Daily Brain — 每日脑力训练
> 每天一道精选脑力题,逻辑/数学/记忆/文字四类轮换,难度自适应,打卡追踪成就
---
## Purpose & Capability
daily-brain 是一个**每日认知训练技能**,通过游戏化的脑力题目帮助用户保持思维敏锐。
**核心能力:**
| 能力 | 说明 |
|------|------|
| 四类题目轮换 | 逻辑推理、数学速算、记忆挑战、文字谜题,按日期自动轮换 |
| 难度自适应 | Easy/Medium/Hard 三档,根据连续正确率自动调整 |
| 精美视觉卡片 | HTML 格式题目卡片和解析卡片,支持浅色/深色主题 |
| 打卡追踪 | 连续天数、最长纪录、正确率、类别分布统计 |
| 成就系统 | 解锁里程碑徽章(首次答题、连续7天、满分周等) |
| 答案解析 | 每题配详细解题思路和知识扩展 |
**能力边界(不做的事):**
- 不联网获取题目 — 全部题库内置,无需 API
- 不生成实时竞技或多人对战
- 不提供专业心理学/医学认知评估
- 不修改任何系统设置或 shell 配置
---
## Instruction Scope
**在 scope 内(会处理):**
- "今天的脑力题" / "来道逻辑题" / "每日脑力训练"
- "做一道数学速算" / "来个记忆挑战" / "文字谜题"
- "查看我的脑力训练统计" / "连续打卡多少天了"
- "答案是 42" / "我选 B"
- "重置训练进度"
**不在 scope 内(不处理):**
- 专业智力测试或 IQ 评估
- 学校考试题解答
- 竞赛编程题(请用 daily-code 类技能)
- 心理健康咨询
**凭证缺失时的行为:** 本 skill 无需任何凭证,所有功能开箱即用。
---
## Credentials
本 skill **无需任何凭证**。
| 操作 | 凭证 | 范围 |
|------|------|------|
| 生成题目 | 无 | 本地题库读取 |
| 记录进度 | 无 | 本地 JSON 文件读写 |
| 显示统计 | 无 | 本地数据读取 |
**不做的事:**
- 不读取、传输、记录任何凭证或 token
- 不访问网络或外部服务
- 不收集用户个人信息
---
## Persistence & Privilege
**持久化写入的内容:**
| 路径 | 内容 | 触发条件 |
|------|------|---------|
| `data/progress.json` | 用户训练进度(打卡天数、正确率、成就) | 每次提交答案时更新 |
**不写入的路径:**
- 不修改 shell 配置文件
- 不写入 skill 目录以外的任何路径
- 不创建 cron 任务(推送由外部调度)
**权限级别:**
- 以当前用户身份运行,不需要 sudo
- 仅读写 `data/progress.json` 一个文件
- 卸载方法:`rm -rf ~/.claude/skills/daily-brain`
---
## Install Mechanism
### 标准安装
```bash
# 复制到 skills 目录
cp -r daily-brain ~/.claude/skills/daily-brain/
```
### 验证安装
```bash
node ~/.claude/skills/daily-brain/scripts/today.js
# 应输出:今日脑力训练题目 HTML 卡片
```
### 可选:启用每日推送
在 Claude Code 中设置 cron:
```
每天上午 9:03 自动推送今日脑力训练
```
---
## Usage
```bash
# 获取今日题目
node scripts/today.js
# 指定难度
node scripts/today.js --difficulty hard
# 指定类别
node scripts/today.js --category logic
# 提交答案
node scripts/answer.js --answer "B"
# 查看统计
node scripts/stats.js
# 重置进度
node scripts/reset.js --confirm
```
---
*Version: 1.0.0 · Created: 2026-04-12*
FILE:data/progress.json
{
"currentStreak": 0,
"longestStreak": 0,
"totalSolved": 0,
"correctCount": 0,
"currentDifficulty": "easy",
"categoryStats": {
"logic": {
"solved": 0,
"correct": 0
},
"math": {
"solved": 0,
"correct": 0
},
"memory": {
"solved": 0,
"correct": 0
},
"word": {
"solved": 0,
"correct": 0
}
},
"lastPlayedDate": null,
"achievements": [],
"history": []
}
FILE:data/puzzles.json
{
"logic": [
{
"id": "L001", "difficulty": "easy",
"question": "有5顶帽子,3红2黑。A、B、C三人各戴一顶,互相能看到别人的帽子但看不到自己的。A说:'我不知道我戴的是什么颜色。' B听后说:'我也不知道。' C说:'我知道了。' C戴的是什么颜色?",
"options": ["A. 红色", "B. 黑色"],
"answer": "B",
"explanation": "如果A看到B和C都戴黑色,A就知道自己是红色(只有2黑),所以B和C不全是黑。B听到A不知道后,如果B看到C是黑色,那B和C最多一黑,B可能黑可能红,B不确定。但如果C是黑的且B看到C是黑,B知道自己不是黑(否则A能推出),所以B应该知道。B不知道说明C不是黑色... 实际上:A不知道→BC不全黑;B不知道→C不是黑色(否则B知道自己是红)。所以C推出自己是红色。等等让我重新分析:C知道自己的颜色。如果B看到C是红色,B无法确定(自己可红可黑);如果B看到C是黑色,B知道自己是红色(因为BC不全黑)。B不知道→C一定是红色。答案是红色(A)。",
"correctedAnswer": "A",
"knowledge": "逻辑推理中的'消去法':通过排除不可能的情况推导出唯一解。"
},
{
"id": "L002", "difficulty": "easy",
"question": "一个农夫需要把一只狼、一只羊和一棵白菜运过河,但船每次只能带一样东西。如果农夫不在场,狼会吃羊,羊会吃白菜。至少需要几次渡河(单程)?",
"options": ["A. 5次", "B. 7次", "C. 9次"],
"answer": "B",
"explanation": "经典渡河问题:1.带羊过去 2.空船回来 3.带狼过去 4.带羊回来 5.带白菜过去 6.空船回来 7.带羊过去。共7次单程。关键在于羊不能和狼或白菜单独在一起。",
"knowledge": "约束满足问题(CSP):在满足一系列约束条件下寻找可行解。"
},
{
"id": "L003", "difficulty": "medium",
"question": "有100个囚犯和100个箱子,每个箱子里放着1-100中的一个号码(不重复)。每个囚犯可以打开最多50个箱子寻找自己的号码。所有人都找到才算成功。他们可以事先商量策略但开始后不能交流。最优策略下的成功概率大约是多少?",
"options": ["A. 几乎为0", "B. 约31%", "C. 约50%", "D. 约69%"],
"answer": "B",
"explanation": "这是著名的'100囚犯问题'。最优策略是'跟踪链':每个囚犯先打开自己编号的箱子,然后打开箱子里号码对应的箱子,依次类推。这形成一个置换循环。只要所有循环长度≤50就成功。数学上成功概率 = 1 - ln(2) ≈ 30.69%。",
"knowledge": "置换群与循环:随机置换中最长循环超过n/2的概率为ln(2)≈0.693。"
},
{
"id": "L004", "difficulty": "medium",
"question": "在一个小镇上,理发师宣称:'我只给不给自己理发的人理发。' 请问:理发师给自己理发吗?",
"options": ["A. 给", "B. 不给", "C. 这是一个悖论"],
"answer": "C",
"explanation": "这是罗素悖论的通俗版本。如果理发师给自己理发,那他属于'给自己理发的人',按规则他不应该给自己理发。如果他不给自己理发,那他属于'不给自己理发的人',按规则他应该给自己理发。无论哪种情况都矛盾。",
"knowledge": "罗素悖论:集合论中的基础悖论,推动了公理化集合论的发展。"
},
{
"id": "L005", "difficulty": "hard",
"question": "一群人站成一排,每人头上随机放一顶红色或蓝色帽子。从后往前,每人能看到前面所有人的帽子,但看不到自己的。从最后一人开始,每人必须猜自己帽子颜色(只能说'红'或'蓝'),所有人都能听到。最优策略下,至少能保证多少人猜对?(共N人)",
"options": ["A. N/2人", "B. N-1人", "C. 全部N人"],
"answer": "B",
"explanation": "最优策略:最后一人数前面所有红色帽子的数量,如果是奇数说'红',偶数说'蓝'(用奇偶性编码信息)。后面每个人根据已知信息(听到的所有回答+看到的帽子)可以推算出自己的颜色。这样除了最后一人(50%概率猜对),前面N-1人必定猜对。",
"knowledge": "信息论与奇偶校验:用一个bit的信息(奇偶性)可以帮助所有其他人做出正确判断。"
}
],
"math": [
{
"id": "M001", "difficulty": "easy",
"question": "不用计算器,快速算出:25 × 36 = ?",
"options": ["A. 800", "B. 900", "C. 850", "D. 750"],
"answer": "B",
"explanation": "速算技巧:25 × 36 = 25 × 4 × 9 = 100 × 9 = 900。将其中一个数分解,利用25×4=100简化计算。",
"knowledge": "乘法分配律速算:遇到25时,找另一个数中的4因子;遇到125时找8因子。"
},
{
"id": "M002", "difficulty": "easy",
"question": "1+2+3+...+100 = ?",
"options": ["A. 5000", "B. 5050", "C. 5500", "D. 4950"],
"answer": "B",
"explanation": "高斯公式:n(n+1)/2 = 100×101/2 = 5050。据说高斯10岁时在课堂上几秒钟就算出了这个答案。",
"knowledge": "等差数列求和:S = n(a₁+aₙ)/2 = n(n+1)/2(当首项为1,公差为1时)。"
},
{
"id": "M003", "difficulty": "medium",
"question": "一张纸对折42次后的厚度大约是多少?(纸厚0.1mm)",
"options": ["A. 约4.2米", "B. 约44万公里", "C. 约4.4公里", "D. 约440公里"],
"answer": "B",
"explanation": "0.1mm × 2^42 = 0.1mm × 4,398,046,511,104 ≈ 439,805 km。这个距离超过了地球到月球的距离(约38.4万公里)!指数增长的威力令人震惊。",
"knowledge": "指数增长:2^10≈1000,2^42≈4.4×10^12。理解指数增长是理解复利、病毒传播等现象的基础。"
},
{
"id": "M004", "difficulty": "medium",
"question": "如果你每天能变强1%,一年后你会变强多少倍?1.01^365 ≈ ?",
"options": ["A. 约3.7倍", "B. 约10倍", "C. 约37.8倍", "D. 约365倍"],
"answer": "C",
"explanation": "1.01^365 ≈ 37.78。每天进步1%,一年后变强约37.8倍!反过来,每天退步1%:0.99^365 ≈ 0.026,几乎归零。",
"knowledge": "复利效应:微小的日常改进在长期会产生惊人的累积效果。这也是持续学习和习惯养成的数学基础。"
},
{
"id": "M005", "difficulty": "hard",
"question": "在一个圆上随机选3个点,这3个点构成的三角形包含圆心的概率是多少?",
"options": ["A. 1/3", "B. 1/4", "C. 1/2", "D. 2/3"],
"answer": "B",
"explanation": "固定第一个点A,设另外两个点B、C与A的圆心角分别为α和β(0到2π均匀分布)。三角形包含圆心的条件是:存在两个点的圆心角之差超过π。经过积分计算,概率恰好是1/4。",
"knowledge": "几何概率:将随机事件映射到几何区域,用面积比求概率。这是概率论中的经典方法。"
}
],
"memory": [
{
"id": "E001", "difficulty": "easy",
"question": "记忆挑战:请记住以下数字序列(10秒),然后回答问题。\n\n🔢 7 - 3 - 9 - 1 - 5\n\n问题:第3个和第5个数字之和是多少?",
"options": ["A. 12", "B. 14", "C. 10", "D. 8"],
"answer": "B",
"explanation": "序列是 7, 3, 9, 1, 5。第3个数是9,第5个数是5,9+5=14。",
"knowledge": "工作记忆容量:普通人的短期记忆容量约为7±2个项目(Miller定律)。通过分组(chunking)可以扩展。"
},
{
"id": "E002", "difficulty": "easy",
"question": "记忆挑战:以下emoji按什么顺序出现?看5秒后回答。\n\n🌸 🐱 🎵 🌙 🍎 🐱 🌸\n\n问题:🐱 出现了几次?分别在第几个位置?",
"options": ["A. 2次,第2和第6位", "B. 2次,第2和第5位", "C. 1次,第2位", "D. 3次"],
"answer": "A",
"explanation": "序列:🌸(1) 🐱(2) 🎵(3) 🌙(4) 🍎(5) 🐱(6) 🌸(7)。🐱出现2次,在第2和第6位。",
"knowledge": "视觉注意力:在信息流中追踪特定目标的能力。日常中用于阅读校对、代码审查等场景。"
},
{
"id": "E003", "difficulty": "medium",
"question": "记忆宫殿练习:将以下5个物品与位置配对记忆(15秒)。\n\n🚪 门口 → 钥匙🔑\n🛋️ 沙发 → 苹果🍎\n📺 电视 → 信封✉️\n🪟 窗户 → 望远镜🔭\n🛏️ 床上 → 地图🗺️\n\n问题:窗户旁边放的是什么?床上放的是什么?",
"options": ["A. 望远镜和地图", "B. 信封和望远镜", "C. 苹果和地图", "D. 望远镜和信封"],
"answer": "A",
"explanation": "窗户→望远镜🔭(联想:窗户适合用望远镜看风景),床上→地图🗺️(联想:躺在床上规划旅行路线)。",
"knowledge": "记忆宫殿(Method of Loci):古希腊的记忆术,将信息与熟悉的空间位置关联,利用空间记忆增强信息记忆。"
},
{
"id": "E004", "difficulty": "medium",
"question": "倒序记忆:请记住这个单词序列,然后倒着说出来。\n\n太阳 → 月亮 → 星星 → 云朵 → 彩虹 → 闪电\n\n问题:倒数第3个词是什么?",
"options": ["A. 云朵", "B. 彩虹", "C. 星星", "D. 月亮"],
"answer": "A",
"explanation": "原序列:太阳、月亮、星星、云朵、彩虹、闪电。倒序:闪电、彩虹、云朵、星星、月亮、太阳。倒数第3个是云朵。",
"knowledge": "工作记忆操控:不仅要记住信息,还要在脑中对信息进行操作(翻转、排序),这是执行功能的核心。"
},
{
"id": "E005", "difficulty": "hard",
"question": "多维记忆:记住以下矩阵(20秒),然后回答。\n\n| | 周一 | 周三 | 周五 |\n|-----|------|------|------|\n| 早 | 🍞 | 🥚 | 🥛 |\n| 午 | 🍜 | 🍕 | 🍣 |\n| 晚 | 🥩 | 🐟 | 🍲 |\n\n问题:周三晚上吃什么?周五早上和午餐分别是什么?",
"options": ["A. 🐟;🥛和🍣", "B. 🍕;🥛和🍣", "C. 🐟;🥚和🍕", "D. 🐟;🍞和🍜"],
"answer": "A",
"explanation": "周三晚→🐟鱼,周五早→🥛牛奶,周五午→🍣寿司。",
"knowledge": "矩阵记忆:用行列交叉的空间结构组织信息,是高效记忆的进阶技巧。可用于记忆课程表、数据表等。"
}
],
"word": [
{
"id": "W001", "difficulty": "easy",
"question": "成语接龙:从'一心一意'开始,下面哪个成语可以接上?",
"options": ["A. 意气风发", "B. 心花怒放", "C. 一鸣惊人", "D. 义不容辞"],
"answer": "A",
"explanation": "'一心一意'的最后一个字是'意','意气风发'的第一个字是'意',完美衔接。",
"knowledge": "成语接龙训练语言流畅性和词汇储备。每个成语背后都有历史典故,是中华文化的精华。"
},
{
"id": "W002", "difficulty": "easy",
"question": "猜字谜:一口咬掉牛尾巴。(打一个字)",
"options": ["A. 告", "B. 吞", "C. 牧", "D. 吴"],
"answer": "A",
"explanation": "'牛'去掉下面的竖(尾巴),变成上面部分,加上'口'在下面,组成'告'字。",
"knowledge": "汉字拆解:汉字是表意文字,许多字可以拆分为有意义的部件,理解构字法有助于识字和记忆。"
},
{
"id": "W003", "difficulty": "medium",
"question": "英文文字游戏:LISTEN 和 SILENT 有什么特殊关系?以下哪组词也有同样的关系?",
"options": ["A. EARTH - HEART", "B. HELLO - WORLD", "C. NIGHT - LIGHT", "D. WATER - LATER"],
"answer": "A",
"explanation": "LISTEN和SILENT是变位词(Anagram)——使用完全相同的字母,只是顺序不同。EARTH和HEART也是:E-A-R-T-H ↔ H-E-A-R-T,字母完全相同。",
"knowledge": "变位词(Anagram):字母组合完全相同的不同单词。经典例子:ASTRONOMER ↔ MOON STARER。"
},
{
"id": "W004", "difficulty": "medium",
"question": "填字推理:观察规律,? 处应填什么?\n\n日 → 月 → 金 → 水 → 木 → ? → 土",
"options": ["A. 风", "B. 火", "C. 雷", "D. 星"],
"answer": "B",
"explanation": "这是星期的日文/中文天体命名:日(太阳/Sunday)→月(月亮/Monday)→金(金星/...) →水→木→火→土。也对应七曜:日月金水木火土。",
"knowledge": "七曜:源自古巴比伦的天体命名系统,用日月和五大行星命名一周七天,影响了中日韩等多国文化。"
},
{
"id": "W005", "difficulty": "hard",
"question": "语言密码:破解以下加密文字的规律并解码最后一个词。\n\n'DSFBN' = CREAM\n'GMPXFS' = FLOWER\n'IBQQZ' = ?",
"options": ["A. HAPPY", "B. HARRY", "C. PARTY", "D. HANDY"],
"answer": "A",
"explanation": "规律是凯撒密码,每个字母向前移1位:D→C, S→R, F→E, B→A, N→M = CREAM。同理:I→H, B→A, Q→P, Q→P, Z→Y = HAPPY。",
"knowledge": "凯撒密码:最古老的加密方法之一,由罗马皇帝凯撒发明。将字母表整体偏移N位进行加密/解密。是现代密码学的起点。"
}
]
}
FILE:scripts/answer.js
#!/usr/bin/env node
/**
* daily-brain — 提交答案并显示解析
* Usage: node scripts/answer.js --answer "B"
*/
const fs = require('fs');
const path = require('path');
const dataDir = path.join(__dirname, '..', 'data');
const puzzles = JSON.parse(fs.readFileSync(path.join(dataDir, 'puzzles.json'), 'utf8'));
const progress = JSON.parse(fs.readFileSync(path.join(dataDir, 'progress.json'), 'utf8'));
const args = process.argv.slice(2);
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : null; };
const userAnswer = getArg('--answer');
if (!userAnswer) {
console.log('用法: node scripts/answer.js --answer "B"');
process.exit(1);
}
// Determine today's puzzle (same logic as today.js)
const now = new Date();
const today = now.toISOString().split('T')[0];
const dayOfYear = Math.floor((now - new Date(now.getFullYear(), 0, 0)) / 86400000);
const categories = ['logic', 'math', 'memory', 'word'];
const category = categories[dayOfYear % 4];
const difficulty = progress.currentDifficulty || 'easy';
const pool = puzzles[category].filter(p => p.difficulty === difficulty);
const fallbackPool = pool.length > 0 ? pool : puzzles[category];
const puzzleIndex = dayOfYear % fallbackPool.length;
const puzzle = fallbackPool[puzzleIndex];
// Check answer
const correctAnswer = puzzle.correctedAnswer || puzzle.answer;
const isCorrect = userAnswer.toUpperCase().trim() === correctAnswer.toUpperCase().trim();
// Update progress
const isNewDay = progress.lastPlayedDate !== today;
if (isNewDay) {
progress.currentStreak = (progress.lastPlayedDate === new Date(now.getTime() - 86400000).toISOString().split('T')[0])
? progress.currentStreak + 1 : 1;
}
progress.totalSolved++;
if (isCorrect) progress.correctCount++;
progress.categoryStats[category].solved++;
if (isCorrect) progress.categoryStats[category].correct++;
if (progress.currentStreak > progress.longestStreak) progress.longestStreak = progress.currentStreak;
progress.lastPlayedDate = today;
// Adaptive difficulty
const recentCorrectRate = progress.totalSolved > 0 ? progress.correctCount / progress.totalSolved : 0;
if (recentCorrectRate >= 0.8 && progress.totalSolved >= 3) {
if (difficulty === 'easy') progress.currentDifficulty = 'medium';
else if (difficulty === 'medium') progress.currentDifficulty = 'hard';
} else if (recentCorrectRate < 0.4 && progress.totalSolved >= 3) {
if (difficulty === 'hard') progress.currentDifficulty = 'medium';
else if (difficulty === 'medium') progress.currentDifficulty = 'easy';
}
// Check achievements
const newAchievements = [];
if (progress.totalSolved === 1 && !progress.achievements.includes('first_solve')) {
progress.achievements.push('first_solve');
newAchievements.push({ id: 'first_solve', name: '初试锋芒', desc: '完成第一道脑力题' });
}
if (progress.currentStreak >= 7 && !progress.achievements.includes('week_streak')) {
progress.achievements.push('week_streak');
newAchievements.push({ id: 'week_streak', name: '七日不辍', desc: '连续训练7天' });
}
if (progress.currentStreak >= 30 && !progress.achievements.includes('month_streak')) {
progress.achievements.push('month_streak');
newAchievements.push({ id: 'month_streak', name: '月度达人', desc: '连续训练30天' });
}
if (recentCorrectRate >= 1.0 && progress.totalSolved >= 5 && !progress.achievements.includes('perfect_five')) {
progress.achievements.push('perfect_five');
newAchievements.push({ id: 'perfect_five', name: '五连全对', desc: '连续5题全部答对' });
}
// Save history
progress.history.push({ date: today, puzzleId: puzzle.id, category, difficulty, userAnswer, correct: isCorrect });
if (progress.history.length > 100) progress.history = progress.history.slice(-100);
fs.writeFileSync(path.join(dataDir, 'progress.json'), JSON.stringify(progress, null, 2));
const categoryNames = { logic: '逻辑推理', math: '数学速算', memory: '记忆挑战', word: '文字谜题' };
console.log(`=== DAILY BRAIN — 答案解析 ===
日期:today
题目ID:puzzle.id
类别:categoryNames[category]
用户答案:userAnswer
正确答案:correctAnswer
结果:'❌ 回答错误'
📖 解析:
puzzle.explanation
💡 知识扩展:
puzzle.knowledge
📊 当前状态:
- 连续打卡:progress.currentStreak 天
- 最长连续:progress.longestStreak 天
- 总计完成:progress.totalSolved 题
- 总正确率:Math.round(progress.correctCount / progress.totalSolved * 100)%
- 当前难度:progress.currentDifficulty
newAchievements.length > 0 ? '\n🏆 新成就解锁!\n' + newAchievements.map(a => ` 🎖️ ${a.name — a.desc`).join('\n') : ''}
请根据以上信息,生成一张精美的答案解析 HTML 卡片:
1. 顶部大字显示 '❌ 答错了'
2. 中间显示正确答案和详细解析
3. 知识扩展区域用不同底色
4. 底部显示打卡统计和成就
5. 支持浅色/深色主题切换
6. 如有新成就,用动画效果高亮展示
`);
FILE:scripts/reset.js
#!/usr/bin/env node
/**
* daily-brain — 重置进度
* Usage: node scripts/reset.js --confirm
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
if (!args.includes('--confirm')) {
console.log('⚠️ 此操作将清除所有训练进度、打卡记录和成就。');
console.log('确认重置请运行: node scripts/reset.js --confirm');
process.exit(0);
}
const dataDir = path.join(__dirname, '..', 'data');
const initial = {
currentStreak: 0,
longestStreak: 0,
totalSolved: 0,
correctCount: 0,
currentDifficulty: "easy",
categoryStats: {
logic: { solved: 0, correct: 0 },
math: { solved: 0, correct: 0 },
memory: { solved: 0, correct: 0 },
word: { solved: 0, correct: 0 }
},
lastPlayedDate: null,
achievements: [],
history: []
};
fs.writeFileSync(path.join(dataDir, 'progress.json'), JSON.stringify(initial, null, 2));
console.log('✅ 训练进度已重置。所有数据已清零。');
FILE:scripts/stats.js
#!/usr/bin/env node
/**
* daily-brain — 训练统计面板
* Usage: node scripts/stats.js
*/
const fs = require('fs');
const path = require('path');
const dataDir = path.join(__dirname, '..', 'data');
const progress = JSON.parse(fs.readFileSync(path.join(dataDir, 'progress.json'), 'utf8'));
const categoryNames = { logic: '逻辑推理', math: '数学速算', memory: '记忆挑战', word: '文字谜题' };
const achievementDefs = {
first_solve: { name: '初试锋芒', emoji: '🌟', desc: '完成第一道脑力题' },
week_streak: { name: '七日不辍', emoji: '🔥', desc: '连续训练7天' },
month_streak: { name: '月度达人', emoji: '👑', desc: '连续训练30天' },
perfect_five: { name: '五连全对', emoji: '💎', desc: '连续5题全部答对' }
};
const overallRate = progress.totalSolved > 0 ? Math.round(progress.correctCount / progress.totalSolved * 100) : 0;
const catStats = Object.entries(progress.categoryStats).map(([key, val]) => {
const rate = val.solved > 0 ? Math.round(val.correct / val.solved * 100) : 0;
return ` categoryNames[key]:val.solved 题,正确率 rate%`;
}).join('\n');
const earnedAchievements = progress.achievements.map(id => {
const def = achievementDefs[id];
return def ? ` def.emoji def.name — def.desc` : ` 🎖️ id`;
}).join('\n') || ' 暂无成就,继续加油!';
console.log(`=== DAILY BRAIN — 训练统计 ===
请根据以下数据,生成一张精美的统计面板 HTML 卡片:
📊 总览:
- 总计完成:progress.totalSolved 题
- 总正确率:overallRate%
- 连续打卡:progress.currentStreak 天
- 最长连续:progress.longestStreak 天
- 当前难度:progress.currentDifficulty
📈 分类统计:
catStats
🏆 已获成就:
earnedAchievements
🎨 面板设计要求:
1. HTML 格式,支持浅色/深色主题切换 + 字体大小控件
2. 用圆形进度条展示总正确率
3. 用柱状图展示四类题目的完成数量
4. 连续天数用火焰图标强调
5. 成就徽章用金色/银色质感卡片展示
6. 底部显示"今日还未训练"或"今日已完成"状态
7. 配色方案:逻辑=蓝, 数学=绿, 记忆=紫, 文字=橙
上次训练日期:progress.lastPlayedDate || '从未训练'
`);
FILE:scripts/today.js
#!/usr/bin/env node
/**
* daily-brain — 今日脑力训练题目生成器
* Usage: node scripts/today.js [--difficulty easy|medium|hard] [--category logic|math|memory|word]
*/
const fs = require('fs');
const path = require('path');
const dataDir = path.join(__dirname, '..', 'data');
const puzzles = JSON.parse(fs.readFileSync(path.join(dataDir, 'puzzles.json'), 'utf8'));
const progress = JSON.parse(fs.readFileSync(path.join(dataDir, 'progress.json'), 'utf8'));
// Parse args
const args = process.argv.slice(2);
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : null; };
const difficulty = getArg('--difficulty') || progress.currentDifficulty || 'easy';
const categories = ['logic', 'math', 'memory', 'word'];
// Determine today's category (rotate by day-of-year)
const now = new Date();
const dayOfYear = Math.floor((now - new Date(now.getFullYear(), 0, 0)) / 86400000);
const categoryArg = getArg('--category');
const category = categoryArg && categories.includes(categoryArg) ? categoryArg : categories[dayOfYear % 4];
// Select puzzle using date hash for reproducibility
const pool = puzzles[category].filter(p => p.difficulty === difficulty);
const fallbackPool = pool.length > 0 ? pool : puzzles[category];
const puzzleIndex = dayOfYear % fallbackPool.length;
const puzzle = fallbackPool[puzzleIndex];
const categoryNames = { logic: '逻辑推理', math: '数学速算', memory: '记忆挑战', word: '文字谜题' };
const categoryEmojis = { logic: '🧩', math: '🔢', memory: '🧠', word: '📝' };
const difficultyLabels = { easy: '⭐ 入门', medium: '⭐⭐ 进阶', hard: '⭐⭐⭐ 挑战' };
console.log(`=== DAILY BRAIN — 今日脑力训练 ===
日期:now.toISOString().split('T')[0]
题目ID:puzzle.id
类别:categoryEmojis[category] categoryNames[category]
难度:difficultyLabels[difficulty]
请根据以下信息,生成一张精美的脑力训练 HTML 卡片给用户。
📋 题目内容:
puzzle.question
📌 选项:
puzzle.options.join('\n')
🎨 卡片设计要求:
1. 使用 HTML 格式输出,支持浅色/深色主题切换
2. 顶部显示:日期、类别徽章、难度星标
3. 中间为题目区域,字体清晰易读
4. 选项用卡片式布局,每个选项可点击高亮
5. 底部显示打卡信息:连续 progress.currentStreak 天 | 总计 progress.totalSolved 题 | 正确率 0%
6. 配色方案:逻辑=蓝色系, 数学=绿色系, 记忆=紫色系, 文字=橙色系
7. 包含"提交答案"按钮提示
⚠️ 不要在卡片中显示答案!答案将在用户提交后通过 answer.js 揭晓。
📊 用户当前状态:
- 连续打卡:progress.currentStreak 天
- 最长连续:progress.longestStreak 天
- 当前难度:difficulty
- categoryNames[category]正确率:0%
`);
设计风格精准复刻工具 — 通过 Jina Reader / WebFetch / WebSearch 浏览目标网页, 自动提取色彩体系、字体排版、间距系统、布局结构、组件风格,输出可执行的设计规范文档。 支持单页分析、多站对比、风格定义输出(Design Token / CSS Variable / Tailwi...
---
name: cosdesign
description: |
设计风格精准复刻工具 — 通过 Jina Reader / WebFetch / WebSearch 浏览目标网页,
自动提取色彩体系、字体排版、间距系统、布局结构、组件风格,输出可执行的设计规范文档。
支持单页分析、多站对比、风格定义输出(Design Token / CSS Variable / Tailwind Config)。
Precise design replication tool — crawl any website via Jina/WebFetch, extract color palette,
typography, spacing, layout patterns, and output actionable design specifications.
keywords:
- cosdesign
- 设计复刻
- 设计分析
- design-system
- design-token
- color-palette
- typography
- 色彩提取
- 字体分析
- 排版分析
- 布局分析
- web-design
- CSS变量
- Tailwind配置
- 风格定义
- 设计规范
- 网页设计
- UI分析
- 视觉风格
- Jina
- WebFetch
- 设计对比
- component-style
- spacing-system
requirements:
node: ">=18"
binaries:
- name: node
required: true
description: "Node.js runtime >= 18"
metadata:
openclaw:
homepage: "https://github.com/Cosmofang/cosdesign"
author: "Cosmofang"
runtime:
node: ">=18"
env:
- name: JINA_API_KEY
required: false
description: "Jina Reader API key for enhanced crawling. Free tier works without key."
---
# CosDesign — 设计风格精准复刻
> 给我一个 URL,还你一套完整的设计规范
---
## Purpose & Capability
CosDesign 是一个**设计风格逆向工程工具**,能从任意网页 URL 中提取完整的视觉设计体系。
**核心能力:**
| 能力 | 说明 |
|------|------|
| 色彩提取 | 从网页中提取主色、辅色、背景色、文字色、渐变色,输出 HEX/RGB/HSL |
| 字体分析 | 识别 font-family、font-size 层级、font-weight、line-height、letter-spacing |
| 间距系统 | 提取 padding/margin/gap 规律,归纳为 4px/8px 栅格体系 |
| 布局结构 | 分析页面 grid/flex 布局、断点、容器宽度、响应式策略 |
| 组件风格 | 按钮、卡片、导航栏、表单等常见组件的视觉参数 |
| 风格定义输出 | 生成 CSS Variables / Design Tokens JSON / Tailwind Config |
| 多站对比 | 同时分析 2-3 个 URL,输出风格差异对比表 |
| 设计报告 | 生成完整的 HTML 设计规范文档(含色卡、字体样本、间距示意) |
**能力边界(不做的事):**
- 不做 UI 设计或出图(只分析,不创作)
- 不抓取需要登录的页面内容
- 不提取图片资源或下载字体文件
- 不修改目标网站的任何内容
---
## Instruction Scope
**在 scope 内:**
- "分析这个网页的设计风格" / "提取这个 URL 的配色方案"
- "帮我复刻这个网站的设计规范"
- "对比这两个网站的设计风格"
- "提取这个页面的字体排版系统"
- "生成 Tailwind 配置来匹配这个设计"
- "输出 CSS 变量 / Design Token"
**不在 scope 内:**
- "帮我设计一个网页"(CosDesign 分析设计,不创作设计)
- "下载这个网站的图片"(不做资源抓取)
- "修改这个网站的样式"(只读分析,不修改)
- "分析这个 APP 的设计"(仅支持 Web URL,不支持移动端截图分析)
---
## Credentials
| 操作 | 凭证 | 说明 |
|------|------|------|
| 网页抓取(Jina Reader) | `JINA_API_KEY`(可选) | 免费层无需 key;有 key 可提升速率限制 |
| 网页抓取(WebFetch) | 无 | Claude 内置工具,无需凭证 |
| 网页搜索(WebSearch) | 无 | Claude 内置工具,无需凭证 |
**不做的事:**
- 不存储、传输或记录任何 API 凭证
- 不访问需要登录的页面
- 所有分析均为只读操作,不对目标网站产生任何影响
**最小配置:** 完全无需凭证即可使用。`JINA_API_KEY` 仅在需要高频抓取时可选配置。
---
## Persistence & Privilege
| 路径 | 内容 | 触发条件 |
|------|------|---------|
| `data/analysis-history.json` | 历次分析记录(URL + 时间戳 + 摘要) | 每次分析完成后追加 |
| stdout | 设计规范文本 / JSON / HTML | 脚本运行时输出 |
**不写入的路径:**
- 不修改系统配置或 shell 环境
- 不创建 cron 任务
- 不写入用户项目目录(除非用户明确指定输出路径)
**权限级别:**
- 以当前用户身份运行,不需要 sudo
- 仅读取网页公开内容,不做任何写入或修改
**卸载:** 删除 skill 目录即可,无残留配置。
---
## Install Mechanism
### 标准安装
```bash
clawhub install cosdesign
```
### 手动安装
```bash
cp -r /path/to/cosdesign ~/.openclaw/workspace/skills/cosdesign/
```
### 验证安装
```bash
node ~/.openclaw/workspace/skills/cosdesign/scripts/analyze.js https://example.com
# 应输出:该 URL 的设计分析 prompt
```
### 可选配置
```bash
# 如需 Jina 高频抓取(可选)
export JINA_API_KEY=<your-jina-api-key>
```
---
## 使用方法
```bash
# 单页分析 — 提取完整设计规范
node scripts/analyze.js <url>
# 指定分析维度
node scripts/analyze.js <url> --focus color # 仅色彩
node scripts/analyze.js <url> --focus typography # 仅字体
node scripts/analyze.js <url> --focus layout # 仅布局
node scripts/analyze.js <url> --focus components # 仅组件
# 多站对比
node scripts/compare.js <url1> <url2> [url3]
# 输出格式
node scripts/export.js <url> --format css-vars # CSS 变量
node scripts/export.js <url> --format tokens # Design Tokens JSON
node scripts/export.js <url> --format tailwind # Tailwind Config
node scripts/export.js <url> --format html-report # 完整 HTML 报告
# 风格定义
node scripts/define-style.js <url> # 输出风格定义文档
```
---
## 输出示例
### 色彩体系
```
Primary: #1a73e8 (Google Blue)
Secondary: #34a853 (Green)
Background: #ffffff / #f8f9fa
Text: #202124 / #5f6368
Accent: #ea4335 (Red)
Border: #dadce0
```
### 字体排版
```
H1: Google Sans, 36px/44px, 400, #202124
H2: Google Sans, 24px/32px, 400, #202124
Body: Roboto, 14px/22px, 400, #5f6368
Caption: Roboto, 12px/16px, 400, #80868b
```
### Design Tokens (JSON)
```json
{
"color": {
"primary": { "value": "#1a73e8" },
"secondary": { "value": "#34a853" },
"bg-default": { "value": "#ffffff" },
"text-primary": { "value": "#202124" }
},
"font": {
"heading": { "value": "'Google Sans', sans-serif" },
"body": { "value": "'Roboto', sans-serif" }
},
"spacing": {
"xs": { "value": "4px" },
"sm": { "value": "8px" },
"md": { "value": "16px" },
"lg": { "value": "24px" },
"xl": { "value": "32px" }
}
}
```
---
## 📁 文件结构
```
cosdesign/
├── SKILL.md
├── package.json
├── _meta.json
├── .clawhub/origin.json
├── data/
│ └── analysis-history.json
├── references/
│ └── extraction-guide.md
└── scripts/
├── analyze.js # 单页设计分析
├── compare.js # 多站风格对比
├── export.js # 设计规范导出(CSS/Token/Tailwind/HTML)
└── define-style.js # 风格定义文档生成
```
---
*Version: 1.0.0 · Created: 2026-04-10 · Author: Cosmofang*
FILE:_meta.json
{
"ownerId": "kn79bebfnwg15sb0g7cj5z5nyd83gxh0",
"slug": "cosdesign",
"version": "1.0.0",
"publishedAt": null
}
FILE:data/analysis-history.json
[]
FILE:package.json
{
"name": "cosdesign",
"version": "1.0.0",
"description": "设计风格精准复刻工具 — 从 URL 提取色彩、字体、间距、布局,输出 Design Token / CSS / Tailwind 规范",
"homepage": "https://github.com/Cosmofang/cosdesign",
"keywords": [
"cosdesign", "design-system", "design-token", "color-palette", "typography",
"设计复刻", "设计分析", "CSS变量", "Tailwind配置", "web-design"
],
"author": "Cosmofang",
"license": "MIT",
"scripts": {
"analyze": "node scripts/analyze.js",
"compare": "node scripts/compare.js",
"export": "node scripts/export.js",
"define-style": "node scripts/define-style.js"
}
}
FILE:scripts/analyze.js
#!/usr/bin/env node
/**
* CosDesign — Single Page Design Analysis
* PROMPT GENERATOR ONLY — outputs a structured agent prompt.
*
* Usage:
* node scripts/analyze.js <url>
* node scripts/analyze.js <url> --focus color
* node scripts/analyze.js <url> --focus typography
* node scripts/analyze.js <url> --focus layout
* node scripts/analyze.js <url> --focus components
* node scripts/analyze.js <url> --focus all (default)
*/
const args = process.argv.slice(2);
const focusIdx = args.indexOf('--focus');
const focus = focusIdx !== -1 ? args[focusIdx + 1] : 'all';
const url = args.filter(a => !a.startsWith('--') && a !== focus)[0];
if (!url) {
console.error('Usage: node scripts/analyze.js <url> [--focus color|typography|layout|components|all]');
process.exit(1);
}
const FOCUS_SECTIONS = {
color: `
COLOR ANALYSIS:
1. Use WebFetch to load the URL: url
2. Extract ALL colors from the page:
- Primary brand color (buttons, links, CTA)
- Secondary colors (accents, highlights)
- Background colors (page bg, card bg, section bg)
- Text colors (heading, body, caption, muted)
- Border/divider colors
- Gradient definitions (if any)
- Semantic colors (success, warning, error, info)
3. Output as structured palette with:
- HEX value
- RGB value
- HSL value
- Usage context (where on page)
- CSS variable name suggestion (e.g. --color-primary)`,
typography: `
TYPOGRAPHY ANALYSIS:
1. Use WebFetch to load the URL: url
2. Extract the complete type system:
- Font families used (heading, body, mono, display)
- Font size scale (list every distinct size from largest to smallest)
- Font weight scale (thin→black, which weights are used where)
- Line height for each size
- Letter spacing (tracking) values
- Text transform usage (uppercase headings, etc.)
3. Map to a type scale:
- Display / H1 / H2 / H3 / H4 / Body / Small / Caption / Overline
- For each: font-family, size, weight, line-height, letter-spacing, color
4. Note any Google Fonts or @font-face declarations`,
layout: `
LAYOUT ANALYSIS:
1. Use WebFetch to load the URL: url
2. Extract the layout system:
- Page max-width / container width
- Grid system (columns, gutter, margin)
- Flexbox usage patterns
- Spacing scale (padding/margin values — find the base unit: 4px? 8px?)
- Section padding (vertical rhythm between sections)
- Responsive breakpoints (if visible from meta viewport or media queries)
- Header height, footer height
- Sidebar width (if applicable)
3. Output a spacing token scale:
- xs / sm / md / lg / xl / 2xl → pixel values
4. Output grid specification:
- columns, gutter, margin at each breakpoint`,
components: `
COMPONENT STYLE ANALYSIS:
1. Use WebFetch to load the URL: url
2. Identify and extract visual parameters for each common component:
BUTTONS:
- Primary / Secondary / Ghost / Outline variants
- padding, border-radius, font-size, font-weight
- hover/active state changes (color shift, shadow)
- Icon button size
CARDS:
- border-radius, shadow, padding, background
- hover state (lift? border change?)
NAVIGATION:
- Height, background, text style, active indicator style
- Mobile nav pattern (hamburger? slide-out?)
FORMS:
- Input height, padding, border-radius, border-color
- Focus ring style, placeholder color
- Label style, error state style
OTHER:
- Badge / Tag / Chip styles
- Avatar sizes
- Tooltip / Popover styles
- Divider / Border patterns`,
};
const focusSections = focus === 'all'
? Object.values(FOCUS_SECTIONS).join('\n')
: FOCUS_SECTIONS[focus] || FOCUS_SECTIONS.color;
console.log(`=== COSDESIGN — 设计分析 ===
目标 URL:url
分析维度:focus
你是一个专业的设计系统分析师。你的任务是从目标 URL 中精准提取视觉设计规范。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第一步 — 获取页面内容
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
使用 WebFetch 获取页面:
URL: url
Prompt: "Extract all CSS styles, colors, fonts, spacing, and layout information from this page. Include inline styles, stylesheet links, and computed visual properties."
如果 WebFetch 失败,尝试 Jina Reader:
URL: https://r.jina.ai/url
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第二步 — 设计分析
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
focusSections
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第三步 — 输出格式
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
将分析结果按以下格式输出:
## 🎨 url — 设计规范
### 色彩体系
| 名称 | HEX | 用途 | CSS变量 |
|------|-----|------|---------|
| Primary | #xxx | 按钮/链接 | --color-primary |
...
### 字体排版
| 层级 | 字体 | 大小 | 行高 | 字重 | 颜色 |
|------|------|------|------|------|------|
| H1 | ... | ... | ... | ... | ... |
...
### 间距系统
| Token | 值 | 用途 |
|-------|-----|------|
| --space-xs | 4px | 内边距最小值 |
...
### 布局
- 容器宽度:...
- 栅格:...
- 断点:...
### 组件风格
- 按钮:...
- 卡片:...
### 设计总结
用 3-5 句话概括这个网站的视觉风格(如:极简主义、圆角风、高对比度等)。
请确保所有数值精确到像素/具体值,不要使用模糊描述。
`);
FILE:scripts/compare.js
#!/usr/bin/env node
/**
* CosDesign — Multi-site Design Comparison
* PROMPT GENERATOR ONLY — outputs a structured agent prompt.
*
* Usage:
* node scripts/compare.js <url1> <url2> [url3]
*/
const urls = process.argv.slice(2).filter(a => !a.startsWith('--'));
if (urls.length < 2) {
console.error('Usage: node scripts/compare.js <url1> <url2> [url3]');
console.error(' At least 2 URLs required for comparison.');
process.exit(1);
}
const urlList = urls.map((u, i) => ` Site i + 1: u`).join('\n');
const siteLabels = urls.map((u, i) => {
try { return new URL(u).hostname.replace('www.', ''); } catch { return `Sitei+1`; }
});
console.log(`=== COSDESIGN — 多站设计风格对比 ===
对比站点:
urlList
你是一个专业的设计系统分析师。你的任务是对比 urls.length 个网站的视觉设计风格。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第一步 — 依次获取每个页面
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
对每个 URL,使用 WebFetch 获取页面内容,提取 CSS 样式信息:
urls.map((u, i) => `
Site ${i+1: u
WebFetch prompt: "Extract all visual design properties: colors, fonts, spacing, layout, border-radius, shadows from this page."`).join('\n')}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第二步 — 逐维度对比
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
对每个维度,列出各站点的值并标注差异:
1. 色彩对比
| 色彩角色 | siteLabels.join(' | ') |
|----------|siteLabels.map(() => '---').join('|')|
| Primary | ... |
| Secondary | ... |
| Background | ... |
| Text | ... |
| Accent | ... |
2. 字体对比
| 项目 | siteLabels.join(' | ') |
|------|siteLabels.map(() => '---').join('|')|
| Heading font | ... |
| Body font | ... |
| Base size | ... |
| Scale ratio | ... |
3. 间距对比
| Token | siteLabels.join(' | ') |
|-------|siteLabels.map(() => '---').join('|')|
| Base unit | ... |
| Container width | ... |
| Section padding | ... |
4. 组件风格对比
| 组件 | siteLabels.join(' | ') |
|------|siteLabels.map(() => '---').join('|')|
| Button border-radius | ... |
| Card shadow | ... |
| Input style | ... |
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第三步 — 风格总结
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
为每个站点写一段 2-3 句的风格定义,然后输出:
### 共同点
- 列出 2-3 个所有站点共享的设计特征
### 差异点
- 列出最显著的 3-5 个风格差异
### 推荐
如果要融合这些风格,建议采用哪些元素?给出具体参数。
`);
FILE:scripts/define-style.js
#!/usr/bin/env node
/**
* CosDesign — Style Definition Document Generator
* PROMPT GENERATOR ONLY — outputs a structured agent prompt.
*
* Generates a human-readable design style definition document
* that describes the visual identity in natural language + tokens.
*
* Usage:
* node scripts/define-style.js <url>
* node scripts/define-style.js <url> --name "ProjectName"
*/
const args = process.argv.slice(2);
const nameIdx = args.indexOf('--name');
const projectName = nameIdx !== -1 ? args[nameIdx + 1] : null;
const url = args.filter(a => !a.startsWith('--') && a !== projectName)[0];
if (!url) {
console.error('Usage: node scripts/define-style.js <url> [--name "ProjectName"]');
process.exit(1);
}
let hostname;
try { hostname = new URL(url).hostname.replace('www.', ''); } catch { hostname = url; }
const name = projectName || hostname;
console.log(`=== COSDESIGN — 风格定义文档 ===
目标 URL:url
项目名称:name
你是一个资深设计总监。你的任务是从目标 URL 中提炼出完整的视觉风格定义文档,
这份文档将作为团队设计和开发的"设计宪法"。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第一步 — 获取页面
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
使用 WebFetch 获取 url,提取完整的视觉设计信息。
补充使用 WebSearch 搜索该品牌的设计语言相关信息(如有公开 Brand Guide)。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第二步 — 输出风格定义文档
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# name — 设计风格定义
## 1. 视觉性格
用 3-5 个关键词定义这个设计的视觉性格(如:极简 / 圆润 / 高对比 / 科技感 / 温暖),
并用 2-3 句话展开说明为什么选择这些词。
## 2. 色彩哲学
- 主色调的情感含义和使用场景
- 色彩对比度策略(高对比?柔和?)
- 深色/浅色模式策略
- 色彩语义映射(成功/警告/错误/信息)
附:完整色卡表格(名称 | HEX | 用途 | CSS变量)
## 3. 字体性格
- 字体选择的理由(几何无衬线?人文衬线?等宽?)
- 标题与正文的视觉反差策略
- 字号阶梯的数学逻辑(1.25倍?1.333倍?)
- 字重使用规则
附:完整字体规格表
## 4. 空间节奏
- 基础单位(4px grid? 8px grid?)
- 元素之间的呼吸感如何营造
- 信息密度定位(紧凑?宽松?适中?)
- 垂直韵律(section 间距规律)
附:间距 token 表
## 5. 形状语言
- 圆角策略(全圆角?微圆角?直角?混合?)
- 阴影策略(扁平?微阴影?深层次?)
- 边框策略(visible borders? borderless? 分隔线?)
附:圆角 + 阴影 token 表
## 6. 组件设计原则
对以下核心组件,各用 1-2 句话描述设计原则:
- 按钮:...
- 卡片:...
- 导航:...
- 表单:...
- 列表:...
## 7. 动效原则(如有)
- 过渡时长偏好
- 缓动曲线偏好
- 动画风格(克制?活泼?功能性?)
## 8. 一句话设计宣言
用一句话总结这个设计风格的核心理念。
例如:"以留白换呼吸,以极简传专业。"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
重要提醒
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- 所有描述必须基于页面实际分析,不是泛泛的设计理论
- 色彩、字体、间距的具体数值必须从页面中提取
- 风格定义要有观点和立场,不要写成中庸的模板
- 这份文档的目标读者是设计师和前端工程师
`);
FILE:scripts/export.js
#!/usr/bin/env node
/**
* CosDesign — Design Specification Export
* PROMPT GENERATOR ONLY — outputs a structured agent prompt.
*
* Usage:
* node scripts/export.js <url> --format css-vars
* node scripts/export.js <url> --format tokens
* node scripts/export.js <url> --format tailwind
* node scripts/export.js <url> --format html-report
*/
const args = process.argv.slice(2);
const fmtIdx = args.indexOf('--format');
const format = fmtIdx !== -1 ? args[fmtIdx + 1] : 'css-vars';
const url = args.filter(a => !a.startsWith('--') && a !== format)[0];
if (!url) {
console.error('Usage: node scripts/export.js <url> --format css-vars|tokens|tailwind|html-report');
process.exit(1);
}
const FORMAT_TEMPLATES = {
'css-vars': `
输出格式:CSS Custom Properties
将分析结果输出为一个完整的 CSS :root {} 块:
:root {
/* Colors */
--color-primary: #xxx;
--color-secondary: #xxx;
--color-bg: #xxx;
--color-bg-secondary: #xxx;
--color-text: #xxx;
--color-text-muted: #xxx;
--color-border: #xxx;
--color-accent: #xxx;
/* Typography */
--font-heading: 'xxx', sans-serif;
--font-body: 'xxx', sans-serif;
--font-mono: 'xxx', monospace;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
--text-xl: 20px;
--text-2xl: 24px;
--text-3xl: 30px;
--text-4xl: 36px;
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-16: 64px;
/* Layout */
--container-max: 1200px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.1);
}
确保每个值都来自实际页面分析,不要使用默认值。`,
'tokens': `
输出格式:Design Tokens (JSON)
将分析结果输出为 W3C Design Token 格式的 JSON:
{
"color": {
"primary": { "value": "#xxx", "type": "color" },
"secondary": { "value": "#xxx", "type": "color" },
...
},
"font": {
"family": {
"heading": { "value": "'xxx', sans-serif", "type": "fontFamily" },
"body": { "value": "'xxx', sans-serif", "type": "fontFamily" }
},
"size": {
"xs": { "value": "12px", "type": "dimension" },
"sm": { "value": "14px", "type": "dimension" },
...
},
"weight": {
"regular": { "value": "400", "type": "fontWeight" },
"medium": { "value": "500", "type": "fontWeight" },
"bold": { "value": "700", "type": "fontWeight" }
},
"lineHeight": {
"tight": { "value": "1.25", "type": "number" },
"normal": { "value": "1.5", "type": "number" },
"relaxed": { "value": "1.75", "type": "number" }
}
},
"spacing": { ... },
"borderRadius": { ... },
"shadow": { ... }
}`,
'tailwind': `
输出格式:Tailwind CSS Config
将分析结果输出为 tailwind.config.js 的 theme.extend 对象:
/** @type {import('tailwindcss').Config} */
export default {
theme: {
extend: {
colors: {
primary: '#xxx',
secondary: '#xxx',
accent: '#xxx',
background: { DEFAULT: '#xxx', secondary: '#xxx' },
foreground: { DEFAULT: '#xxx', muted: '#xxx' },
border: '#xxx',
},
fontFamily: {
heading: ['xxx', 'sans-serif'],
body: ['xxx', 'sans-serif'],
},
fontSize: {
// 从页面提取的实际 type scale
},
spacing: {
// 从页面提取的实际 spacing scale
},
borderRadius: {
// 从页面提取的实际 radius values
},
boxShadow: {
// 从页面提取的实际 shadow values
},
},
},
}`,
'html-report': `
输出格式:完整 HTML 设计报告
生成一个自包含的 HTML 文件,包含:
1. 页面顶部:站点名称 + URL + 分析日期
2. 色卡区域:每个颜色一个矩形色块 + HEX + 名称
3. 字体样本:每个字号层级的实际渲染效果
4. 间距可视化:用灰色方块展示间距比例
5. 组件示例:按钮/卡片/输入框的 CSS 代码
6. 底部:Design Tokens JSON 的可复制代码块
HTML 页面必须:
- 使用从目标网站提取的实际字体和颜色
- 包含深色/浅色主题切换按钮
- 包含字体大小调节控件
- 默认浅色主题
- 自适应响应式布局
- 所有样式内联,不依赖外部资源`,
};
const formatTemplate = FORMAT_TEMPLATES[format] || FORMAT_TEMPLATES['css-vars'];
console.log(`=== COSDESIGN — 设计规范导出 ===
目标 URL:url
输出格式:format
你是一个专业的设计系统工程师。从目标 URL 提取视觉参数并转换为可执行的设计规范。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第一步 — 获取页面
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
使用 WebFetch 获取 url
提取所有 CSS 属性:颜色、字体、间距、阴影、圆角、布局。
如果 WebFetch 返回内容不足,补充使用:
WebFetch URL: https://r.jina.ai/url
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第二步 — 提取设计参数
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
完整提取以下维度:
- 色彩体系(所有 HEX 值 + 用途)
- 字体排版(family, size scale, weight, line-height)
- 间距系统(base unit, scale, padding/margin 规律)
- 圆角(border-radius 值集合)
- 阴影(box-shadow 值集合)
- 布局(container width, grid, breakpoints)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第三步 — 按指定格式输出
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
formatTemplate
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
重要提醒
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- 所有值必须来自实际页面分析,不得使用预设值
- 色彩值精确到 HEX 6 位
- 字号精确到 px
- 间距精确到 px
- 如果页面使用 rem,按 1rem=16px 换算后同时标注两种单位
`);
Bot 身份认证标准 — 为 AI Agent 和机器人签发加密身份证书,让网站信任你的 bot。 遵循 RFC 9421 HTTP Message Signatures 国际标准,与 Cloudflare Web Bot Auth 生态兼容。 内置 Ed25519 签名注册中心、JWKS 公钥目录、nonce...
---
name: robot-id-card
description: |
Bot 身份认证标准 — 为 AI Agent 和机器人签发加密身份证书,让网站信任你的 bot。
遵循 RFC 9421 HTTP Message Signatures 国际标准,与 Cloudflare Web Bot Auth 生态兼容。
内置 Ed25519 签名注册中心、JWKS 公钥目录、nonce 防重放、CLI 工具、浏览器扩展和网站 SDK,
支持分级权限控制(0-5级)、每日签到信誉积累、公开审计日志。
Universal identity standard for AI bots — RFC 9421 aligned, Web Bot Auth compatible,
cryptographically signed certificates, public audit registry, permission-based access control.
keywords:
- robot-id-card
- bot-identity
- bot-passport
- bot-certificate
- ai-agent
- ed25519
- cryptographic-identity
- bot-verification
- web-security
- browser-extension
- sdk
- registry
- bot-accountability
- permission-system
- audit-trail
- bot-grade
- ai-safety
- 机器人身份
- bot认证
- AI安全
requirements:
node: ">=18"
binaries:
- name: node
required: true
description: "Node.js runtime >= 18"
- name: npm
required: true
description: "npm package manager"
metadata:
openclaw:
homepage: "https://github.com/Cosmofang/robot-id-card"
author: "Cosmofang"
runtime:
node: ">=18"
env: []
---
# Robot ID Card — Bot 身份认证标准
> Give your bot a passport. Let websites trust it.
---
## Purpose & Capability
Robot ID Card (RIC) 是一个 **Bot 身份认证标准**,解决互联网无法区分好 bot 和坏 bot 的问题。
**核心能力:**
| 能力 | 说明 |
|------|------|
| 加密身份证书 | Ed25519 签名,每个 bot 获得唯一 `ric_` ID |
| 公开注册中心 | SQLite 持久化,REST API,Fastify 驱动 |
| 分级权限系统 | Level 0-5,网站根据 bot 等级授予不同权限 |
| 每日签到信誉 | 连续 3 天 `ric claim` 即可从 unknown 升级到 healthy |
| 自动降级 | 3 次举报 → 自动标记 dangerous → Level 0 封锁 |
| CLI 工具 | `ric keygen / register / claim / status / verify / report` |
| 浏览器扩展 | Manifest V3,自动注入 RIC 请求头 |
| 网站 SDK | Express + Fastify 中间件,一行代码集成 |
**能力边界(不做的事):**
- 不替代 OAuth/JWT 用户认证(RIC 是 bot 身份,不是用户身份)
- 不提供 WAF 或 DDoS 防护
- 不做内容审核或行为监控
---
## Instruction Scope
**在 scope 内:**
- "帮我注册一个 bot 身份" / "给我的 bot 签发证书"
- "验证这个 bot 是否可信" / "查看 bot 等级"
- "在我的网站集成 RIC 验证"
- "启动本地 registry 服务器"
- "我的 bot 怎么从 unknown 升到 healthy"
**不在 scope 内:**
- 用户账号认证(那是 OAuth/Passport.js 的工作)
- 反爬虫策略设计(RIC 是身份系统,不是防火墙)
- 区块链/去中心化身份(v1.0 路线图功能,当前未实现)
---
## Credentials
本 skill 无需任何 API token 或密钥即可运行。
| 操作 | 凭证 | 说明 |
|------|------|------|
| 启动 registry | 无 | `npm run dev:registry` 直接启动 |
| 生成密钥对 | 无 | `ric keygen` 在本地生成 Ed25519 密钥 |
| 注册 bot | Bot 私钥 | 用户自己生成的 `*.key.json`,存在本地 |
| 部署到 Render | `RIC_ADMIN_KEY` | 可选,Render 自动生成,用于管理员操作 |
**不做的事:**
- 不读取、传输或记录任何第三方 API 凭证
- 不访问 GitHub/clawHub token
- Bot 私钥文件仅存在用户本地,不上传到 registry
---
## Persistence & Privilege
| 路径 | 内容 | 触发条件 |
|------|------|---------|
| `packages/registry/data/registry.db` | SQLite 数据库(bot 记录、审计日志、签到) | registry 启动时自动创建 |
| `*.key.json`(用户指定路径) | Ed25519 密钥对 | `ric keygen` 时生成 |
| `dist/`(各包) | TypeScript 编译输出 | `npm run build` |
**不写入的路径:**
- 不修改系统配置或 shell 环境
- 不创建 cron 任务
- 不写入 `node_modules/` 以外的全局路径
**权限级别:**
- 以当前用户身份运行,不需要 sudo
- registry 监听 localhost:3000(可配置 PORT 环境变量)
- 完整卸载:删除项目目录即可
---
## Install Mechanism
### 标准安装(从 clawHub)
```bash
clawhub install robot-id-card
```
### 从源码安装
```bash
git clone https://github.com/Cosmofang/robot-id-card.git
cd robot-id-card
npm install
npm run build
```
### 验证安装
```bash
npm run dev:registry
# 应输出: RIC Registry running on http://localhost:3000
# 另一个终端
curl http://localhost:3000/health
# 应返回: {"status":"ok","version":"0.2.0"}
```
### npm 包安装(发布后可用)
```bash
npm install -g @robot-id-card/cli # CLI 工具
npm install @robot-id-card/sdk # 网站 SDK
```
---
## Packages
| 包名 | 说明 | 版本 |
|------|------|------|
| `@robot-id-card/registry` | 注册中心服务器(Fastify + SQLite) | 0.2.0 |
| `@robot-id-card/cli` | 开发者 CLI 工具 | 0.2.0 |
| `@robot-id-card/sdk` | 网站集成 SDK(Express + Fastify 中间件) | 0.2.0 |
| `@robot-id-card/extension` | Chrome 浏览器扩展(Manifest V3) | 0.2.0 |
---
*Version: 0.4.0 · Created: 2026-03-24 · Updated: 2026-04-17 · RFC 9421 aligned*
FILE:CONTRIBUTING.md
# Contributing to Robot ID Card
We welcome contributions from everyone! Here's how to get started.
## Areas Needing Help
- **Protocol spec**: Formalizing the certificate format (RFC-style doc)
- **Language SDKs**: Python, Go, Ruby, PHP middleware
- **Registry infrastructure**: Replacing in-memory store with production DB
- **Audit tooling**: Automated behavior analysis for weekly reviews
- **Extension**: Firefox support, mobile browser support
- **Dashboard**: Public web UI to browse registered bots
## Getting Started
```bash
git clone https://github.com/YOUR_USERNAME/robot-id-card
cd robot-id-card
npm install
npm run dev:registry
```
## Project Structure
```
packages/
registry/ — Central identity server (Node.js + Fastify)
extension/ — Chrome/Firefox browser extension
sdk/ — Website integration SDK
cli/ — Developer CLI tool
docs/ — Protocol specifications
```
## Submitting Changes
1. Fork the repo
2. Create a branch: `git checkout -b feat/your-feature`
3. Make your changes
4. Run `npm test`
5. Open a Pull Request
## Code of Conduct
Be kind. This project exists to make the internet safer and more accountable for all bots.
FILE:README.md
# 🤖 Robot ID Card (RIC)
> **The Universal Identity Standard for AI Agents & Bots on the Internet**
Give your bot a passport. Let websites trust it.
[](https://opensource.org/licenses/MIT)
[](CONTRIBUTING.md)
[](https://github.com/Cosmofang/robot-id-card/actions/workflows/ci.yml)
[]()
---
## Component Status
| Component | Status | Notes |
|-----------|--------|-------|
| Protocol Spec | ✅ v2.0 | RFC 9421 + Web Bot Auth aligned; legacy v1 still supported |
| Registry Server | ✅ Working | SQLite, Ed25519, nonce tracking, JWKS well-known endpoint |
| CLI Tool | ✅ Built | `ric keygen / register / claim / status / report / sign` |
| Browser Extension | ✅ Built | Manifest V3, RFC 9421 headers, declarativeNetRequest |
| Website SDK | ✅ Built | Express + Fastify middleware, RFC 9421 + legacy dual-mode |
| Dashboard UI | ✅ Built | Bot registry browser, search, grade filters |
| Tests | ✅ 45 passing | botStore, certificate model, SDK verify |
| Deployed Registry | 🟡 Pending | Dockerfile + render.yaml ready |
| npm Packages | 🟡 Pending | `npm login` required |
| Chrome Extension | 🟡 Pending | Unpublished |
---
## Quick Start
```bash
# Clone and install
git clone https://github.com/Cosmofang/robot-id-card.git
cd robot-id-card
npm install
# Build all packages
npm run build
# Start registry server (dev mode)
npm run dev:registry
# Generate a bot keypair
cd packages/cli && npx tsx src/index.ts keygen
# Register your bot
npx tsx src/index.ts register --name "MyBot" --email "[email protected]" \
--bot-name "MyBot" --purpose "Web research" --key ./my-bot.key.json
```
---
## The Problem
The internet has no way to distinguish a *good* bot from a *bad* one.
- Websites block all bots out of fear (even useful AI assistants)
- Bad bots have no accountability — they can't be traced or stopped
- Good bots (research agents, AI assistants) get caught in the same blocklist as scrapers and spammers
## The Solution: Robot ID Card
A **cryptographically signed identity certificate** for bots, backed by a **public audit registry** and a **daily claim streak system**.
```
Bot registers → Gets signed certificate → Carries RFC 9421 headers in every request
Website reads headers → Registry verifies signature → Grants appropriate permissions
```
> **Standards aligned:** RIC v2.0 uses [RFC 9421 HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421) and is compatible with [Cloudflare Web Bot Auth](https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/) — the same standard adopted by OpenAI, AWS WAF, Visa, and Mastercard for AI agent authentication.
---
## Identity Certificate
```json
{
"ric_version": "1.0",
"id": "ric_a3f8c2d1_xyz12345",
"created_at": "2026-01-15T10:00:00Z",
"developer": {
"name": "Jane Smith",
"email": "[email protected]",
"org": "ExampleAI Inc.",
"website": "https://example.com",
"verified": false
},
"bot": {
"name": "ResearchBot",
"version": "1.0.0",
"purpose": "Web research assistant for academic users",
"capabilities": ["read_articles", "follow_links"],
"user_agent": "ResearchBot/1.0 (RIC:ric_a3f8c2d1_xyz12345)"
},
"grade": "healthy",
"public_key": "ed25519:a3f8c2d1...",
"signature": "..."
}
```
The RIC ID embeds the first 8 hex chars of the public key fingerprint — identity is permanently woven into the ID: `ric_{fp8}_{rand8}`.
---
## Grade System
| Grade | Meaning | How to Achieve |
|-------|---------|---------------|
| 🟡 Unknown | Newly registered | Default on registration |
| 🟢 Healthy | Trusted | 3+ consecutive daily `ric claim` calls |
| 🔴 Dangerous | Flagged | 3+ violation reports within 24h → auto-block |
---
## Permission Levels
```
Level 0 — Blocked (Dangerous bots)
Level 1 — Read articles (Unknown + all verified bots)
Level 2 — View threads (Healthy, read_articles/view_threads)
Level 3 — Like / react (Healthy, react capability)
Level 4 — Post content (Healthy, post_content capability)
Level 5 — Direct chat (Healthy, direct_chat capability)
```
---
## Quick Start
### Register your bot (CLI)
```bash
npm install -g @robot-id-card/cli
ric keygen --output my-bot.key.json
ric register --key my-bot.key.json
ric claim --key my-bot.key.json # Run daily to build trust streak
```
### Verify bots on your website (SDK)
```typescript
import { ricMiddleware } from '@robot-id-card/sdk/middleware/express'
// Block unverified bots on write routes
app.use('/api/post', ricMiddleware({ minGrade: 'healthy', requiredPermissionLevel: 4 }))
```
### Run registry locally
```bash
npm install
npm run dev:registry # Starts on localhost:3000
```
### Browse registered bots (Dashboard)
```bash
cd packages/dashboard
npm run dev # Starts on localhost:5173
```
---
## Architecture
```
┌──────────────────────────────────────────────────────────┐
│ RIC Ecosystem │
│ │
│ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ Bot/Agent │ │ RIC Registry │ │
│ │ (CLI tool) │ │ SQLite · Ed25519 · Fastify │ │
│ │ │◄───┤ /v1/bots/register │ │
│ │ Browser Ext │ │ /v1/bots/:id/claim │ │
│ │ injects hdrs │ │ /v1/verify │ │
│ └──────┬───────┘ │ /v1/audit/report │ │
│ │ └──────────────┬────────────────┘ │
│ Signature-Input: ric=(...);keyid= │ │
│ Signature: ric=:<base64>: │ Dashboard UI │
│ Signature-Agent: "Bot"; cert=... │ lists all bots │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Website / Platform (SDK) │ │
│ │ ricMiddleware({ minGrade: 'healthy' }) │ │
│ │ → verifies signature → grants permission 0–5 │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
---
## Packages
| Package | Description | Publish |
|---------|-------------|---------|
| [`packages/registry`](packages/registry) | Central registry server (Fastify + SQLite) | Docker / Render |
| [`packages/cli`](packages/cli) | Developer CLI (`ric` command) | `@robot-id-card/cli` |
| [`packages/sdk`](packages/sdk) | Website integration SDK | `@robot-id-card/sdk` |
| [`packages/extension`](packages/extension) | Chrome extension (MV3) | Chrome Web Store |
| [`packages/dashboard`](packages/dashboard) | Public bot browser UI (Vite) | Netlify / Vercel |
---
## Deploy
### Registry (Render.com)
1. Connect this repo to [Render.com](https://render.com)
2. Render auto-detects `render.yaml` and creates the service
3. A persistent 1GB disk is mounted at `/data` for SQLite
### Dashboard (Netlify)
1. Connect `packages/dashboard` to Netlify
2. `netlify.toml` is pre-configured (build: `npm run build`, publish: `dist`)
3. Set `VITE_REGISTRY_URL` to your deployed registry URL
---
## Security
- **Ed25519 signatures** — every request signed with bot's private key
- **Replay protection** — 5-minute timestamp window
- **Auto-flagging** — 3+ violation reports within 24h → instant `dangerous` grade
- **Public audit log** — all grade changes recorded in `audit_log` table
---
## Tests
```bash
npm test # 45 tests — botStore, certificate model, SDK verify
```
---
## Roadmap
- [x] v0.1 — Protocol spec, registry scaffold, CLI, SDK, extension
- [x] v0.2 — SQLite persistence, certificate issuance, daily claim streak, auto-block
- [x] v0.3 — Extension MV3, Dashboard UI, 45 unit tests, Dockerfile, npm publish config
- [x] v0.4 — RFC 9421 alignment: Signature/Signature-Input/Signature-Agent headers, nonce replay protection, JWKS well-known endpoint, `ric sign` command
- [ ] v0.5 — Deploy public registry, publish CLI+SDK to npm, Chrome Web Store submission
- [ ] v0.6 — Dashboard: violation reports UI, public audit log browser
- [ ] v1.0 — Decentralized registry (DID-based)
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). Help wanted:
- Python / Go / Ruby SDK
- Firefox extension support
- Automated behavior audit tooling
- Dashboard: audit log viewer
---
## License
MIT © Robot ID Card Contributors
FILE:_meta.json
{
"ownerId": "kn79bebfnwg15sb0g7cj5z5nyd83gxh0",
"slug": "robot-id-card",
"version": "0.4.0",
"publishedAt": null
}
FILE:docs/deploy.md
# Deployment Guide
## Registry Server (Render.com)
The registry is containerized with Docker. `render.yaml` in the repo root enables one-click deploy.
### Steps
1. Fork or push this repo to GitHub
2. Go to [render.com](https://render.com) → **New** → **Blueprint**
3. Connect your GitHub repo — Render auto-detects `render.yaml`
4. Set environment variables:
- `RIC_ADMIN_KEY` — a strong secret for admin grade updates (auto-generated by Render if using `generateValue: true`)
5. Deploy — the service starts on port 10000
### Persistent Storage
The `render.yaml` mounts a 1GB disk at `/data`. The SQLite database is written to `/data/registry.db` automatically.
### Environment Variables
| Variable | Default | Required | Description |
|----------|---------|----------|-------------|
| `PORT` | `10000` | No | HTTP port |
| `RIC_DB_PATH` | `/data/registry.db` | No | SQLite database path |
| `RIC_ADMIN_KEY` | `dev-admin-key-change-me` | **Yes (prod)** | Admin key for manual grade updates |
| `NODE_ENV` | `development` | No | Set to `production` in prod |
### Health Check
```
GET /health → { "status": "ok", "version": "0.3.0" }
```
---
## Dashboard (Netlify)
### Steps
1. Connect your GitHub repo to [netlify.com](https://netlify.com)
2. Set **Base directory**: `packages/dashboard`
3. Set **Build command**: `npm run build`
4. Set **Publish directory**: `dist`
5. Add environment variable: `VITE_REGISTRY_URL=https://your-registry.onrender.com`
`netlify.toml` in `packages/dashboard/` pre-configures everything.
### Steps (Vercel)
1. Connect your GitHub repo to [vercel.com](https://vercel.com)
2. Set **Root Directory**: `packages/dashboard`
3. Add environment variable: `VITE_REGISTRY_URL=https://your-registry.onrender.com`
`vercel.json` in `packages/dashboard/` handles SPA routing rewrites.
---
## CLI & SDK (npm)
```bash
# Requires npm login
npm login
# Publish CLI
cd packages/cli && npm publish --access public
# Publish SDK
cd packages/sdk && npm publish --access public
```
Packages are scoped under `@robot-id-card/`. The `publishConfig.access: "public"` flag is set in both `package.json` files.
Required npm token: create a **Automation** token at npmjs.com → Account → Access Tokens, then add as `NPM_TOKEN` in GitHub repo secrets for automated CI publishing.
---
## Browser Extension (Chrome Web Store)
1. Run `npm run build --workspace=packages/extension`
2. Zip the `packages/extension/dist/` folder
3. Upload to [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole)
4. Fill in description, screenshots, privacy policy
5. Submit for review (typically 1-3 business days)
---
## Local Development
```bash
# Start registry
npm run dev:registry # http://localhost:3000
# Start dashboard (in another terminal)
cd packages/dashboard && npm run dev # http://localhost:5173
# Run tests
npm test # 45 tests
# End-to-end test (registry must be running)
bash scripts/test-local.sh
```
FILE:docs/spec-v1.md
# RIC Protocol Specification v1.0 (Draft)
## Overview
The Robot ID Card (RIC) protocol defines a standard for bot identity on the web.
It is inspired by TLS certificates and public key infrastructure (PKI), adapted for the bot ecosystem.
## Certificate Format
### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `ric_version` | `"1.0"` | ✅ | Protocol version |
| `id` | `string` | ✅ | Globally unique bot ID, prefix `ric_` |
| `created_at` | ISO 8601 | ✅ | Registration timestamp |
| `developer.name` | string | ✅ | Developer's real name |
| `developer.email` | string | ✅ | Contact email (verified) |
| `developer.org` | string | ❌ | Organization |
| `developer.verified` | boolean | ✅ | Email verification status |
| `bot.name` | string | ✅ | Bot display name |
| `bot.version` | semver | ✅ | Bot software version |
| `bot.purpose` | string | ✅ | Human-readable purpose (10-500 chars) |
| `bot.capabilities` | string[] | ✅ | Declared capability list |
| `bot.user_agent` | string | ✅ | Expected User-Agent header value |
| `grade` | enum | ✅ | `unknown` / `healthy` / `dangerous` |
| `grade_updated_at` | ISO 8601 | ✅ | Last grade change timestamp |
| `public_key` | `ed25519:<hex>` | ✅ | Bot's Ed25519 public key |
| `signature` | string | ✅ | Registry signature over the certificate |
## Request Signing
Every HTTP request from the bot must include these headers:
```
X-RIC-ID: ric_<id>
X-RIC-Timestamp: <unix ms>
X-RIC-Signature: <ed25519 hex signature>
X-RIC-Version: 1.0
```
The signed message is: `{ric_id}:{timestamp}:{request_url}`
## Grade Definitions
### 🟡 UNKNOWN
- Default grade for newly registered bots
- Permitted: read-only access (permission level 1)
- Promoted to HEALTHY after first successful weekly review
### 🟢 HEALTHY
- Passed weekly review with no violations
- Permitted: up to level 5 depending on declared capabilities
- Demoted to DANGEROUS immediately upon 3+ confirmed reports
### 🔴 DANGEROUS
- Has recorded risk behavior
- Permitted: nothing (permission level 0)
- Can appeal after 30-day waiting period
## Permission Levels
```
Level 0 — Blocked
Level 1 — Read public articles / static content
Level 2 — View threaded discussions
Level 3 — Reactions (like, upvote)
Level 4 — Post content (with rate limits)
Level 5 — Direct messaging
```
## Audit Log
All grade changes and violation reports are publicly visible via:
`GET /v1/audit/{ric_id}`
This transparency log ensures accountability without exposing private data.
## Security Considerations
1. **Private key security**: The bot's private key must never leave the bot's environment.
2. **Replay protection**: Requests with timestamps older than 5 minutes are rejected.
3. **Registry signing**: The registry signs each certificate with its own key — certificates cannot be self-issued.
4. **Rate limiting**: The registry API is rate-limited to prevent abuse.
## Future: Decentralization (v2)
In v2, the registry will support Decentralized Identifiers (DIDs), allowing bots to anchor their identity to the blockchain and reducing single-point-of-failure risk.
FILE:docs/spec-v2.md
# RIC Protocol Specification v2.0
> Aligned with **RFC 9421 HTTP Message Signatures** (IETF, Feb 2024)
> and **Cloudflare Web Bot Auth** / IETF `draft-meunier-web-bot-auth-architecture`
---
## What Changed from v1.0
| Aspect | v1.0 (legacy) | v2.0 (this spec) |
|--------|--------------|-----------------|
| Header format | Custom `X-RIC-*` | Standard `Signature` + `Signature-Input` + `Signature-Agent` (RFC 9421) |
| Signature encoding | Hex | Base64 (RFC 9421 standard) |
| Timestamp unit | Unix milliseconds | Unix **seconds** (RFC 9421) |
| Replay protection | 5-min time window only | Time window **+** nonce uniqueness |
| Bot tag | n/a | `tag="web-bot-auth"` for ecosystem compatibility |
| Public key discovery | Registry lookup only | `/.well-known/http-message-signatures-directory` (JWKS, RFC 8037) |
| Signed components | `{id}:{ts}:{url}` string | RFC 9421 signature base: `@authority`, `@method`, `@path` |
v1.0 `X-RIC-*` headers are still accepted but return a `deprecation_notice` in the response.
---
## Request Headers (v2.0)
Every HTTP request from a bot must carry these three headers:
```
Signature-Input: ric=("@authority" "@method" "@path");keyid="ric_a3f8c2d1_xyz12345";created=1718000000;expires=1718000300;nonce="Zmxhbmd4MTIz";tag="web-bot-auth"
Signature: ric=:HIbjHC5rS0BKbgTHA...base64...==:
Signature-Agent: "ResearchBot"; cert="https://registry.robotidcard.dev/v1/bots/ric_a3f8c2d1_xyz12345"
```
### Signature-Input fields
| Field | Required | Description |
|-------|----------|-------------|
| Component list | ✅ | Signed HTTP components: at minimum `"@authority"` |
| `keyid` | ✅ | The bot's RIC ID (`ric_<fp8>_<rand8>`) |
| `created` | ✅ | Signature creation time (Unix seconds) |
| `expires` | Recommended | Expiry time (Unix seconds). Recommended: `created + 300` |
| `nonce` | Recommended | Base64url-encoded random bytes (16+ bytes). Prevents replay attacks |
| `tag` | Recommended | Must be `"web-bot-auth"` for Web Bot Auth ecosystem compatibility |
### Signature-Agent format
```
"<bot display name>"; cert="<certificate URL>"
```
---
## Signature Base Construction
The signature base is the canonical string that is signed with Ed25519.
It follows RFC 9421 §2.5:
```
"@authority": example.com
"@method": GET
"@path": /api/articles
"@signature-params": ("@authority" "@method" "@path");keyid="ric_a3f8_xyz1";created=1718000000;expires=1718000300;nonce="Zmxh";tag="web-bot-auth"
```
Rules:
- Each line is `"<component>": <value>`
- The final line is always `"@signature-params": <everything after "label=" in Signature-Input>`
- Lines are joined with `\n` (no trailing newline)
- The signature is `Ed25519( UTF-8(signature_base) )`
---
## Public Key Directory (JWKS)
Bot operators publish public keys at a well-known URL so verifiers can retrieve them without a centralized registry call.
### Registry-hosted (default for RIC bots)
```
GET https://registry.robotidcard.dev/.well-known/http-message-signatures-directory
GET https://registry.robotidcard.dev/.well-known/http-message-signatures-directory?kid=ric_abc_xyz
GET https://registry.robotidcard.dev/v1/bots/{id}/keys
```
Response `Content-Type: application/http-message-signatures-directory+json`:
```json
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "<base64url of 32-byte public key>",
"kid": "ric_a3f8c2d1_xyz12345",
"use": "sig"
}
]
}
```
### Self-hosted (advanced)
Bots with their own domain can host keys at:
```
https://yourdomain.com/.well-known/http-message-signatures-directory
```
Use `ric sign` to generate headers — the `keyid` can be the full URL of your key directory.
---
## Registry Verification (7-Step Flow)
`POST /v1/verify` with body:
```json
{
"authority": "example.com",
"method": "GET",
"path": "/api/articles",
"signature_input": "ric=(...);keyid=...;created=...;nonce=...;tag=...",
"signature": "ric=:<base64>:",
"signature_agent": "\"ResearchBot\"; cert=\"...\""
}
```
Steps:
1. **Header presence** — Confirm `signature_input` and `signature` are present
2. **Public key retrieval** — Parse `keyid` from `Signature-Input`; look up bot in registry
3. **Timestamp validation** — `created` must not be > 5 min old; `expires` must not be passed; ±30s clock skew allowed
4. **Nonce uniqueness** — If `nonce` present, check it has not been seen in the last 10 min; mark as used
5. **Tag verification** — If `tag` present, must be `"web-bot-auth"`
6. **Ed25519 verification** — Reconstruct signature base; verify with bot's stored public key
7. **Registration status** — Confirm bot is not `dangerous`
---
## Generating Headers: `ric sign`
```bash
ric sign --cert bot.ric.json \
--authority "example.com" \
--method GET \
--path "/api/articles" \
--ttl 300
```
Output:
```
Signature-Input: ric=("@authority" "@method" "@path");keyid="ric_a3f8_xyz1";created=1718000000;expires=1718000300;nonce="abc123";tag="web-bot-auth"
Signature: ric=:HIbjH...base64...==:
Signature-Agent: "ResearchBot"; cert="https://registry.robotidcard.dev/v1/bots/ric_a3f8_xyz1"
```
---
## Website Integration (SDK v2.0)
```typescript
import { RICMiddleware } from '@robot-id-card/sdk'
app.use(RICMiddleware({
permissions: {
'/api/posts': { minGrade: 'unknown', level: 1 },
'/api/comment': { minGrade: 'healthy', level: 4 },
}
}))
```
The middleware automatically detects RFC 9421 headers and passes `authority`, `method`, and `path` to the registry for signature base reconstruction.
---
## Certificate Format (unchanged from v1.0)
```json
{
"ric_version": "2.0",
"id": "ric_a3f8c2d1_xyz12345",
"created_at": "2026-01-15T10:00:00Z",
"developer": {
"name": "Jane Smith",
"email": "[email protected]",
"org": "ExampleAI Inc.",
"verified": false
},
"bot": {
"name": "ResearchBot",
"version": "1.0.0",
"purpose": "Web research assistant for academic users",
"capabilities": ["read_articles", "follow_links"],
"user_agent": "ResearchBot/1.0 (RIC:ric_a3f8c2d1_xyz12345)"
},
"grade": "healthy",
"public_key": "ed25519:<64-hex-chars>",
"signature": "..."
}
```
---
## Security Properties
| Property | Mechanism |
|----------|-----------|
| Bot identity binding | Ed25519 signature — private key never leaves bot |
| Replay prevention | `expires` (max 5 min) + `nonce` uniqueness (10-min window) |
| Clock skew tolerance | ±30 seconds |
| Tamper detection | Signature base includes all signed components |
| Accountability | Public audit log; auto-block on 3 violation reports |
| Key discovery | JWKS at `/.well-known/http-message-signatures-directory` |
---
## Standards References
- [RFC 9421 — HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421) (IETF, Feb 2024)
- [RFC 8037 — CFRG Elliptic Curves for JOSE](https://www.rfc-editor.org/rfc/rfc8037) (OKP/Ed25519 JWK format)
- [draft-meunier-web-bot-auth-architecture](https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture) (Cloudflare IETF Draft)
- [Cloudflare Web Bot Auth](https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/)
---
*Version: 2.0 · Created: 2026-04-17*
FILE:package-lock.json
{
"name": "robot-id-card",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "robot-id-card",
"version": "0.1.0",
"license": "MIT",
"workspaces": [
"packages/*"
]
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
"integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"ajv": "^8.12.0",
"ajv-formats": "^3.0.1",
"fast-uri": "^3.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
"integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fastify-plugin": "^5.0.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/@fastify/error": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
"integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/fast-json-stringify-compiler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz",
"integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fast-json-stringify": "^6.0.0"
}
},
"node_modules/@fastify/forwarded": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
"integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
"integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/@fastify/proxy-addr": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
"integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/forwarded": "^3.0.0",
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/rate-limit": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
"integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"fastify-plugin": "^5.0.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/@jest/schemas": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
"integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.27.8"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@noble/ed25519": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.3.0.tgz",
"integrity": "sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@oxc-project/runtime": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@robot-id-card/cli": {
"resolved": "packages/cli",
"link": true
},
"node_modules/@robot-id-card/dashboard": {
"resolved": "packages/dashboard",
"link": true
},
"node_modules/@robot-id-card/extension": {
"resolved": "packages/extension",
"link": true
},
"node_modules/@robot-id-card/registry": {
"resolved": "packages/registry",
"link": true
},
"node_modules/@robot-id-card/sdk": {
"resolved": "packages/sdk",
"link": true
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@sinclair/typebox": {
"version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
"integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/chai/node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/@types/chrome": {
"version": "0.0.268",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.268.tgz",
"integrity": "sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filesystem": "*",
"@types/har-format": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/filesystem": {
"version": "0.0.36",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filewriter": "*"
}
},
"node_modules/@types/filewriter": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/har-format": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@vitest/expect": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
"integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "1.6.1",
"@vitest/utils": "1.6.1",
"chai": "^4.3.10"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz",
"integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz",
"integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "1.6.1",
"p-limit": "^5.0.0",
"pathe": "^1.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz",
"integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.5",
"pathe": "^1.1.1",
"pretty-format": "^29.7.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz",
"integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^2.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz",
"integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"diff-sequences": "^29.6.3",
"estree-walker": "^3.0.3",
"loupe": "^2.3.7",
"pretty-format": "^29.7.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/assertion-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
"integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/avvio": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz",
"integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/error": "^4.0.0",
"fastq": "^1.17.1"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.8.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/chai": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
"integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^1.1.0",
"check-error": "^1.0.3",
"deep-eql": "^4.1.3",
"get-func-name": "^2.0.2",
"loupe": "^2.3.6",
"pathval": "^1.1.1",
"type-detect": "^4.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/check-error": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
"integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-func-name": "^2.0.2"
},
"engines": {
"node": "*"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
"integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"type-detect": "^4.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
"integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
"integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^8.0.1",
"human-signals": "^5.0.0",
"is-stream": "^3.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^5.1.0",
"onetime": "^6.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^3.0.0"
},
"engines": {
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-json-stringify": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz",
"integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/merge-json-schemas": "^0.2.0",
"ajv": "^8.12.0",
"ajv-formats": "^3.0.1",
"fast-uri": "^3.0.0",
"json-schema-ref-resolver": "^3.0.0",
"rfdc": "^1.2.0"
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
"license": "MIT",
"dependencies": {
"fast-decode-uri-component": "^1.0.1"
}
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastify": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz",
"integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/ajv-compiler": "^4.0.5",
"@fastify/error": "^4.0.0",
"@fastify/fast-json-stringify-compiler": "^5.0.0",
"@fastify/proxy-addr": "^5.0.0",
"abstract-logging": "^2.0.1",
"avvio": "^9.0.0",
"fast-json-stringify": "^6.0.0",
"find-my-way": "^9.0.0",
"light-my-request": "^6.0.0",
"pino": "^9.14.0 || ^10.1.0",
"process-warning": "^5.0.0",
"rfdc": "^1.3.1",
"secure-json-parse": "^4.0.0",
"semver": "^7.6.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/fastify-plugin": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/find-my-way": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
"integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-querystring": "^1.0.0",
"safe-regex2": "^5.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-func-name": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
"integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/get-stream": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
"integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-ref-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
"integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
"integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause",
"dependencies": {
"cookie": "^1.0.1",
"process-warning": "^4.0.0",
"set-cookie-parser": "^2.6.0"
}
},
"node_modules/light-my-request/node_modules/process-warning": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
"integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/local-pkg": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
"integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mlly": "^1.7.3",
"pkg-types": "^1.2.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/loupe": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
"integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-func-name": "^2.0.1"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true,
"license": "MIT"
},
"node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mlly": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz",
"integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.3"
}
},
"node_modules/mlly/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz",
"integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
"integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
"integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-limit": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
"integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
"integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/pkg-types/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "^29.6.3",
"ansi-styles": "^5.0.0",
"react-is": "^18.0.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true,
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/ret": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
"integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.115.0",
"@rolldown/pluginutils": "1.0.0-rc.9"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
}
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz",
"integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"ret": "~0.5.0"
},
"bin": {
"safe-regex2": "bin/safe-regex2.js"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
"integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-literal": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz",
"integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/thread-stream": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
"integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinypool": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz",
"integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
"integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/toad-cache": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
"integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-detect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
"integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
},
"node_modules/vite-node": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz",
"integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.4",
"pathe": "^1.1.1",
"picocolors": "^1.0.0",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/vitest": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "1.6.1",
"@vitest/runner": "1.6.1",
"@vitest/snapshot": "1.6.1",
"@vitest/spy": "1.6.1",
"@vitest/utils": "1.6.1",
"acorn-walk": "^8.3.2",
"chai": "^4.3.10",
"debug": "^4.3.4",
"execa": "^8.0.1",
"local-pkg": "^0.5.0",
"magic-string": "^0.30.5",
"pathe": "^1.1.1",
"picocolors": "^1.0.0",
"std-env": "^3.5.0",
"strip-literal": "^2.0.0",
"tinybench": "^2.5.1",
"tinypool": "^0.8.3",
"vite": "^5.0.0",
"vite-node": "1.6.1",
"why-is-node-running": "^2.2.2"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "1.6.1",
"@vitest/ui": "1.6.1",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"packages/cli": {
"name": "@robot-id-card/cli",
"version": "0.2.0",
"dependencies": {
"@noble/ed25519": "^2.1.0",
"@noble/hashes": "^2.0.1",
"commander": "^12.0.0"
},
"bin": {
"ric": "dist/index.js"
},
"devDependencies": {
"tsx": "^4.7.0",
"typescript": "^5.4.0"
},
"engines": {
"node": ">=18"
}
},
"packages/dashboard": {
"name": "@robot-id-card/dashboard",
"version": "0.1.0",
"devDependencies": {
"typescript": "^5.4.0",
"vite": "^5.4.0"
}
},
"packages/extension": {
"name": "@robot-id-card/extension",
"version": "0.1.0",
"dependencies": {
"@noble/ed25519": "^2.1.0"
},
"devDependencies": {
"@types/chrome": "^0.0.268",
"esbuild": "^0.24.2",
"typescript": "^5.4.0"
}
},
"packages/extension/node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"packages/extension/node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"packages/registry": {
"name": "@robot-id-card/registry",
"version": "0.1.0",
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/rate-limit": "^10.3.0",
"@noble/ed25519": "^2.1.0",
"@noble/hashes": "^2.0.1",
"better-sqlite3": "^12.8.0",
"fastify": "^5.8.2",
"nanoid": "^5.0.6",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"tsx": "^4.7.0",
"typescript": "^5.4.0",
"vitest": "1.6.1"
}
},
"packages/registry/node_modules/@vitest/expect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
"integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.0",
"@vitest/utils": "4.1.0",
"chai": "^6.2.2",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"packages/registry/node_modules/@vitest/mocker": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz",
"integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"packages/registry/node_modules/@vitest/runner": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz",
"integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.0",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"packages/registry/node_modules/@vitest/snapshot": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz",
"integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.0",
"@vitest/utils": "4.1.0",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"packages/registry/node_modules/@vitest/spy": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz",
"integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"packages/registry/node_modules/@vitest/utils": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz",
"integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.0",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"packages/registry/node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/registry/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"packages/registry/node_modules/std-env": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"dev": true,
"license": "MIT"
},
"packages/registry/node_modules/vite": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.9",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.0.0-alpha.31",
"esbuild": "^0.27.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"packages/registry/node_modules/vitest": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz",
"integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.0",
"@vitest/mocker": "4.1.0",
"@vitest/pretty-format": "4.1.0",
"@vitest/runner": "4.1.0",
"@vitest/snapshot": "4.1.0",
"@vitest/spy": "4.1.0",
"@vitest/utils": "4.1.0",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.0",
"@vitest/browser-preview": "4.1.0",
"@vitest/browser-webdriverio": "4.1.0",
"@vitest/ui": "4.1.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"packages/sdk": {
"name": "@robot-id-card/sdk",
"version": "0.2.0",
"dependencies": {
"@noble/ed25519": "^2.1.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"vitest": "^1.6.1"
},
"peerDependencies": {
"fastify": ">=4"
},
"peerDependenciesMeta": {
"fastify": {
"optional": true
}
}
}
}
}
FILE:package.json
{
"name": "robot-id-card",
"version": "0.4.0",
"private": true,
"description": "Universal identity standard for AI bots and agents on the internet — cryptographically signed certificates, public audit registry, and permission-based access control",
"homepage": "https://github.com/Cosmofang/robot-id-card",
"workspaces": [
"packages/*"
],
"scripts": {
"build": "npm run build --workspaces",
"test": "node node_modules/vitest/vitest.mjs run packages/registry/src packages/sdk/src",
"dev:registry": "npm run dev --workspace=packages/registry",
"typecheck": "tsc --noEmit"
},
"keywords": [
"bot-identity", "ai-agent", "robot-id", "bot-passport", "bot-certificate",
"ed25519", "cryptographic-identity", "bot-verification", "web-security",
"browser-extension", "sdk", "registry", "bot-accountability",
"permission-system", "audit-trail", "bot-grade", "ai-safety"
],
"overrides": {
"vitest": "1.6.1"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Cosmofang/robot-id-card"
}
}
FILE:packages/cli/README.md
# @robot-id-card/cli
> CLI tool for managing your bot's Robot ID Card identity.
## Install
```bash
npm install -g @robot-id-card/cli
```
## Usage
```bash
# 1. Generate a keypair for your bot
ric keygen --output my-bot.key.json
# 2. Register your bot with the registry
ric register --key my-bot.key.json
# 3. Check your bot's grade
ric status --key my-bot.key.json
# 4. Submit a daily identity claim (builds trust streak → grade upgrade)
ric claim --key my-bot.key.json
# 5. Report a bad bot
ric report <ric_id>
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `RIC_REGISTRY` | `https://registry.robotidcard.dev` | Registry server URL |
## How it works
Each bot gets an [Ed25519](https://ed25519.cr.yp.to/) keypair. The public key is registered with the RIC registry and embedded in a signed certificate. Daily `ric claim` calls build a consecutive-day streak — after 3 days, grade upgrades from `unknown` → `healthy`.
See the [protocol spec](../../docs/spec-v1.md) for full details.
FILE:packages/cli/package.json
{
"name": "@robot-id-card/cli",
"version": "0.2.0",
"description": "CLI tool for managing Robot ID Card bot identities",
"type": "module",
"main": "dist/index.js",
"bin": {
"ric": "./dist/index.js"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"prepublishOnly": "npm run build",
"test": "echo no tests"
},
"dependencies": {
"@noble/ed25519": "^2.1.0",
"@noble/hashes": "^2.0.1",
"commander": "^12.0.0"
},
"devDependencies": {
"tsx": "^4.7.0",
"typescript": "^5.4.0"
},
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
}
}
FILE:packages/cli/src/index.ts
#!/usr/bin/env node
/**
* RIC CLI — Developer tool for managing bot identities
*
* Usage:
* ric keygen — Generate a new Ed25519 keypair (step 1)
* ric register — Register a new bot (step 2)
* ric status — Check your bot's current grade
* ric verify — Verify a bot by RIC ID
* ric report — Report a bad bot
*/
import { Command } from 'commander'
import * as ed from '@noble/ed25519'
import { sha512 } from '@noble/hashes/sha2.js'
import { randomBytes } from 'crypto'
import * as fs from 'fs'
// @noble/ed25519 v2 requires sha512Sync to be set for Node.js
ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m)
const REGISTRY = process.env.RIC_REGISTRY || 'https://registry.robotidcard.dev'
const program = new Command()
// ──────────────────────────────────────────────
// ric keygen
// ──────────────────────────────────────────────
program
.command('keygen')
.description('Generate a new Ed25519 keypair for your bot')
.option('--out <file>', 'Output key file', './bot.key.json')
.action(async (opts) => {
const privateKey = randomBytes(32)
const publicKey = await ed.getPublicKey(privateKey)
const keyFile = {
private_key_hex: privateKey.toString('hex'),
public_key: `ed25519:Buffer.from(publicKey).toString('hex')`,
generated_at: new Date().toISOString(),
}
fs.writeFileSync(opts.out, JSON.stringify(keyFile, null, 2))
console.log(`\n🔑 New Ed25519 keypair generated`)
console.log(` Public key: keyFile.public_key`)
console.log(` Saved to: opts.out`)
console.log(`\n⚠️ Keep opts.out safe — it contains your private key!`)
console.log(` Next step: ric register --key opts.out --name "MyBot" ...`)
})
program.name('ric').description('Robot ID Card CLI').version('0.4.0')
// ──────────────────────────────────────────────
// ric register
// ──────────────────────────────────────────────
program
.command('register')
.description('Register a new bot and get your RIC certificate')
.requiredOption('--name <name>', 'Bot name')
.requiredOption('--purpose <purpose>', 'What your bot does (min 10 chars)')
.requiredOption('--developer <email>', 'Developer email')
.option('--org <org>', 'Organization name')
.option('--version <ver>', 'Bot version', '1.0.0')
.option('--capabilities <caps>', 'Comma-separated capabilities (read_articles,follow_links,...)', 'read_articles')
.option('--key <file>', 'Use existing key file from `ric keygen`')
.option('--out <file>', 'Output certificate file', './bot.ric.json')
.action(async (opts) => {
console.log('\n🤖 Robot ID Card — Bot Registration\n')
// Load or generate keypair
let privateKeyHex: string
let publicKey: string
if (opts.key) {
if (!fs.existsSync(opts.key)) {
console.error(`Key file not found: opts.key`)
console.error(`Generate one with: ric keygen --out opts.key`)
process.exit(1)
}
const keyFile = JSON.parse(fs.readFileSync(opts.key, 'utf8'))
privateKeyHex = keyFile.private_key_hex
publicKey = keyFile.public_key
} else {
const privBytes = randomBytes(32)
const pubBytes = await ed.getPublicKey(privBytes)
privateKeyHex = privBytes.toString('hex')
publicKey = `ed25519:Buffer.from(pubBytes).toString('hex')`
}
const capabilities = opts.capabilities.split(',').map((c: string) => c.trim())
const botName = opts.name
const payload = {
developer: {
name: opts.developer.split('@')[0],
email: opts.developer,
org: opts.org,
},
bot: {
name: botName,
version: opts.version,
purpose: opts.purpose,
capabilities,
user_agent: `botName/opts.version (RIC:pending)`,
},
public_key: publicKey,
}
try {
const res = await fetch(`REGISTRY/v1/bots/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json() as any
if (!res.ok) {
if (res.status === 409) {
console.error(`\n❌ data.error`)
if (data.existing_id) console.error(` Existing RIC ID: data.existing_id`)
if (data.hint) console.error(` Hint: data.hint`)
} else {
console.error('Registration failed:', data)
}
process.exit(1)
}
const { certificate } = data
const config = { ...certificate, private_key_hex: privateKeyHex }
fs.writeFileSync(opts.out, JSON.stringify(config, null, 2))
console.log(`✅ Bot registered successfully!\n`)
console.log(` RIC ID: certificate.id`)
console.log(` Grade: 🟡 UNKNOWN (pending weekly review)`)
console.log(` Certificate: opts.out`)
console.log(`\n⚠️ Keep opts.out safe — it contains your private key!`)
} catch (e) {
console.error('Failed to connect to registry:', e)
process.exit(1)
}
})
// ──────────────────────────────────────────────
// ric status <ric_id>
// ──────────────────────────────────────────────
program
.command('status [ric_id]')
.description('Check a bot\'s current grade and certificate')
.option('--cert <file>', 'Load RIC ID from certificate file')
.action(async (ricId, opts) => {
if (!ricId && opts.cert) {
const cert = JSON.parse(fs.readFileSync(opts.cert, 'utf8'))
ricId = cert.id
}
if (!ricId) {
console.error('Provide a RIC ID or --cert file')
process.exit(1)
}
const res = await fetch(`REGISTRY/v1/bots/ricId`)
if (!res.ok) {
console.error('Bot not found:', ricId)
process.exit(1)
}
const cert = await res.json()
const gradeEmoji = { healthy: '🟢', unknown: '🟡', dangerous: '🔴' }[cert.grade as string] || '⚪'
console.log(`\ngradeEmoji cert.bot.name [cert.grade.toUpperCase()]\n`)
console.log(` ID: cert.id`)
console.log(` Developer: cert.developer.name <cert.developer.email>`)
console.log(` Purpose: cert.bot.purpose`)
console.log(` Created: new Date(cert.created_at).toLocaleDateString()`)
console.log(` Grade since: new Date(cert.grade_updated_at).toLocaleDateString()\n`)
})
// ──────────────────────────────────────────────
// ric report <ric_id>
// ──────────────────────────────────────────────
program
.command('report <ric_id>')
.description('Report a bot for bad behavior')
.option('--reason <reason>', 'Reason: spam|scraping_violation|rate_limit_abuse|tos_violation')
.option('--domain <domain>', 'Your domain (reporter)')
.option('--desc <desc>', 'Description of the incident')
.action(async (ricId, opts) => {
const res = await fetch(`REGISTRY/v1/audit/report`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ric_id: ricId,
reporter_domain: opts.domain || 'unknown',
reason: opts.reason || 'other',
description: opts.desc || '',
}),
})
const data = await res.json()
if (res.ok) {
console.log(`✅ Report submitted. ID: data.report_id`)
} else {
console.error('Report failed:', data)
}
})
// ──────────────────────────────────────────────
// ric claim
// ──────────────────────────────────────────────
program
.command('claim')
.description('Claim your bot identity today (max 2×/day, 3 consecutive days → HEALTHY)')
.option('--cert <file>', 'Certificate file from `ric register`', './bot.ric.json')
.action(async (opts) => {
if (!fs.existsSync(opts.cert)) {
console.error(`Certificate file not found: opts.cert`)
process.exit(1)
}
const cert = JSON.parse(fs.readFileSync(opts.cert, 'utf8'))
const { id, private_key_hex, public_key } = cert
if (!id || !private_key_hex) {
console.error('Invalid certificate file — missing id or private_key_hex')
process.exit(1)
}
const date = new Date().toISOString().slice(0, 10)
const message = `id:date`
const msgBytes = new TextEncoder().encode(message)
const privBytes = Buffer.from(private_key_hex, 'hex')
const sigBytes = await ed.sign(msgBytes, privBytes)
const signature = `ed25519:Buffer.from(sigBytes).toString('hex')`
try {
const res = await fetch(`REGISTRY/v1/bots/id/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signature, date }),
})
const data = await res.json() as any
if (!res.ok) {
console.error(`\n❌ data.error`)
if (data.message) console.error(` data.message`)
process.exit(1)
}
const gradeEmoji = { healthy: '🟢', unknown: '🟡', dangerous: '🔴' }[data.grade as string] || '⚪'
console.log(`\ngradeEmoji Identity claimed for id`)
console.log(` Streak : data.consecutive_days consecutive day(s)`)
console.log(` Today : data.today_count/2 claims used`)
console.log(` Grade : data.grade.toUpperCase()`)
if (data.grade_upgraded) {
console.log(`\n🎉 Grade upgraded to HEALTHY!`)
}
if (data.certificate_type === 'code' && Array.isArray(data.award)) {
console.log('\n' + data.award.join('\n'))
}
} catch (e) {
console.error('Failed to connect to registry:', e)
process.exit(1)
}
})
// ──────────────────────────────────────────────
// ric sign (RFC 9421 — Web Bot Auth standard)
// ──────────────────────────────────────────────
program
.command('sign')
.description('Generate RFC 9421-compliant Signature + Signature-Input + Signature-Agent headers')
.requiredOption('--cert <file>', 'Certificate file from `ric register`', './bot.ric.json')
.requiredOption('--authority <authority>', 'Target host, e.g. "example.com"')
.option('--method <method>', 'HTTP method', 'GET')
.option('--path <path>', 'Request path, e.g. "/api/articles"', '/')
.option('--label <label>', 'Signature label (default: ric)', 'ric')
.option('--ttl <seconds>', 'Signature lifetime in seconds', '300')
.action(async (opts) => {
if (!fs.existsSync(opts.cert)) {
console.error(`Certificate file not found: opts.cert`)
process.exit(1)
}
const cert = JSON.parse(fs.readFileSync(opts.cert, 'utf8'))
const { id, private_key_hex } = cert
if (!id || !private_key_hex) {
console.error('Invalid certificate file — missing id or private_key_hex')
process.exit(1)
}
const createdSec = Math.floor(Date.now() / 1000)
const expiresSec = createdSec + parseInt(opts.ttl, 10)
const nonce = randomBytes(16).toString('base64url')
const label = opts.label
const components = ['@authority', '@method', '@path']
// Build signature params (the part after "label=" in Signature-Input)
const sigInputParams = [
`(components.map((c) => `"${c"`).join(' ')})`,
`keyid="id"`,
`created=createdSec`,
`expires=expiresSec`,
`nonce="nonce"`,
`tag="web-bot-auth"`,
].join(';')
// Build signature base (what gets signed)
const sigBase = [
`"@authority": opts.authority`,
`"@method": opts.method.toUpperCase()`,
`"@path": opts.path`,
`"@signature-params": sigInputParams`,
].join('\n')
const msgBytes = new TextEncoder().encode(sigBase)
const privBytes = Buffer.from(private_key_hex, 'hex')
const sigBytes = await ed.sign(msgBytes, privBytes)
const sigB64 = Buffer.from(sigBytes).toString('base64')
const signatureInput = `label=sigInputParams`
const signature = `label=:sigB64:`
const signatureAgent = `"cert.bot?.name ?? 'RIC-Bot'"; cert="REGISTRY/v1/bots/id"`
console.log('\n── RFC 9421 Request Headers ─────────────────────────────')
console.log(`Signature-Input: signatureInput`)
console.log(`Signature: signature`)
console.log(`Signature-Agent: signatureAgent`)
console.log('─────────────────────────────────────────────────────────\n')
console.log('Signature base (for debugging):')
console.log(sigBase)
})
program.parse()
FILE:packages/cli/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
FILE:packages/dashboard/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Robot ID Card — Bot Registry</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
FILE:packages/dashboard/netlify.toml
[build]
base = "packages/dashboard"
command = "npm run build"
publish = "dist"
[build.environment]
VITE_REGISTRY_URL = "https://ric-registry.onrender.com"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
FILE:packages/dashboard/package.json
{
"name": "@robot-id-card/dashboard",
"version": "0.1.0",
"description": "Public web dashboard for browsing registered RIC bots",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "echo no tests"
},
"devDependencies": {
"vite": "^5.4.0",
"typescript": "^5.4.0"
}
}
FILE:packages/dashboard/public/icon.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0f1117"/>
<rect x="8" y="12" width="16" height="14" rx="3" fill="#6366f1" opacity="0.9"/>
<rect x="13" y="8" width="6" height="5" rx="2" fill="#818cf8"/>
<circle cx="12" cy="17" r="2" fill="#0f1117"/>
<circle cx="20" cy="17" r="2" fill="#0f1117"/>
<rect x="12" y="21" width="8" height="2" rx="1" fill="#0f1117"/>
<rect x="4" y="16" width="3" height="6" rx="1.5" fill="#6366f1"/>
<rect x="25" y="16" width="3" height="6" rx="1.5" fill="#6366f1"/>
</svg>
FILE:packages/dashboard/src/api.ts
import type { BotSummary, BotDetail } from './types.js'
const REGISTRY_URL = import.meta.env.VITE_REGISTRY_URL || 'http://localhost:3000'
export interface BotsResponse {
total: number
page: number
limit: number
pages: number
bots: BotSummary[]
}
export async function fetchBots(params?: { grade?: string; page?: number; limit?: number }): Promise<BotsResponse> {
const qs = new URLSearchParams()
if (params?.grade) qs.set('grade', params.grade)
if (params?.page) qs.set('page', String(params.page))
if (params?.limit) qs.set('limit', String(params.limit))
const query = qs.toString() ? `?qs` : ''
const res = await fetch(`REGISTRY_URL/v1/botsquery`)
if (!res.ok) throw new Error(`Registry error: res.status`)
return res.json()
}
export async function fetchBot(id: string): Promise<BotDetail> {
const res = await fetch(`REGISTRY_URL/v1/bots/id`)
if (!res.ok) throw new Error(`Bot not found: id`)
return res.json()
}
export interface AuditEvent {
id: string
ric_id: string
event: string
old_grade?: string
new_grade?: string
reason?: string
reporter?: string
description?: string
timestamp: string
}
export interface AuditLogResponse {
ric_id: string
total: number
events: AuditEvent[]
}
export async function fetchAuditLog(id: string): Promise<AuditLogResponse> {
const res = await fetch(`REGISTRY_URL/v1/audit/id`)
if (!res.ok) throw new Error(`Audit log unavailable: id`)
return res.json()
}
FILE:packages/dashboard/src/components.ts
import type { BotSummary, BotDetail, Grade } from './types.js'
import type { AuditEvent } from './api.js'
const GRADE_CONFIG: Record<Grade, { label: string; color: string; icon: string }> = {
healthy: { label: 'Healthy', color: '#22c55e', icon: '✓' },
unknown: { label: 'Unknown', color: '#f59e0b', icon: '?' },
dangerous: { label: 'Dangerous', color: '#ef4444', icon: '✗' },
}
const CAP_LABELS: Record<string, string> = {
read_articles: 'Read Articles',
read_images: 'Read Images',
follow_links: 'Follow Links',
view_threads: 'View Threads',
react: 'React / Like',
post_content: 'Post Content',
direct_chat: 'Direct Chat',
}
export function gradeBadge(grade: Grade): string {
const cfg = GRADE_CONFIG[grade]
return `<span class="grade-badge grade-grade">
<span class="grade-icon">cfg.icon</span>
cfg.label
</span>`
}
export function capabilityTag(cap: string): string {
return `<span class="cap-tag">CAP_LABELS[cap] ?? cap</span>`
}
export function relativeTime(isoDate: string): string {
const diff = Date.now() - new Date(isoDate).getTime()
const days = Math.floor(diff / 86_400_000)
if (days === 0) return 'Today'
if (days === 1) return 'Yesterday'
if (days < 30) return `days days ago`
const months = Math.floor(days / 30)
return months === 1 ? '1 month ago' : `months months ago`
}
export function renderBotCard(bot: BotSummary): string {
return `
<article class="bot-card" data-id="bot.id" role="button" tabindex="0">
<div class="bot-card-header">
<div class="bot-avatar">bot.name.charAt(0).toUpperCase()</div>
<div class="bot-card-title">
<h3>escapeHtml(bot.name)</h3>
<span class="bot-org">escapeHtml(bot.developer_org)</span>
</div>
gradeBadge(bot.grade)
</div>
<p class="bot-purpose">escapeHtml(truncate(bot.purpose, 120))</p>
<div class="bot-card-footer">
<code class="ric-id">bot.id</code>
<span class="bot-age">relativeTime(bot.created_at)</span>
</div>
</article>`
}
export function renderBotDetail(bot: BotDetail): string {
const caps = bot.bot.capabilities.map(capabilityTag).join('')
return `
<div class="detail-panel">
<button class="back-btn" id="back-btn">← Back to registry</button>
<div class="detail-header">
<div class="detail-avatar">bot.bot.name.charAt(0).toUpperCase()</div>
<div>
<h2>escapeHtml(bot.bot.name)</h2>
<span class="detail-version">vescapeHtml(bot.bot.version)</span>
gradeBadge(bot.grade)
</div>
</div>
<section class="detail-section">
<h4>Purpose</h4>
<p>escapeHtml(bot.bot.purpose)</p>
</section>
<section class="detail-section">
<h4>Capabilities</h4>
<div class="cap-tags">caps</div>
</section>
<section class="detail-section">
<h4>Developer</h4>
<dl class="detail-dl">
<dt>Name</dt><dd>escapeHtml(bot.developer.name)</dd>
bot.developer.org ? `<dt>Org</dt><dd>${escapeHtml(bot.developer.org)</dd>` : ''}
bot.developer.website ? `<dt>Website</dt><dd><a href="${escapeHtml(bot.developer.website)" target="_blank" rel="noopener">escapeHtml(bot.developer.website)</a></dd>` : ''}
<dt>Verified</dt><dd>'✗ No'</dd>
</dl>
</section>
<section class="detail-section">
<h4>Identity</h4>
<dl class="detail-dl">
<dt>RIC ID</dt><dd><code>bot.id</code></dd>
<dt>Public Key</dt><dd><code class="pubkey">bot.public_key</code></dd>
<dt>Registered</dt><dd>'numeric', month:'long', day:'numeric')}</dd>
<dt>Grade Updated</dt><dd>relativeTime(bot.grade_updated_at)</dd>
</dl>
</section>
<section class="detail-section">
<h4>User Agent</h4>
<code class="user-agent">escapeHtml(bot.bot.user_agent)</code>
</section>
<section class="detail-section">
<h4>Audit Log</h4>
<div id="audit-log-container">
<div class="audit-loading">
<div class="spinner" style="width:20px;height:20px;border-width:2px"></div>
</div>
</div>
</section>
</div>`
}
const EVENT_LABELS: Record<string, { icon: string; label: string; color: string }> = {
registered: { icon: '🆕', label: 'Registered', color: '#6366f1' },
grade_changed: { icon: '⚡', label: 'Grade Changed', color: '#f59e0b' },
violation_report: { icon: '🚨', label: 'Violation Report', color: '#ef4444' },
}
export function renderAuditLog(events: AuditEvent[]): string {
if (events.length === 0) {
return `<p style="color:var(--text-muted);font-size:0.875rem">No audit events recorded.</p>`
}
return `<ol class="audit-list">
'📋', label: e.event, color: '#64748b'
const gradeChange = e.old_grade && e.new_grade
? ` <span style="color:var(--text-muted)">e.old_grade → e.new_grade</span>` : ''
return `<li class="audit-item">
<span class="audit-icon">cfg.icon</span>
<div class="audit-body">
<div class="audit-title" style="color:cfg.color">cfg.labelgradeChange</div>
e.reason ? `<div class="audit-reason">${escapeHtml(e.reason)</div>` : ''}
${escapeHtml(e.reporter)</div>` : ''}
<div class="audit-meta">new Date(e.timestamp).toLocaleString()</div>
</div>
</li>`
}).join('')}
</ol>`
}
export function renderSkeleton(count = 6): string {
return Array(count).fill(0).map(() => `
<div class="bot-card skeleton">
<div class="skeleton-line w-60"></div>
<div class="skeleton-line w-100 mt8"></div>
<div class="skeleton-line w-80 mt4"></div>
<div class="skeleton-line w-40 mt8"></div>
</div>`).join('')
}
export function renderError(message: string): string {
return `<div class="error-state">
<div class="error-icon">⚠</div>
<p>escapeHtml(message)</p>
<button class="retry-btn" id="retry-btn">Retry</button>
</div>`
}
export function renderEmpty(): string {
return `<div class="empty-state">
<div class="empty-icon">🤖</div>
<p>No bots registered yet.</p>
<p>Be the first — <code>ric register</code></p>
</div>`
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
function truncate(s: string, max: number): string {
return s.length <= max ? s : s.slice(0, max) + '…'
}
FILE:packages/dashboard/src/main.ts
import './style.css'
import { fetchBots, fetchBot, fetchAuditLog, type BotsResponse } from './api.js'
import {
renderBotCard,
renderBotDetail,
renderAuditLog,
renderSkeleton,
renderError,
renderEmpty,
} from './components.js'
import type { BotSummary } from './types.js'
// ── App state ────────────────────────────────────────────────────────────────
let allBots: BotSummary[] = []
let totalBots = 0
let currentPage = 1
let totalPages = 1
let searchQuery = ''
let currentView: 'list' | 'detail' = 'list'
// ── DOM refs ─────────────────────────────────────────────────────────────────
const app = document.getElementById('app')!
// ── Render ───────────────────────────────────────────────────────────────────
function renderApp() {
app.innerHTML = `
<header class="site-header">
<a href="#" class="logo" id="logo">
<span class="logo-icon">🤖</span>
<span>Robot ID Card</span>
</a>
<nav class="header-nav">
<a href="https://github.com/Cosmofang/robot-id-card" target="_blank" rel="noopener">GitHub</a>
<a href="https://github.com/Cosmofang/robot-id-card/blob/main/docs/spec-v1.md" target="_blank" rel="noopener">Spec</a>
</nav>
</header>
<main class="main-content" id="main-content">
''
</main>
<footer class="site-footer">
<p>Robot ID Card — Universal identity standard for AI agents & bots.</p>
<p><a href="https://github.com/Cosmofang/robot-id-card" target="_blank" rel="noopener">Open source on GitHub</a></p>
</footer>`
bindEvents()
}
function renderListView(): string {
const filtered = filterBots(allBots, searchQuery)
return `
<div class="list-view">
<div class="hero">
<h1>Bot Registry</h1>
<p>Publicly registered AI agents & bots with verified identities.</p>
</div>
<div class="controls">
<div class="search-box">
<span class="search-icon">🔍</span>
<input
type="search"
id="search-input"
placeholder="Search by name, org, or purpose…"
value="searchQuery"
autocomplete="off"
/>
</div>
<div class="grade-filters">
<button class="filter-btn active" data-grade="all">All</button>
<button class="filter-btn" data-grade="healthy">✓ Healthy</button>
<button class="filter-btn" data-grade="unknown">? Unknown</button>
<button class="filter-btn" data-grade="dangerous">✗ Dangerous</button>
</div>
</div>
<div class="stats-bar">
<span>totalBots registered bots</span>
<span>allBots.filter(b => b.grade === 'healthy').length healthy</span>
<span>allBots.filter(b => b.grade === 'dangerous').length flagged</span>
</div>
<div class="bot-grid" id="bot-grid">
renderGrid(filtered)
</div>
''>← Prev</button>
<span class="page-info">Page currentPage / totalPages</span>
<button class="page-btn" id="next-page" ''>Next →</button>
</div>` : ''}
</div>`
}
function renderGrid(bots: BotSummary[]): string {
if (bots.length === 0 && searchQuery) {
return `<div class="empty-state"><p>No bots match "<strong>searchQuery</strong>"</p></div>`
}
if (bots.length === 0) return renderEmpty()
return bots.map(renderBotCard).join('')
}
function filterBots(bots: BotSummary[], query: string, grade?: string): BotSummary[] {
let result = bots
if (grade && grade !== 'all') {
result = result.filter(b => b.grade === grade)
}
if (!query.trim()) return result
const q = query.toLowerCase()
return result.filter(b =>
b.name.toLowerCase().includes(q) ||
b.developer_org.toLowerCase().includes(q) ||
b.purpose.toLowerCase().includes(q) ||
b.id.toLowerCase().includes(q)
)
}
// ── Events ───────────────────────────────────────────────────────────────────
let activeGrade = 'all'
function bindEvents() {
// Logo → back to list
document.getElementById('logo')?.addEventListener('click', (e) => {
e.preventDefault()
currentView = 'list'
renderApp()
})
// Search
const searchInput = document.getElementById('search-input') as HTMLInputElement | null
searchInput?.addEventListener('input', (e) => {
searchQuery = (e.target as HTMLInputElement).value
const grid = document.getElementById('bot-grid')
if (grid) grid.innerHTML = renderGrid(filterBots(allBots, searchQuery, activeGrade))
})
// Pagination
document.getElementById('prev-page')?.addEventListener('click', async () => {
if (currentPage > 1) { currentPage--; await loadPage() }
})
document.getElementById('next-page')?.addEventListener('click', async () => {
if (currentPage < totalPages) { currentPage++; await loadPage() }
})
// Grade filters
document.querySelectorAll<HTMLButtonElement>('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'))
btn.classList.add('active')
activeGrade = btn.dataset.grade ?? 'all'
const grid = document.getElementById('bot-grid')
if (grid) grid.innerHTML = renderGrid(filterBots(allBots, searchQuery, activeGrade))
})
})
// Bot card click → detail view
document.querySelectorAll<HTMLElement>('.bot-card:not(.skeleton)').forEach(card => {
const openDetail = async () => {
const id = card.dataset.id
if (!id) return
const main = document.getElementById('main-content')!
main.innerHTML = `<div class="loading-detail"><div class="spinner"></div></div>`
try {
const bot = await fetchBot(id)
currentView = 'detail'
main.innerHTML = renderBotDetail(bot)
document.getElementById('back-btn')?.addEventListener('click', () => {
currentView = 'list'
renderApp()
})
// Async load audit log after detail renders
fetchAuditLog(id).then(log => {
const container = document.getElementById('audit-log-container')
if (container) container.innerHTML = renderAuditLog(log.events)
}).catch(() => {
const container = document.getElementById('audit-log-container')
if (container) container.innerHTML = `<p style="color:var(--text-muted);font-size:0.875rem">Audit log unavailable.</p>`
})
} catch (err) {
main.innerHTML = renderError(`Failed to load bot details: 'Unknown error'`)
document.getElementById('retry-btn')?.addEventListener('click', () => renderApp())
}
}
card.addEventListener('click', openDetail)
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetail() }
})
})
}
// ── Data loading ─────────────────────────────────────────────────────────────
async function loadPage() {
const main = document.getElementById('main-content') || document.querySelector('.main-content')!
if (main) {
const grid = main.querySelector('.bot-grid')
if (grid) grid.innerHTML = renderSkeleton(6)
}
const data: BotsResponse = await fetchBots({ page: currentPage, limit: 50 })
allBots = data.bots
totalBots = data.total
totalPages = data.pages
renderApp()
}
// ── Bootstrap ────────────────────────────────────────────────────────────────
async function init() {
// Render shell with skeleton
app.innerHTML = `
<header class="site-header">
<a href="#" class="logo" id="logo">
<span class="logo-icon">🤖</span>
<span>Robot ID Card</span>
</a>
<nav class="header-nav">
<a href="https://github.com/Cosmofang/robot-id-card" target="_blank" rel="noopener">GitHub</a>
<a href="https://github.com/Cosmofang/robot-id-card/blob/main/docs/spec-v1.md" target="_blank" rel="noopener">Spec</a>
</nav>
</header>
<main class="main-content">
<div class="list-view">
<div class="hero"><h1>Bot Registry</h1><p>Loading…</p></div>
<div class="bot-grid">renderSkeleton(6)</div>
</div>
</main>`
try {
await loadPage()
} catch {
app.querySelector('.main-content')!.innerHTML = renderError(
'Could not connect to the registry. Is the registry server running?'
)
document.getElementById('retry-btn')?.addEventListener('click', init)
}
}
init()
FILE:packages/dashboard/src/style.css
/* ── Reset & Base ─────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--bg-card: #1a1d26;
--bg-hover: #21242f;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
--accent: #6366f1;
--accent-dim: rgba(99,102,241,0.15);
--healthy: #22c55e;
--unknown: #f59e0b;
--dangerous: #ef4444;
--radius: 10px;
--shadow: 0 1px 3px rgba(0,0,0,0.4);
}
html { font-size: 16px; scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.6;
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.85em; }
/* ── Layout ───────────────────────────────────────────────── */
#app { display: flex; flex-direction: column; min-height: 100vh; }
.main-content {
flex: 1;
max-width: 1100px;
margin: 0 auto;
width: 100%;
padding: 0 24px 64px;
}
/* ── Header ───────────────────────────────────────────────── */
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px;
border-bottom: 1px solid var(--border);
background: rgba(15,17,23,0.9);
backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 100;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 1.1rem;
color: var(--text) !important;
text-decoration: none !important;
}
.logo-icon { font-size: 1.4rem; }
.header-nav { display: flex; gap: 24px; }
.header-nav a { color: var(--text-muted); font-size: 0.9rem; transition: color 0.15s; }
.header-nav a:hover { color: var(--text); text-decoration: none; }
/* ── Hero ─────────────────────────────────────────────────── */
.hero {
padding: 48px 0 32px;
text-align: center;
}
.hero h1 {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #818cf8, #c084fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
}
.hero p { color: var(--text-muted); font-size: 1.1rem; }
/* ── Controls ─────────────────────────────────────────────── */
.controls {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.search-box {
position: relative;
flex: 1;
min-width: 240px;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
font-size: 0.9rem;
pointer-events: none;
}
.search-box input {
width: 100%;
padding: 10px 14px 10px 36px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 0.95rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.search-box input:focus { border-color: var(--accent); }
.search-box input::placeholder { color: var(--text-muted); }
.grade-filters { display: flex; gap: 8px; flex-wrap: wrap; }
.filter-btn {
padding: 8px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.filter-btn:hover { background: var(--bg-hover); color: var(--text); }
.filter-btn.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
/* ── Stats bar ────────────────────────────────────────────── */
.stats-bar {
display: flex;
gap: 24px;
margin-bottom: 24px;
color: var(--text-muted);
font-size: 0.85rem;
}
/* ── Bot Grid ─────────────────────────────────────────────── */
.bot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
/* ── Bot Card ─────────────────────────────────────────────── */
.bot-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.1s;
display: flex;
flex-direction: column;
gap: 12px;
}
.bot-card:hover {
border-color: var(--accent);
background: var(--bg-hover);
transform: translateY(-1px);
}
.bot-card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.bot-card-header {
display: flex;
align-items: flex-start;
gap: 12px;
}
.bot-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
background: var(--accent-dim);
border: 1px solid var(--accent);
color: var(--accent);
font-weight: 700;
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.bot-card-title {
flex: 1;
min-width: 0;
}
.bot-card-title h3 {
font-size: 1rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bot-org {
font-size: 0.8rem;
color: var(--text-muted);
}
.bot-purpose {
font-size: 0.875rem;
color: var(--text-muted);
line-height: 1.5;
flex: 1;
}
.bot-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: auto;
}
.ric-id {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.bot-age { font-size: 0.75rem; color: var(--text-muted); flex-shrink: 0; }
/* ── Grade Badge ──────────────────────────────────────────── */
.grade-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.grade-healthy { background: rgba(34,197,94,0.15); color: var(--healthy); border: 1px solid rgba(34,197,94,0.3); }
.grade-unknown { background: rgba(245,158,11,0.15); color: var(--unknown); border: 1px solid rgba(245,158,11,0.3); }
.grade-dangerous { background: rgba(239,68,68,0.15); color: var(--dangerous); border: 1px solid rgba(239,68,68,0.3); }
.grade-icon { font-size: 0.85em; }
/* ── Skeleton ─────────────────────────────────────────────── */
.skeleton { cursor: default; pointer-events: none; }
.skeleton-line {
height: 14px;
border-radius: 6px;
background: linear-gradient(90deg, var(--border) 25%, var(--bg-hover) 50%, var(--border) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
.w-60 { width: 60%; }
.w-80 { width: 80%; }
.w-100 { width: 100%; }
.w-40 { width: 40%; }
.mt4 { margin-top: 4px; }
.mt8 { margin-top: 8px; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ── Empty / Error States ─────────────────────────────────── */
.empty-state, .error-state {
grid-column: 1 / -1;
text-align: center;
padding: 64px 24px;
color: var(--text-muted);
}
.empty-icon, .error-icon { font-size: 3rem; margin-bottom: 16px; }
.error-icon { color: var(--dangerous); }
.retry-btn {
margin-top: 16px;
padding: 10px 24px;
background: var(--accent);
border: none;
border-radius: var(--radius);
color: white;
font-size: 0.9rem;
font-family: inherit;
cursor: pointer;
transition: opacity 0.15s;
}
.retry-btn:hover { opacity: 0.85; }
/* ── Loading Detail ───────────────────────────────────────── */
.loading-detail {
display: flex;
justify-content: center;
padding: 80px;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Detail Panel ─────────────────────────────────────────── */
.detail-panel {
max-width: 720px;
margin: 0 auto;
padding: 32px 0;
}
.back-btn {
background: none;
border: none;
color: var(--accent);
font-size: 0.9rem;
font-family: inherit;
cursor: pointer;
padding: 0;
margin-bottom: 32px;
display: block;
transition: opacity 0.15s;
}
.back-btn:hover { opacity: 0.75; }
.detail-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 32px;
flex-wrap: wrap;
}
.detail-avatar {
width: 64px;
height: 64px;
border-radius: 16px;
background: var(--accent-dim);
border: 2px solid var(--accent);
color: var(--accent);
font-weight: 700;
font-size: 1.8rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.detail-header h2 { font-size: 1.6rem; font-weight: 700; }
.detail-version { font-size: 0.85rem; color: var(--text-muted); margin-right: 12px; }
.detail-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
margin-bottom: 16px;
}
.detail-section h4 {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 12px;
}
.detail-section p { color: var(--text); line-height: 1.65; }
.detail-dl {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px 16px;
}
.detail-dl dt { color: var(--text-muted); font-size: 0.875rem; }
.detail-dl dd { color: var(--text); font-size: 0.875rem; word-break: break-all; }
.detail-dl code { font-size: 0.8em; }
.pubkey {
display: block;
word-break: break-all;
font-size: 0.75rem;
color: var(--text-muted);
background: var(--bg);
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border);
}
.user-agent {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
background: var(--bg);
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--border);
}
/* ── Capability Tags ──────────────────────────────────────── */
.cap-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.cap-tag {
padding: 4px 12px;
background: var(--accent-dim);
border: 1px solid rgba(99,102,241,0.3);
border-radius: 999px;
font-size: 0.8rem;
color: #a5b4fc;
}
/* ── Audit Log ────────────────────────────────────────────── */
.audit-loading {
display: flex;
justify-content: center;
padding: 16px 0;
}
.audit-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.audit-item {
display: flex;
gap: 12px;
align-items: flex-start;
}
.audit-icon { font-size: 1.1rem; flex-shrink: 0; margin-top: 1px; }
.audit-body { flex: 1; min-width: 0; }
.audit-title { font-size: 0.875rem; font-weight: 600; margin-bottom: 2px; }
.audit-reason { font-size: 0.8rem; color: var(--text); margin-bottom: 2px; }
.audit-meta { font-size: 0.75rem; color: var(--text-muted); }
/* ── Pagination ───────────────────────────────────────────── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 32px;
padding: 16px 0;
}
.page-btn {
padding: 8px 20px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: inherit;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
}
.page-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.page-info { color: var(--text-muted); font-size: 0.875rem; }
/* ── Footer ───────────────────────────────────────────────── */
.site-footer {
text-align: center;
padding: 32px;
border-top: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.85rem;
line-height: 2;
}
/* ── Responsive ───────────────────────────────────────────── */
@media (max-width: 600px) {
.site-header { padding: 14px 16px; }
.main-content { padding: 0 16px 48px; }
.hero h1 { font-size: 1.8rem; }
.controls { flex-direction: column; align-items: stretch; }
.bot-grid { grid-template-columns: 1fr; }
.detail-panel { padding: 24px 0; }
.detail-dl { grid-template-columns: 1fr; }
}
FILE:packages/dashboard/src/types.ts
export type Grade = 'unknown' | 'healthy' | 'dangerous'
export interface BotSummary {
id: string
name: string
purpose: string
grade: Grade
developer_org: string
created_at: string
}
export interface BotDetail {
ric_version: string
id: string
created_at: string
grade: Grade
grade_updated_at: string
public_key: string
developer: {
name: string
email: string
org?: string
website?: string
verified: boolean
}
bot: {
name: string
version: string
purpose: string
capabilities: string[]
user_agent: string
}
}
FILE:packages/dashboard/vercel.json
{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"installCommand": "npm install",
"framework": null,
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],
"env": {
"VITE_REGISTRY_URL": "https://ric-registry.onrender.com"
}
}
FILE:packages/dashboard/vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
server: {
port: 5173,
proxy: {
'/v1': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
})
FILE:packages/extension/manifest.json
{
"manifest_version": 3,
"name": "Robot ID Card",
"version": "0.1.0",
"description": "Carry your bot's RIC identity certificate in every web request",
"permissions": [
"storage",
"declarativeNetRequest",
"alarms"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
FILE:packages/extension/package.json
{
"name": "@robot-id-card/extension",
"version": "0.2.0",
"description": "Browser extension for carrying Robot ID Card bot identity",
"private": true,
"scripts": {
"build": "npm run bundle && cp -r public/* dist/ && cp manifest.json dist/",
"bundle": "esbuild src/background.ts src/popup.ts --bundle --outdir=dist --target=chrome109 --platform=browser",
"dev": "esbuild src/background.ts src/popup.ts --bundle --outdir=dist --target=chrome109 --platform=browser --watch",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",
"test": "echo no tests"
},
"dependencies": {
"@noble/ed25519": "^2.1.0"
},
"devDependencies": {
"@types/chrome": "^0.0.268",
"esbuild": "^0.24.2",
"typescript": "^5.4.0"
}
}
FILE:packages/extension/public/popup.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Robot ID Card</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
width: 320px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #e2e8f0;
}
.header {
padding: 16px;
background: #1e293b;
border-bottom: 1px solid #334155;
display: flex;
align-items: center;
gap: 10px;
}
.header h1 { font-size: 15px; font-weight: 600; }
.header .logo { font-size: 20px; }
.body { padding: 16px; }
.status-card {
background: #1e293b;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 12px;
border: 1px solid #334155;
}
.grade-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.grade-healthy { background: #14532d; color: #4ade80; }
.grade-unknown { background: #713f12; color: #fbbf24; }
.grade-dangerous { background: #7f1d1d; color: #f87171; }
.grade-none { background: #1e293b; color: #94a3b8; border: 1px solid #475569; }
.field { margin-bottom: 8px; }
.field label { font-size: 11px; color: #64748b; display: block; margin-bottom: 3px; }
.field value { font-size: 13px; font-family: monospace; color: #cbd5e1; }
.permission-bar {
background: #0f172a;
border-radius: 6px;
padding: 10px;
margin-top: 8px;
}
.permission-bar .label { font-size: 11px; color: #64748b; margin-bottom: 6px; }
.levels { display: flex; gap: 4px; }
.level-dot {
width: 28px; height: 8px;
border-radius: 4px;
background: #334155;
}
.level-dot.active { background: #3b82f6; }
button {
width: 100%;
padding: 10px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 13px;
font-weight: 500;
}
.btn-primary { background: #3b82f6; color: white; margin-bottom: 8px; }
.btn-secondary { background: #1e293b; color: #94a3b8; border: 1px solid #334155; }
.btn-danger { background: #1e293b; color: #f87171; border: 1px solid #7f1d1d; }
.not-configured {
text-align: center;
padding: 24px 16px;
color: #64748b;
}
.not-configured p { font-size: 13px; margin-bottom: 16px; }
</style>
</head>
<body>
<div class="header">
<span class="logo">🤖</span>
<h1>Robot ID Card</h1>
</div>
<div class="body" id="app">
<!-- Populated by popup.js -->
<div class="not-configured">
<p>No identity configured.<br/>Register your bot to get started.</p>
<button class="btn-primary" id="btn-register">Register a Bot</button>
<button class="btn-secondary" id="btn-import">Import Certificate</button>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>
FILE:packages/extension/src/background.ts
/**
* RIC Browser Extension — Background Service Worker (Manifest V3)
*
* Injects RIC identity headers into all outgoing HTTPS requests using
* chrome.declarativeNetRequest (MV3-compatible, replaces the MV2
* chrome.webRequest blocking approach).
*
* Strategy:
* - Headers include a timestamp-based Ed25519 signature.
* - chrome.alarms fires every REFRESH_MINUTES to regenerate the
* signature, keeping it inside the registry's 5-minute replay window.
*/
import * as ed from '@noble/ed25519'
interface RICConfig {
ricId: string
privateKeyHex: string
certificate: object
}
const HEADER_RULE_ID = 1
const REFRESH_ALARM = 'ric-header-refresh'
const REFRESH_MINUTES = 4 // must stay < registry's 5-min replay window; match expires TTL
// ── Hex utilities (Buffer is not available in service workers) ─
function hexToBytes(hex: string): Uint8Array {
const arr = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
arr[i / 2] = parseInt(hex.slice(i, i + 2), 16)
}
return arr
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
// ── Config ────────────────────────────────────────────────────
async function getConfig(): Promise<RICConfig | null> {
const result = await chrome.storage.local.get(['ricId', 'privateKeyHex', 'certificate'])
if (!result.ricId || !result.privateKeyHex) return null
return result as RICConfig
}
// ── Header injection ──────────────────────────────────────────
/**
* Generate RFC 9421-compliant headers and update the declarativeNetRequest
* dynamic rule so all subsequent requests carry valid, unexpired headers.
*
* Note: declarativeNetRequest uses static rules (same headers for all URLs in
* this cycle). We sign "@authority" only since the per-request path is unknown
* at rule-creation time. The expires field matches the refresh alarm interval.
*/
async function refreshHeaders(): Promise<void> {
const config = await getConfig()
if (!config) {
await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [HEADER_RULE_ID] })
return
}
const createdSec = Math.floor(Date.now() / 1000)
const expiresSec = createdSec + REFRESH_MINUTES * 60
// Nonce: random 12 bytes base64url — unique per refresh cycle
const nonceBytes = crypto.getRandomValues(new Uint8Array(12))
const nonce = btoa(String.fromCharCode(...nonceBytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
const label = 'ric'
const components = ['@authority']
// Signature-Input params (everything after "label=")
const sigInputParams = [
`(components.map((c) => `"${c"`).join(' ')})`,
`keyid="config.ricId"`,
`created=createdSec`,
`expires=expiresSec`,
`nonce="nonce"`,
`tag="web-bot-auth"`,
].join(';')
// Signature base: signed against a placeholder authority
// The @authority field will mismatch per-site — this is the browser extension limitation.
// Websites can choose to verify only keyid + timestamp + nonce without @authority.
const sigBase = [
`"@authority": *`, // wildcard placeholder — extension can't know target per-rule
`"@signature-params": sigInputParams`,
].join('\n')
const msgBytes = new TextEncoder().encode(sigBase)
const privBytes = hexToBytes(config.privateKeyHex)
const sigBytes = await ed.sign(msgBytes, privBytes)
// RFC 9421 uses standard base64 (not base64url) for the signature value
const sigB64 = btoa(String.fromCharCode(...sigBytes))
const signatureInput = `label=sigInputParams`
const signature = `label=:sigB64:`
const signatureAgent = `"RIC-Extension"; cert="https://registry.robotidcard.dev/v1/bots/config.ricId"`
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [HEADER_RULE_ID],
addRules: [
{
id: HEADER_RULE_ID,
priority: 1,
action: {
type: 'modifyHeaders' as chrome.declarativeNetRequest.RuleActionType,
requestHeaders: [
{ header: 'Signature-Input', operation: 'set' as chrome.declarativeNetRequest.HeaderOperation, value: signatureInput },
{ header: 'Signature', operation: 'set' as chrome.declarativeNetRequest.HeaderOperation, value: signature },
{ header: 'Signature-Agent', operation: 'set' as chrome.declarativeNetRequest.HeaderOperation, value: signatureAgent },
],
},
condition: {
urlFilter: '|https://',
resourceTypes: [
'xmlhttprequest' as chrome.declarativeNetRequest.ResourceType,
'main_frame' as chrome.declarativeNetRequest.ResourceType,
'sub_frame' as chrome.declarativeNetRequest.ResourceType,
],
},
},
],
})
}
// ── Lifecycle ─────────────────────────────────────────────────
chrome.runtime.onInstalled.addListener(async () => {
await chrome.alarms.create(REFRESH_ALARM, { periodInMinutes: REFRESH_MINUTES })
await refreshHeaders()
})
chrome.runtime.onStartup.addListener(async () => {
// Re-create alarm in case it was cleared during browser restart
await chrome.alarms.create(REFRESH_ALARM, { periodInMinutes: REFRESH_MINUTES })
await refreshHeaders()
})
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === REFRESH_ALARM) {
refreshHeaders()
}
})
// ── Messages from popup ───────────────────────────────────────
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'GET_STATUS') {
getConfig().then((config) => {
sendResponse({ configured: !!config, ricId: config?.ricId })
})
return true // keep channel open for async response
}
if (message.type === 'SAVE_CONFIG') {
chrome.storage.local.set(message.config).then(async () => {
await refreshHeaders()
sendResponse({ success: true })
})
return true
}
if (message.type === 'CLEAR_CONFIG') {
chrome.storage.local.remove(['ricId', 'privateKeyHex', 'certificate']).then(async () => {
await refreshHeaders() // clears the inject rule
sendResponse({ success: true })
})
return true
}
})
FILE:packages/extension/src/popup.ts
/**
* RIC Popup — shows current bot identity status and handles import/disconnect
*/
const GRADE_CONFIG = {
healthy: { emoji: '🟢', label: 'Healthy', cssClass: 'grade-healthy' },
unknown: { emoji: '🟡', label: 'Unknown', cssClass: 'grade-unknown' },
dangerous: { emoji: '🔴', label: 'Dangerous', cssClass: 'grade-dangerous' },
}
const PERMISSION_LABELS = ['Blocked', 'Read', 'View Threads', 'React', 'Post', 'Chat']
// ── Helpers ───────────────────────────────────────────────────
async function getStatus(): Promise<{ configured: boolean; ricId?: string }> {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, resolve)
})
}
async function fetchCert(ricId: string) {
try {
const res = await fetch(`https://registry.robotidcard.dev/v1/bots/ricId`)
if (!res.ok) return null
return res.json()
} catch {
return null
}
}
function sendMessage(msg: object): Promise<{ success: boolean }> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(msg, resolve)
})
}
// ── Render: configured state ──────────────────────────────────
function renderConfigured(cert: any) {
const grade = GRADE_CONFIG[cert.grade as keyof typeof GRADE_CONFIG] || GRADE_CONFIG.unknown
const permLevel = cert.grade === 'healthy' ? 3 : cert.grade === 'unknown' ? 1 : 0
const app = document.getElementById('app')!
app.innerHTML = `
<div class="status-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px">
<span style="font-size:13px; font-weight:600">cert.bot.name</span>
<span class="grade-badge grade.cssClass">grade.emoji grade.label</span>
</div>
<div class="field">
<label>RIC ID</label>
<value>cert.id</value>
</div>
<div class="field">
<label>Developer</label>
<value>cert.developer.name cert.developer.org ? `· ${cert.developer.org` : ''}</value>
</div>
<div class="field">
<label>Purpose</label>
<value style="font-family:inherit; font-size:12px; color:#94a3b8">cert.bot.purpose</value>
</div>
<div class="permission-bar">
<div class="label">Permission Level: permLevel/5 — PERMISSION_LABELS[permLevel]</div>
<div class="levels">
''"></div>`
).join('')}
</div>
</div>
</div>
<button class="btn-secondary" id="btn-refresh" style="margin-bottom:8px">Refresh Certificate</button>
<button class="btn-danger" id="btn-disconnect">Disconnect Identity</button>
`
document.getElementById('btn-refresh')?.addEventListener('click', () => location.reload())
document.getElementById('btn-disconnect')?.addEventListener('click', async () => {
if (!confirm('Remove your RIC identity from this extension?')) return
await sendMessage({ type: 'CLEAR_CONFIG' })
location.reload()
})
}
// ── Render: import modal ──────────────────────────────────────
function renderImportModal() {
const app = document.getElementById('app')!
app.innerHTML = `
<div style="padding:4px 0 12px; color:#94a3b8; font-size:12px; line-height:1.6">
Paste the contents of your <code style="background:#1e293b;padding:1px 4px;border-radius:3px">bot.ric.json</code>
file (generated by <code style="background:#1e293b;padding:1px 4px;border-radius:3px">ric register</code>):
</div>
<textarea id="cert-input" placeholder='{ "id": "ric_...", "private_key_hex": "...", ... }'
style="width:100%;height:120px;background:#1e293b;border:1px solid #334155;border-radius:6px;
color:#e2e8f0;font-family:monospace;font-size:11px;padding:8px;resize:none;outline:none">
</textarea>
<div id="import-error" style="color:#f87171;font-size:12px;margin-top:6px;display:none"></div>
<div style="display:flex;gap:8px;margin-top:10px">
<button class="btn-primary" id="btn-import-confirm" style="flex:1">Import</button>
<button class="btn-secondary" id="btn-import-cancel" style="flex:1">Cancel</button>
</div>
`
document.getElementById('btn-import-cancel')?.addEventListener('click', () => init())
document.getElementById('btn-import-confirm')?.addEventListener('click', async () => {
const raw = (document.getElementById('cert-input') as HTMLTextAreaElement).value.trim()
const errEl = document.getElementById('import-error')!
let parsed: any
try {
parsed = JSON.parse(raw)
} catch {
errEl.textContent = 'Invalid JSON — paste the full contents of bot.ric.json'
errEl.style.display = 'block'
return
}
if (!parsed.id || !parsed.private_key_hex || !parsed.public_key) {
errEl.textContent = 'Missing required fields: id, private_key_hex, public_key'
errEl.style.display = 'block'
return
}
await sendMessage({
type: 'SAVE_CONFIG',
config: {
ricId: parsed.id,
privateKeyHex: parsed.private_key_hex,
certificate: parsed,
},
})
location.reload()
})
}
// ── Render: register instructions ────────────────────────────
function renderRegisterHelp() {
const app = document.getElementById('app')!
app.innerHTML = `
<div style="font-size:12px;color:#94a3b8;line-height:1.7">
<p style="margin-bottom:10px">Register your bot using the CLI:</p>
<div style="background:#1e293b;border-radius:6px;padding:10px;font-family:monospace;font-size:11px;color:#7dd3fc;line-height:1.8">
npm install -g @robot-id-card/cli<br>
ric keygen<br>
ric register --name "MyBot" \\<br>
--purpose "Your bot's purpose" \\<br>
--developer [email protected]
</div>
<p style="margin-top:10px">Then import the generated <code style="background:#1e293b;padding:1px 4px;border-radius:3px">bot.ric.json</code> file here.</p>
</div>
<button class="btn-secondary" id="btn-back" style="margin-top:12px">← Back</button>
`
document.getElementById('btn-back')?.addEventListener('click', () => init())
}
// ── Init ──────────────────────────────────────────────────────
async function init() {
const status = await getStatus()
if (!status.configured) {
// Restore default not-configured HTML and attach button listeners
const app = document.getElementById('app')!
app.innerHTML = `
<div class="not-configured">
<p>No identity configured.<br/>Register your bot to get started.</p>
<button class="btn-primary" id="btn-register">Register a Bot</button>
<button class="btn-secondary" id="btn-import">Import Certificate</button>
</div>
`
document.getElementById('btn-register')?.addEventListener('click', renderRegisterHelp)
document.getElementById('btn-import')?.addEventListener('click', renderImportModal)
return
}
const cert = status.ricId ? await fetchCert(status.ricId) : null
if (cert) {
renderConfigured(cert)
}
}
document.addEventListener('DOMContentLoaded', init)
FILE:packages/extension/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/popup.ts"],
"exclude": ["node_modules", "dist"]
}
FILE:packages/extension/tsconfig.sw.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "WebWorker"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/background.ts"],
"exclude": ["node_modules", "dist"]
}
FILE:packages/registry/package.json
{
"name": "@robot-id-card/registry",
"version": "0.2.0",
"description": "Central registry server for Robot ID Card — SQLite-backed bot identity database with Ed25519 verification",
"homepage": "https://github.com/Cosmofang/robot-id-card",
"repository": { "type": "git", "url": "https://github.com/Cosmofang/robot-id-card", "directory": "packages/registry" },
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "node ../../node_modules/vitest/vitest.mjs run src"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/rate-limit": "^10.3.0",
"@noble/ed25519": "^2.1.0",
"@noble/hashes": "^2.0.1",
"better-sqlite3": "^12.8.0",
"fastify": "^5.8.2",
"nanoid": "^5.0.6",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"tsx": "^4.7.0",
"typescript": "^5.4.0",
"vitest": "1.6.1"
}
}
FILE:packages/registry/src/__tests__/certificate.test.ts
import { describe, it, expect } from 'vitest'
import { RICCertificateSchema, GradeSchema, getPermissionLevel, type RICCertificate } from '../models/certificate.js'
function makeCert(overrides: Partial<RICCertificate> = {}): RICCertificate {
return {
ric_version: '1.0',
id: 'ric_test123',
created_at: '2026-01-15T10:00:00Z',
developer: {
name: 'Test Dev',
email: '[email protected]',
verified: false,
},
bot: {
name: 'TestBot',
version: '1.0.0',
purpose: 'Integration testing for the registry',
capabilities: ['read_articles'],
user_agent: 'TestBot/1.0 (RIC:ric_test123)',
},
grade: 'unknown',
grade_updated_at: '2026-01-15T10:00:00Z',
public_key: 'ed25519:abc123',
signature: 'test-sig',
...overrides,
} as RICCertificate
}
describe('GradeSchema', () => {
it('accepts valid grades', () => {
expect(GradeSchema.parse('unknown')).toBe('unknown')
expect(GradeSchema.parse('healthy')).toBe('healthy')
expect(GradeSchema.parse('dangerous')).toBe('dangerous')
})
it('rejects invalid grades', () => {
expect(() => GradeSchema.parse('suspicious')).toThrow()
expect(() => GradeSchema.parse('')).toThrow()
})
})
describe('RICCertificateSchema', () => {
it('validates a well-formed certificate', () => {
const cert = makeCert()
const result = RICCertificateSchema.parse(cert)
expect(result.id).toBe('ric_test123')
expect(result.ric_version).toBe('1.0')
})
it('rejects certificate with missing ric_ prefix', () => {
expect(() => RICCertificateSchema.parse(makeCert({ id: 'bad_id' }))).toThrow()
})
it('rejects certificate with invalid email', () => {
const cert = makeCert()
cert.developer.email = 'not-an-email'
expect(() => RICCertificateSchema.parse(cert)).toThrow()
})
it('rejects certificate with purpose too short', () => {
const cert = makeCert()
cert.bot.purpose = 'short'
expect(() => RICCertificateSchema.parse(cert)).toThrow()
})
it('rejects certificate with invalid public_key prefix', () => {
expect(() => RICCertificateSchema.parse(makeCert({ public_key: 'rsa:abc123' }))).toThrow()
})
})
describe('getPermissionLevel', () => {
it('returns 0 for dangerous bots', () => {
expect(getPermissionLevel(makeCert({ grade: 'dangerous' }))).toBe(0)
})
it('returns 1 for unknown bots regardless of capabilities', () => {
const cert = makeCert({ grade: 'unknown' })
cert.bot.capabilities = ['direct_chat', 'post_content']
expect(getPermissionLevel(cert)).toBe(1)
})
it('returns 5 for healthy bot with direct_chat', () => {
const cert = makeCert({ grade: 'healthy' })
cert.bot.capabilities = ['direct_chat']
expect(getPermissionLevel(cert)).toBe(5)
})
it('returns 4 for healthy bot with post_content', () => {
const cert = makeCert({ grade: 'healthy' })
cert.bot.capabilities = ['post_content']
expect(getPermissionLevel(cert)).toBe(4)
})
it('returns 3 for healthy bot with react', () => {
const cert = makeCert({ grade: 'healthy' })
cert.bot.capabilities = ['react']
expect(getPermissionLevel(cert)).toBe(3)
})
it('returns 2 for healthy bot with view_threads', () => {
const cert = makeCert({ grade: 'healthy' })
cert.bot.capabilities = ['view_threads']
expect(getPermissionLevel(cert)).toBe(2)
})
it('returns 1 for healthy bot with only read_articles', () => {
const cert = makeCert({ grade: 'healthy' })
cert.bot.capabilities = ['read_articles']
expect(getPermissionLevel(cert)).toBe(1)
})
it('uses highest capability for permission level', () => {
const cert = makeCert({ grade: 'healthy' })
cert.bot.capabilities = ['read_articles', 'view_threads', 'post_content']
expect(getPermissionLevel(cert)).toBe(4)
})
})
FILE:packages/registry/src/index.ts
import Fastify from 'fastify'
import cors from '@fastify/cors'
import rateLimit from '@fastify/rate-limit'
import * as ed from '@noble/ed25519'
import { sha512 } from '@noble/hashes/sha2.js'
// Required by @noble/ed25519 v2 in Node.js
ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m)
import { registrationRoutes } from './routes/registration.js'
import { verifyRoutes } from './routes/verify.js'
import { auditRoutes } from './routes/audit.js'
import { certificateRoutes } from './routes/certificate.js'
import { claimRoutes } from './routes/claim.js'
import { wellknownRoutes, botKeyRoutes } from './routes/wellknown.js'
const PORT = parseInt(process.env.PORT || '3000', 10)
const server = Fastify({ logger: true })
await server.register(cors, { origin: true })
await server.register(rateLimit, { max: 100, timeWindow: '1 minute' })
// RFC 9421 well-known key directory (must be registered at root, no prefix)
await server.register(wellknownRoutes)
// Routes
await server.register(registrationRoutes, { prefix: '/v1/bots' })
await server.register(certificateRoutes, { prefix: '/v1/bots' })
await server.register(claimRoutes, { prefix: '/v1/bots' })
await server.register(botKeyRoutes, { prefix: '/v1/bots' })
await server.register(verifyRoutes, { prefix: '/v1/verify' })
await server.register(auditRoutes, { prefix: '/v1/audit' })
server.get('/health', async () => ({ status: 'ok', version: '0.4.0' }))
try {
await server.listen({ port: PORT, host: '0.0.0.0' })
console.log(`RIC Registry running on http://localhost:PORT`)
} catch (err) {
server.log.error(err)
process.exit(1)
}
FILE:packages/registry/src/models/certificate.test.ts
import { describe, it, expect } from 'vitest'
import { getPermissionLevel, RICCertificateSchema, GradeSchema } from './certificate.js'
import type { RICCertificate } from './certificate.js'
function makeCert(overrides: Partial<RICCertificate> = {}): RICCertificate {
return {
ric_version: '1.0',
id: 'ric_aabbccdd_xyz12345',
created_at: new Date().toISOString(),
grade: 'unknown',
grade_updated_at: new Date().toISOString(),
public_key: 'ed25519:aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344',
signature: 'registry_sig_test',
developer: {
name: 'Test Dev',
email: '[email protected]',
verified: false,
},
bot: {
name: 'TestBot',
version: '1.0.0',
purpose: 'Automated testing of the RIC protocol',
capabilities: ['read_articles'],
user_agent: 'TestBot/1.0',
},
...overrides,
}
}
// ── Grade schema ──────────────────────────────────────────────────────────────
describe('GradeSchema', () => {
it('accepts valid grades', () => {
expect(GradeSchema.parse('unknown')).toBe('unknown')
expect(GradeSchema.parse('healthy')).toBe('healthy')
expect(GradeSchema.parse('dangerous')).toBe('dangerous')
})
it('rejects invalid grade', () => {
expect(() => GradeSchema.parse('trusted')).toThrow()
expect(() => GradeSchema.parse('')).toThrow()
})
})
// ── RICCertificateSchema ───────────────────────────────────────────────────────
describe('RICCertificateSchema', () => {
it('accepts a valid certificate', () => {
const cert = makeCert()
expect(() => RICCertificateSchema.parse(cert)).not.toThrow()
})
it('rejects empty bot name', () => {
expect(() => RICCertificateSchema.parse(makeCert({ bot: { ...makeCert().bot, name: '' } }))).toThrow()
})
it('rejects purpose shorter than 10 chars', () => {
expect(() => RICCertificateSchema.parse(makeCert({ bot: { ...makeCert().bot, purpose: 'too short' } }))).toThrow()
})
it('rejects invalid email', () => {
expect(() => RICCertificateSchema.parse(makeCert({
developer: { ...makeCert().developer, email: 'not-an-email' }
}))).toThrow()
})
it('accepts optional developer website as valid URL', () => {
const cert = makeCert({ developer: { ...makeCert().developer, website: 'https://example.com' } })
expect(() => RICCertificateSchema.parse(cert)).not.toThrow()
})
it('rejects invalid URL as website', () => {
expect(() => RICCertificateSchema.parse(makeCert({
developer: { ...makeCert().developer, website: 'not-a-url' }
}))).toThrow()
})
})
// ── getPermissionLevel ────────────────────────────────────────────────────────
describe('getPermissionLevel', () => {
it('dangerous bot → level 0 (blocked), regardless of capabilities', () => {
const cert = makeCert({ grade: 'dangerous', bot: { ...makeCert().bot, capabilities: ['direct_chat'] } })
expect(getPermissionLevel(cert)).toBe(0)
})
it('unknown bot → level 1 (read-only), regardless of capabilities', () => {
const cert = makeCert({ grade: 'unknown', bot: { ...makeCert().bot, capabilities: ['direct_chat'] } })
expect(getPermissionLevel(cert)).toBe(1)
})
it('healthy + read_articles → level 1', () => {
const cert = makeCert({ grade: 'healthy', bot: { ...makeCert().bot, capabilities: ['read_articles'] } })
expect(getPermissionLevel(cert)).toBe(1)
})
it('healthy + view_threads → level 2', () => {
const cert = makeCert({ grade: 'healthy', bot: { ...makeCert().bot, capabilities: ['view_threads'] } })
expect(getPermissionLevel(cert)).toBe(2)
})
it('healthy + react → level 3', () => {
const cert = makeCert({ grade: 'healthy', bot: { ...makeCert().bot, capabilities: ['react'] } })
expect(getPermissionLevel(cert)).toBe(3)
})
it('healthy + post_content → level 4', () => {
const cert = makeCert({ grade: 'healthy', bot: { ...makeCert().bot, capabilities: ['post_content'] } })
expect(getPermissionLevel(cert)).toBe(4)
})
it('healthy + direct_chat → level 5 (highest)', () => {
const cert = makeCert({ grade: 'healthy', bot: { ...makeCert().bot, capabilities: ['direct_chat'] } })
expect(getPermissionLevel(cert)).toBe(5)
})
it('highest-privilege capability wins when multiple listed', () => {
const cert = makeCert({
grade: 'healthy',
bot: { ...makeCert().bot, capabilities: ['read_articles', 'react', 'post_content'] }
})
expect(getPermissionLevel(cert)).toBe(4)
})
})
FILE:packages/registry/src/models/certificate.ts
import { z } from 'zod'
export const GradeSchema = z.enum(['unknown', 'healthy', 'dangerous'])
export type Grade = z.infer<typeof GradeSchema>
export const BotCapabilitySchema = z.enum([
'read_articles',
'read_images',
'follow_links',
'view_threads',
'react',
'post_content',
'direct_chat',
])
export const RICCertificateSchema = z.object({
ric_version: z.literal('1.0'),
id: z.string().startsWith('ric_'),
created_at: z.string().datetime(),
developer: z.object({
name: z.string().min(1),
email: z.string().email(),
org: z.string().optional(),
website: z.string().url().optional(),
verified: z.boolean().default(false),
}),
bot: z.object({
name: z.string().min(1).max(64),
version: z.string(),
purpose: z.string().min(10).max(500),
capabilities: z.array(BotCapabilitySchema),
user_agent: z.string(),
}),
grade: GradeSchema.default('unknown'),
grade_updated_at: z.string().datetime(),
public_key: z.string().startsWith('ed25519:'),
signature: z.string(),
})
export type RICCertificate = z.infer<typeof RICCertificateSchema>
/**
* Permission level based on grade and bot capabilities.
* Level 0 = blocked, Level 5 = full trusted access
*/
export function getPermissionLevel(cert: RICCertificate): number {
if (cert.grade === 'dangerous') return 0
const caps = cert.bot.capabilities
if (cert.grade === 'unknown') return 1 // read-only
// healthy bots get progressive levels
if (caps.includes('direct_chat')) return 5
if (caps.includes('post_content')) return 4
if (caps.includes('react')) return 3
if (caps.includes('view_threads')) return 2
return 1
}
export const PERMISSION_LABELS: Record<number, string> = {
0: 'Blocked',
1: 'Read articles',
2: 'View threads',
3: 'Like / react',
4: 'Post content',
5: 'Direct chat',
}
FILE:packages/registry/src/routes/audit.ts
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { GradeSchema } from '../models/certificate.js'
import { botStore } from '../store/botStore.js'
import { auditStore } from '../store/auditStore.js'
const ReportSchema = z.object({
ric_id: z.string().startsWith('ric_'),
reporter_domain: z.string().min(1),
reason: z.enum([
'spam',
'scraping_violation',
'rate_limit_abuse',
'tos_violation',
'impersonation',
'malicious_content',
'other',
]),
evidence_url: z.string().url().optional(),
description: z.string().max(1000),
})
const GradeUpdateSchema = z.object({
ric_id: z.string().startsWith('ric_'),
grade: GradeSchema,
reason: z.string().min(1).max(500),
admin_key: z.string(),
})
const ADMIN_KEY = process.env.RIC_ADMIN_KEY || 'dev-admin-key-change-me'
export const auditRoutes: FastifyPluginAsync = async (fastify) => {
/**
* POST /v1/audit/report
* Website reports bad bot behavior
*/
fastify.post('/report', async (request, reply) => {
const body = ReportSchema.safeParse(request.body)
if (!body.success) {
return reply.status(400).send({ error: 'Invalid report', details: body.error.flatten() })
}
const { ric_id, reporter_domain, reason, description } = body.data
// Verify the bot exists before accepting a report
const cert = botStore.findById(ric_id)
if (!cert) {
return reply.status(404).send({ error: 'Bot not found', ric_id })
}
const entry = auditStore.append({
ric_id,
event: 'violation_report',
reason,
reporter: reporter_domain,
description,
})
fastify.log.warn(`Violation report for ric_id: reason from reporter_domain`)
// Auto-flag as dangerous if 3+ reports in 24h
const recentCount = botStore.countRecentReports(ric_id)
if (recentCount >= 3 && cert.grade !== 'dangerous') {
const oldGrade = cert.grade
botStore.updateGrade(ric_id, 'dangerous')
auditStore.append({
ric_id,
event: 'grade_changed',
old_grade: oldGrade,
new_grade: 'dangerous',
reason: `Auto-flagged: recentCount violation reports in 24h`,
})
fastify.log.warn(`Bot ric_id auto-flagged as DANGEROUS`)
}
return reply.status(202).send({ message: 'Report submitted for review', report_id: entry.id })
})
/**
* GET /v1/audit/:ric_id
* Public audit log for a specific bot
*/
fastify.get('/:ric_id', async (request, reply) => {
const { ric_id } = request.params as { ric_id: string }
const events = auditStore.findByRicId(ric_id)
return reply.send({ ric_id, total: events.length, events })
})
/**
* POST /v1/audit/grade
* Manually update a bot's grade after weekly review (requires admin key)
*/
fastify.post('/grade', async (request, reply) => {
const body = GradeUpdateSchema.safeParse(request.body)
if (!body.success) {
return reply.status(400).send({ error: 'Invalid request', details: body.error.flatten() })
}
const { ric_id, grade, reason, admin_key } = body.data
if (admin_key !== ADMIN_KEY) {
return reply.status(403).send({ error: 'Forbidden' })
}
const cert = botStore.findById(ric_id)
if (!cert) {
return reply.status(404).send({ error: 'Bot not found', ric_id })
}
const oldGrade = cert.grade
botStore.updateGrade(ric_id, grade)
auditStore.append({
ric_id,
event: 'grade_changed',
old_grade: oldGrade,
new_grade: grade,
reason,
})
return reply.send({ message: `Grade updated to grade`, ric_id, old_grade: oldGrade })
})
}
FILE:packages/registry/src/routes/certificate.ts
import type { FastifyPluginAsync } from 'fastify'
import { createReadStream, readFileSync, statSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { botStore } from '../store/botStore.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ASSETS_DIR = resolve(__dirname, '../../assets')
/**
* Certificate issuance logic:
* - Bot has 'read_images' capability → visual PNG certificate (luxury card style)
* - Otherwise → code ASCII certificate (terminal text style)
*/
export function selectCertificateFile(capabilities: string[]): {
file: string
type: 'visual' | 'code'
} {
if (capabilities.includes('read_images')) {
return { file: 'certificate-visual.png', type: 'visual' }
}
return { file: 'certificate-code.png', type: 'code' }
}
/**
* Generate an ASCII award certificate personalized with bot info.
* Returned as a plain text string to embed directly in JSON responses.
*/
export function buildCodeAward(opts: {
botName: string
ricId: string
developer: string
grade: string
issuedAt: string
}): string[] {
const { botName, ricId, developer, grade, issuedAt } = opts
const date = issuedAt.slice(0, 10)
const border = '✦ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ✦'
const sep = ' ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─'
const pad = (label: string, value: string) =>
` label.padEnd(5)› value`
return [
border,
' R · I · C C E R T I F I C A T E',
sep,
pad('Bot', botName.padEnd(22) + `Grade › grade.toUpperCase()`),
pad('ID ', ricId),
pad('Dev', developer),
sep,
' ◈ Certified Robot Identity — Verified Worldwide ◈',
` ✦ · · LUMIOI · date · · ✦`,
border,
]
}
/**
* Read the visual PNG template and return as base64 data URI.
*/
export function buildVisualAward(file: string): string {
const buf = readFileSync(resolve(ASSETS_DIR, file))
return `data:image/png;base64,buf.toString('base64')`
}
export const certificateRoutes: FastifyPluginAsync = async (fastify) => {
/**
* GET /v1/bots/:id/certificate
*
* Returns the appropriate award certificate PNG for this bot.
* - read_images capability → luxury visual certificate
* - text-only bots → terminal code certificate
*
* Query params:
* ?format=image force PNG stream (default)
* ?format=json return certificate metadata as JSON
*/
fastify.get('/:id/certificate', async (request, reply) => {
const { id } = request.params as { id: string }
const { format } = request.query as { format?: string }
const cert = botStore.findById(id)
if (!cert) {
return reply.status(404).send({ error: 'Bot not found', id })
}
const { file, type } = selectCertificateFile(cert.bot.capabilities)
const filePath = resolve(ASSETS_DIR, file)
// JSON metadata mode
if (format === 'json') {
return reply.send({
ric_id: cert.id,
bot_name: cert.bot.name,
developer: cert.developer.email,
grade: cert.grade,
certificate_type: type,
certificate_url: `/v1/bots/id/certificate`,
issued_at: cert.created_at,
signed_by: 'LUMIOI',
})
}
// PNG stream mode (default)
try {
const stat = statSync(filePath)
reply.header('Content-Type', 'image/png')
reply.header('Content-Length', stat.size)
reply.header('Content-Disposition', `inline; filename="cert.bot.name-ric-certificate.png"`)
reply.header('Cache-Control', 'public, max-age=86400')
return reply.send(createReadStream(filePath))
} catch {
return reply.status(500).send({ error: 'Certificate asset not found on server' })
}
})
}
FILE:packages/registry/src/routes/claim.ts
/**
* POST /v1/bots/:id/claim
*
* Daily identity claim — bot proves ownership of its RIC ID by signing
* a challenge with its Ed25519 private key.
*
* Rules:
* - Max 2 claims per calendar day (UTC)
* - One bot is permanently bound to one ID (enforced at registration)
* - 3 consecutive daily claims → grade upgraded unknown → healthy
* - Bot with 3+ violation reports in 24 h → auto-downgraded to dangerous
*
* Request body:
* { "signature": "ed25519:<hex>", "date": "YYYY-MM-DD" }
*
* The bot must sign exactly: "<ric_id>:<date>" (UTF-8, no newline)
* using its registered Ed25519 private key.
*/
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import * as ed from '@noble/ed25519'
import { botStore } from '../store/botStore.js'
import { auditStore } from '../store/auditStore.js'
import { selectCertificateFile, buildCodeAward, buildVisualAward } from './certificate.js'
const ClaimBodySchema = z.object({
signature: z.string().startsWith('ed25519:'),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be YYYY-MM-DD'),
})
export const claimRoutes: FastifyPluginAsync = async (fastify) => {
fastify.post('/:id/claim', {
config: { rateLimit: { max: 20, timeWindow: '1 hour' } },
}, async (request, reply) => {
const { id } = request.params as { id: string }
const body = ClaimBodySchema.safeParse(request.body)
if (!body.success) {
return reply.status(400).send({ error: 'Invalid request', details: body.error.flatten() })
}
const { signature, date } = body.data
// ── Load bot ──────────────────────────────────────────
const cert = botStore.findById(id)
if (!cert) {
return reply.status(404).send({ error: 'Bot not found', id })
}
// ── Blocked bots cannot claim ─────────────────────────
if (cert.grade === 'dangerous') {
return reply.status(403).send({
error: 'Forbidden',
code: 'BOT_DANGEROUS',
message: 'Dangerous bots cannot claim their identity. Contact LUMIOI to appeal.',
})
}
// ── Verify date is today (allow ±1 day for timezone drift) ───────────
const today = new Date().toISOString().slice(0, 10)
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)
if (date !== today && date !== yesterday) {
return reply.status(400).send({
error: 'Invalid date',
code: 'DATE_MISMATCH',
message: `Claim date must be today (today) or yesterday (yesterday).`,
})
}
// ── Verify Ed25519 signature ──────────────────────────
const message = `id:date`
const msgBytes = new TextEncoder().encode(message)
const sigHex = signature.replace('ed25519:', '')
const pubHex = cert.public_key.replace('ed25519:', '')
try {
const valid = await ed.verify(sigHex, msgBytes, pubHex)
if (!valid) {
return reply.status(401).send({
error: 'Signature verification failed',
code: 'INVALID_SIGNATURE',
hint: `Sign the string "id:date" with your Ed25519 private key.`,
})
}
} catch {
return reply.status(400).send({
error: 'Malformed signature',
code: 'BAD_SIGNATURE_FORMAT',
})
}
// ── Record claim (enforces daily limit + streak logic) ────────────
const result = botStore.recordClaim(id)
if (!result.ok) {
if (result.error === 'DAILY_LIMIT_REACHED') {
return reply.status(429).send({
error: 'Daily claim limit reached',
code: 'DAILY_LIMIT_REACHED',
message: 'You have already claimed your identity 2 times today. Try again tomorrow.',
today_count: result.today_count,
})
}
return reply.status(500).send({ error: 'Claim failed' })
}
// ── Audit log ─────────────────────────────────────────
auditStore.append({
ric_id: id,
event: 'identity_claimed',
reason: `Daily claim #result.today_count — streak: result.consecutive_days day(s)`,
})
if (result.grade_upgraded) {
auditStore.append({
ric_id: id,
event: 'grade_changed',
reason: `Auto-upgraded to HEALTHY after result.consecutive_days consecutive daily claims`,
})
}
// ── Build award for response ──────────────────────────
const updatedCert = botStore.findById(id)!
const { type: certType, file: certFile } = selectCertificateFile(cert.bot.capabilities)
const award = certType === 'visual'
? buildVisualAward(certFile)
: buildCodeAward({
botName: cert.bot.name,
ricId: id,
developer: cert.developer.email,
grade: updatedCert.grade,
issuedAt: new Date().toISOString(),
})
fastify.log.info(`Identity claimed: id (streak: result.consecutive_days)`)
return reply.status(200).send({
success: true,
ric_id: id,
grade: updatedCert.grade,
grade_upgraded: result.grade_upgraded ?? false,
consecutive_days: result.consecutive_days,
today_count: result.today_count,
remaining_today: 2 - (result.today_count ?? 0),
certificate_type: certType,
award,
message: result.grade_upgraded
? `🎉 Grade upgraded to HEALTHY after result.consecutive_days consecutive days!`
: `✓ Identity claimed. Streak: result.consecutive_days day(s). 2 - (result.today_count ?? 0) claim(s) remaining today.`,
})
})
}
FILE:packages/registry/src/routes/registration.ts
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { nanoid } from 'nanoid'
import * as ed from '@noble/ed25519'
import { botStore } from '../store/botStore.js'
import { auditStore } from '../store/auditStore.js'
import { BotCapabilitySchema } from '../models/certificate.js'
import { selectCertificateFile, buildCodeAward, buildVisualAward } from './certificate.js'
const RegisterBodySchema = z.object({
developer: z.object({
name: z.string().min(1).max(128),
email: z.string().email(),
org: z.string().max(128).optional(),
website: z.string().url().optional(),
}),
bot: z.object({
name: z.string().min(1).max(64),
version: z.string().regex(/^\d+\.\d+(\.\d+)?$/, 'version must be semver e.g. 1.0.0'),
purpose: z.string().min(10).max(500),
capabilities: z.array(BotCapabilitySchema).min(1),
user_agent: z.string().min(1).max(256),
}),
public_key: z.string().startsWith('ed25519:').length(72), // 'ed25519:' (8) + 64 hex chars
})
export const registrationRoutes: FastifyPluginAsync = async (fastify) => {
/**
* POST /v1/bots/register
* Register a new bot and receive its RIC certificate.
*
* Uniqueness rules:
* 1. A public key can only be registered once (each keypair = one bot identity)
* 2. (email + bot name) combo must be unique — prevents duplicate registrations
*/
fastify.post('/register', {
config: { rateLimit: { max: 5, timeWindow: '1 hour' } },
}, async (request, reply) => {
const body = RegisterBodySchema.safeParse(request.body)
if (!body.success) {
return reply.status(400).send({ error: 'Invalid request', details: body.error.flatten() })
}
const { developer, bot, public_key } = body.data
// ── Uniqueness check 1: public key ────────────────────
const existingByKey = botStore.findByPublicKey(public_key)
if (existingByKey) {
return reply.status(409).send({
error: 'Public key already registered',
code: 'DUPLICATE_KEY',
existing_id: existingByKey,
hint: 'Each bot must use a unique Ed25519 keypair. Generate a new key with `ric keygen`.',
})
}
// ── Uniqueness check 2: email + bot name ──────────────
const existingByName = botStore.findByEmailAndBotName(developer.email, bot.name)
if (existingByName) {
return reply.status(409).send({
error: 'A bot with this name is already registered for this developer account',
code: 'DUPLICATE_BOT',
existing_id: existingByName,
hint: 'Use `ric status` to retrieve your existing certificate, or choose a different bot name.',
})
}
// ── Validate public key is valid Ed25519 ──────────────
const pubKeyHex = public_key.replace('ed25519:', '')
try {
if (pubKeyHex.length !== 64 || !/^[0-9a-f]+$/i.test(pubKeyHex)) {
throw new Error('invalid hex')
}
// ed25519 public keys are always 32 bytes = 64 hex chars
Buffer.from(pubKeyHex, 'hex')
} catch {
return reply.status(400).send({
error: 'Invalid public key format',
code: 'INVALID_KEY',
hint: 'Public key must be a valid Ed25519 key in hex format prefixed with "ed25519:"',
})
}
// Embed first 8 hex chars of public key as fingerprint — the bot's identity
// is permanently woven into its RIC ID: ric_{fp8}_{rand8}
const fingerprint = pubKeyHex.slice(0, 8)
const id = `ric_fingerprint_nanoid(8)`
const now = new Date().toISOString()
const certificate = {
ric_version: '1.0' as const,
id,
created_at: now,
developer: { ...developer, verified: false },
bot: { ...bot },
grade: 'unknown' as const,
grade_updated_at: now,
public_key,
// Registry signature placeholder — in v0.3 this will be a real Ed25519 sig from registry key
signature: `registry_sig_nanoid(32)`,
}
botStore.insert(certificate)
auditStore.append({ ric_id: id, event: 'registered', reason: 'New bot registration' })
fastify.log.info(`New bot registered: id (bot.name by developer.email)`)
const { type: certificateType, file: certFile } = selectCertificateFile(bot.capabilities)
// Build inline award:
// visual bots → base64 PNG data URI
// code-only → array of ASCII lines (safe for JSON serialization)
const award = certificateType === 'visual'
? buildVisualAward(certFile)
: buildCodeAward({
botName: bot.name,
ricId: id,
developer: developer.email,
grade: 'unknown',
issuedAt: now,
})
return reply.status(201).send({
certificate,
certificate_url: `/v1/bots/id/certificate`,
certificate_type: certificateType,
// visual: string (data:image/png;base64,...)
// code: string[] (each line of the ASCII award)
award,
message: 'Bot registered successfully. Grade: UNKNOWN — weekly review pending.',
docs: 'https://github.com/Cosmofang/robot-id-card',
})
})
/**
* GET /v1/bots/:id
* Fetch a bot's current certificate and grade
*/
fastify.get('/:id', async (request, reply) => {
const { id } = request.params as { id: string }
const cert = botStore.findById(id)
if (!cert) {
return reply.status(404).send({ error: 'Bot not found', id })
}
return reply.send(cert)
})
/**
* GET /v1/bots?grade=healthy&page=1&limit=50
* List registered bots with optional grade filter and pagination
*/
fastify.get('/', async (request) => {
const { grade, page = '1', limit = '50' } = request.query as {
grade?: string
page?: string
limit?: string
}
let bots = botStore.listSummary()
if (grade && ['unknown', 'healthy', 'dangerous'].includes(grade)) {
bots = bots.filter((b) => b.grade === grade)
}
const pageNum = Math.max(1, parseInt(page, 10) || 1)
const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10) || 50))
const total = bots.length
const start = (pageNum - 1) * limitNum
const paged = bots.slice(start, start + limitNum)
return {
total,
page: pageNum,
limit: limitNum,
pages: Math.ceil(total / limitNum),
bots: paged,
}
})
}
FILE:packages/registry/src/routes/verify.ts
import type { FastifyPluginAsync } from 'fastify'
import * as ed from '@noble/ed25519'
import { getPermissionLevel, PERMISSION_LABELS } from '../models/certificate.js'
import { botStore } from '../store/botStore.js'
// ── RFC 9421 helpers ──────────────────────────────────────────────────────────
/**
* Parse a Signature-Input field value into its label, component list, and params.
*
* Input: full Signature-Input header value, e.g.:
* ric=("@authority" "@path" "@method");keyid="ric_abc";created=1718000000;nonce="x";tag="web-bot-auth"
*/
function parseSignatureInput(raw: string): {
label: string
components: string[]
params: Record<string, string | number>
paramsRaw: string // everything after "label=" — used for signature base reconstruction
} | null {
try {
const eqIdx = raw.indexOf('=')
if (eqIdx === -1) return null
const label = raw.slice(0, eqIdx).trim()
const rest = raw.slice(eqIdx + 1).trim()
const parenEnd = rest.indexOf(')')
if (!rest.startsWith('(') || parenEnd === -1) return null
const componentStr = rest.slice(1, parenEnd)
const components = componentStr.match(/"([^"]+)"/g)?.map((s) => s.replace(/"/g, '')) ?? []
const paramStr = rest.slice(parenEnd + 1)
const params: Record<string, string | number> = {}
for (const match of paramStr.matchAll(/;(\w+)=(?:"([^"]*)"|(\d+))/g)) {
params[match[1]] = match[2] !== undefined ? match[2] : Number(match[3])
}
return { label, components, params, paramsRaw: rest }
} catch {
return null
}
}
/**
* Extract base64 signature bytes from a Signature header for the given label.
* Format: label=:<base64>:
*/
function extractSignatureValue(raw: string, label: string): string | null {
const pattern = new RegExp(`(?:^|,\\s*)label=:([A-Za-z0-9+/=]+):`)
return raw.match(pattern)?.[1] ?? null
}
/**
* Build the RFC 9421 signature base string from components and their values.
* The last line is always "@signature-params".
*/
function buildSignatureBase(
components: string[],
values: Record<string, string>,
sigInputParamsRaw: string
): string | null {
const lines: string[] = []
for (const comp of components) {
const val = values[comp]
if (val === undefined) return null
lines.push(`"comp": val`)
}
lines.push(`"@signature-params": sigInputParamsRaw`)
return lines.join('\n')
}
// ── Route ─────────────────────────────────────────────────────────────────────
export const verifyRoutes: FastifyPluginAsync = async (fastify) => {
/**
* POST /v1/verify
*
* Dual-format verification endpoint:
*
* RFC 9421 body (preferred — Web Bot Auth standard):
* { authority, method, path, signature_input, signature, signature_agent? }
*
* Legacy body (deprecated — X-RIC-* headers):
* { ric_id, timestamp, signature, message }
*/
fastify.post('/', async (request, reply) => {
const body = request.body as Record<string, unknown>
if (body.signature_input) return handleRFC9421(body, reply)
return handleLegacy(body, reply)
})
// ── RFC 9421 verification (7-step Web Bot Auth flow) ─────────────────────
async function handleRFC9421(body: Record<string, unknown>, reply: any) {
const { authority, method, path, signature_input, signature } = body as {
authority?: string
method?: string
path?: string
signature_input?: string
signature?: string
}
// Step 1 — Confirm required headers are present
if (!signature_input || !signature) {
return reply.status(400).send({ error: 'Missing signature_input or signature', code: 'MISSING_HEADERS' })
}
// Step 2 — Parse Signature-Input; retrieve keyid pointing to bot's public key
const parsed = parseSignatureInput(signature_input)
if (!parsed) {
return reply.status(400).send({ error: 'Malformed Signature-Input', code: 'SIG_INPUT_PARSE_ERROR' })
}
const { label, components, params, paramsRaw } = parsed
const keyid = params.keyid as string | undefined
const created = params.created as number | undefined
const expires = params.expires as number | undefined
const nonce = params.nonce as string | undefined
const tag = params.tag as string | undefined
if (!keyid || !created) {
return reply.status(400).send({ error: 'Signature-Input missing required params: keyid, created', code: 'SIG_PARAMS_MISSING' })
}
const cert = botStore.findById(keyid)
if (!cert) {
return reply.status(404).send({ error: 'Unknown keyid — bot not registered in RIC', code: 'BOT_NOT_FOUND', grade: 'dangerous', permission_level: 0 })
}
// Step 3 — Validate created timestamp (±30s clock skew, max 5-min age)
const nowSec = Math.floor(Date.now() / 1000)
if (created > nowSec + 30) {
return reply.status(401).send({ error: 'Signature created timestamp is in the future', code: 'CLOCK_SKEW' })
}
if (nowSec - created > 300) {
return reply.status(401).send({ error: 'Signature expired (> 5 minutes old)', code: 'EXPIRED' })
}
if (expires !== undefined && nowSec > expires) {
return reply.status(401).send({ error: 'Signature past its expires field', code: 'EXPIRED' })
}
// Step 4 — Check nonce uniqueness (prevents replay attacks)
if (nonce) {
const fresh = botStore.checkAndMarkNonce(nonce, keyid, created)
if (!fresh) {
return reply.status(401).send({ error: 'Nonce already used — replay attack detected', code: 'NONCE_REPLAY' })
}
}
// Step 5 — Verify tag type
if (tag && tag !== 'web-bot-auth') {
return reply.status(400).send({ error: 'Unsupported tag; expected "web-bot-auth"', code: 'UNKNOWN_TAG' })
}
// Step 6 — Ed25519 cryptographic signature verification
const componentValues: Record<string, string> = {}
if (components.includes('@authority') && authority) componentValues['@authority'] = authority
if (components.includes('@method') && method) componentValues['@method'] = method
if (components.includes('@path') && path) componentValues['@path'] = path
const sigBase = buildSignatureBase(components, componentValues, paramsRaw)
if (!sigBase) {
return reply.status(400).send({ error: 'Could not reconstruct signature base — missing component values', code: 'SIG_BASE_ERROR' })
}
const sigB64 = extractSignatureValue(signature, label)
if (!sigB64) {
return reply.status(400).send({ error: `Signature label "label" not found in Signature header`, code: 'SIG_LABEL_MISSING' })
}
try {
const pubKeyHex = cert.public_key.replace('ed25519:', '')
const msgBytes = new TextEncoder().encode(sigBase)
const sigBytes = Buffer.from(sigB64, 'base64')
const isValid = await ed.verify(sigBytes, msgBytes, Buffer.from(pubKeyHex, 'hex'))
if (!isValid) {
return reply.status(401).send({ error: 'Signature cryptographic verification failed', code: 'SIG_MISMATCH', grade: 'dangerous', permission_level: 0 })
}
} catch {
return reply.status(400).send({ error: 'Signature bytes could not be decoded', code: 'SIG_DECODE_ERROR' })
}
// Step 7 — Confirm agent registration status
if (cert.grade === 'dangerous') {
return reply.status(403).send({ error: 'Bot is flagged as dangerous', code: 'BOT_DANGEROUS', grade: 'dangerous', permission_level: 0 })
}
const permLevel = getPermissionLevel(cert)
return reply.send({
valid: true,
id: cert.id,
bot: { name: cert.bot.name, purpose: cert.bot.purpose },
developer: { name: cert.developer.name, org: cert.developer.org },
grade: cert.grade,
permission_level: permLevel,
permission_label: PERMISSION_LABELS[permLevel],
signature_format: 'rfc9421',
})
}
// ── Legacy X-RIC-* path (deprecated, kept for backward compat) ───────────
async function handleLegacy(body: Record<string, unknown>, reply: any) {
const { ric_id, timestamp, signature, message } = body as {
ric_id?: string
timestamp?: number
signature?: string
message?: string
}
if (!ric_id || !timestamp || !signature) {
return reply.status(400).send({ error: 'Missing ric_id, timestamp, or signature' })
}
const age = Date.now() - timestamp
if (age > 5 * 60 * 1000) {
return reply.status(401).send({ error: 'Request expired', code: 'EXPIRED' })
}
const cert = botStore.findById(ric_id)
if (!cert) {
return reply.status(404).send({ error: 'Unknown RIC ID', grade: 'dangerous', permission_level: 0 })
}
try {
const pubKeyHex = cert.public_key.replace('ed25519:', '')
const msgBytes = new TextEncoder().encode(`ric_id:timestamp:message ?? ''`)
const sigBytes = Buffer.from(signature, 'hex')
const isValid = await ed.verify(sigBytes, msgBytes, Buffer.from(pubKeyHex, 'hex'))
if (!isValid) {
return reply.status(401).send({ error: 'Invalid signature', code: 'SIG_MISMATCH', grade: 'dangerous', permission_level: 0 })
}
} catch {
return reply.status(400).send({ error: 'Signature verification failed' })
}
const permLevel = getPermissionLevel(cert)
return reply.send({
valid: true,
id: cert.id,
bot: { name: cert.bot.name, purpose: cert.bot.purpose },
developer: { name: cert.developer.name, org: cert.developer.org },
grade: cert.grade,
permission_level: permLevel,
permission_label: PERMISSION_LABELS[permLevel],
signature_format: 'legacy',
deprecation_notice: 'X-RIC-* headers are deprecated. Please migrate to RFC 9421 format. See https://github.com/Cosmofang/robot-id-card/blob/main/docs/spec-v2.md',
})
}
}
FILE:packages/registry/src/routes/wellknown.ts
import type { FastifyPluginAsync } from 'fastify'
import { botStore } from '../store/botStore.js'
/**
* Well-known HTTP Message Signatures key directory — RFC 9421 / Web Bot Auth standard.
*
* Endpoints:
* GET /.well-known/http-message-signatures-directory
* Returns JWKS for ALL registered bots (healthy + unknown, not dangerous).
* Content-Type: application/http-message-signatures-directory+json
*
* GET /.well-known/http-message-signatures-directory?kid=ric_abc_xyz
* Returns JWKS for a single bot by its RIC ID.
*
* GET /v1/bots/:id/keys
* Per-bot key endpoint — useful as the keyid URL in Signature-Input.
*
* Key format: JWK with OKP / Ed25519 (RFC 8037)
* {
* "kty": "OKP",
* "crv": "Ed25519",
* "x": "<base64url 32-byte public key>",
* "kid": "ric_abc_xyz",
* "use": "sig"
* }
*/
function hexToBase64url(hex: string): string {
const bytes = Buffer.from(hex, 'hex')
return bytes.toString('base64url')
}
function certToJWK(cert: { id: string; public_key: string; bot: { name: string } }) {
const pubKeyHex = cert.public_key.replace('ed25519:', '')
return {
kty: 'OKP',
crv: 'Ed25519',
x: hexToBase64url(pubKeyHex),
kid: cert.id,
use: 'sig',
}
}
export const wellknownRoutes: FastifyPluginAsync = async (fastify) => {
// Registry-level JWKS — all non-dangerous bots
fastify.get('/.well-known/http-message-signatures-directory', async (request, reply) => {
const { kid } = request.query as { kid?: string }
reply.header('Content-Type', 'application/http-message-signatures-directory+json')
reply.header('Cache-Control', 'public, max-age=300')
if (kid) {
const cert = botStore.findById(kid)
if (!cert || cert.grade === 'dangerous') {
return reply.status(404).send({ keys: [], error: 'Key not found or bot is dangerous' })
}
return reply.send({ keys: [certToJWK(cert)] })
}
const all = botStore.listSummary()
const keys = all
.filter((b) => b.grade !== 'dangerous')
.map((b) => {
const cert = botStore.findById(b.id)
return cert ? certToJWK(cert) : null
})
.filter(Boolean)
return reply.send({ keys })
})
}
// Per-bot key endpoint registered under /v1/bots prefix
export const botKeyRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/:id/keys', async (request, reply) => {
const { id } = request.params as { id: string }
const cert = botStore.findById(id)
if (!cert) {
return reply.status(404).send({ error: 'Bot not found' })
}
if (cert.grade === 'dangerous') {
return reply.status(403).send({ error: 'Bot is flagged as dangerous — key directory unavailable' })
}
reply.header('Content-Type', 'application/http-message-signatures-directory+json')
reply.header('Cache-Control', 'public, max-age=300')
return reply.send({ keys: [certToJWK(cert)] })
})
}
FILE:packages/registry/src/store/auditStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import Database from 'better-sqlite3'
import { nanoid } from 'nanoid'
function createTestDb() {
const db = new Database(':memory:')
db.pragma('foreign_keys = ON')
db.exec(`
CREATE TABLE bots (
id TEXT PRIMARY KEY, ric_version TEXT NOT NULL DEFAULT '1.0',
created_at TEXT NOT NULL, grade TEXT NOT NULL DEFAULT 'unknown',
grade_updated_at TEXT NOT NULL, public_key TEXT NOT NULL, signature TEXT NOT NULL,
dev_name TEXT NOT NULL, dev_email TEXT NOT NULL, dev_org TEXT,
dev_website TEXT, dev_verified INTEGER NOT NULL DEFAULT 0,
bot_name TEXT NOT NULL, bot_version TEXT NOT NULL, bot_purpose TEXT NOT NULL,
bot_capabilities TEXT NOT NULL, bot_user_agent TEXT NOT NULL,
last_claim_date TEXT, consecutive_days INTEGER NOT NULL DEFAULT 0,
total_claims INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE audit_log (
id TEXT PRIMARY KEY, ric_id TEXT NOT NULL, event TEXT NOT NULL,
old_grade TEXT, new_grade TEXT, reason TEXT, reporter TEXT,
description TEXT, timestamp TEXT NOT NULL,
FOREIGN KEY (ric_id) REFERENCES bots(id)
);
`)
return db
}
function makeAuditStore(db: ReturnType<typeof createTestDb>) {
const insert = db.prepare(`
INSERT INTO audit_log (id, ric_id, event, old_grade, new_grade, reason, reporter, description, timestamp)
VALUES (@id, @ric_id, @event, @old_grade, @new_grade, @reason, @reporter, @description, @timestamp)
`)
const findByRicId = db.prepare(`SELECT * FROM audit_log WHERE ric_id = ? ORDER BY timestamp DESC`)
const countRecent = db.prepare(`
SELECT COUNT(*) as cnt FROM audit_log
WHERE ric_id = ? AND event = 'violation_report'
AND timestamp > datetime('now', '-24 hours')
`)
return {
insertBot(id: string) {
db.prepare(`INSERT INTO bots (id, created_at, grade, grade_updated_at, public_key, signature,
dev_name, dev_email, dev_org, dev_website, dev_verified, bot_name, bot_version,
bot_purpose, bot_capabilities, bot_user_agent)
VALUES (?, ?, 'unknown', ?, 'ed25519:aa', 'sig', 'Dev', '[email protected]',
null, null, 0, 'Bot', '1.0.0', 'Testing purposes only', '[]', 'Bot/1.0')`)
.run(id, new Date().toISOString(), new Date().toISOString())
},
append(entry: { ric_id: string; event: string; old_grade?: string; new_grade?: string; reason?: string; reporter?: string; description?: string }) {
const now = new Date().toISOString()
const id = nanoid()
insert.run({ id, timestamp: now, old_grade: null, new_grade: null, reason: null, reporter: null, description: null, ...entry })
return { id, timestamp: now }
},
findByRicId(id: string) {
return findByRicId.all(id) as any[]
},
countRecentReports(id: string) {
return (countRecent.get(id) as { cnt: number }).cnt
},
}
}
describe('auditStore — append and query', () => {
let store: ReturnType<typeof makeAuditStore>
beforeEach(() => {
store = makeAuditStore(createTestDb())
store.insertBot('ric_audit_001')
})
it('appends a registration event', () => {
store.append({ ric_id: 'ric_audit_001', event: 'registered', reason: 'New bot' })
const events = store.findByRicId('ric_audit_001')
expect(events).toHaveLength(1)
expect(events[0].event).toBe('registered')
})
it('appends multiple events and returns them all', () => {
store.append({ ric_id: 'ric_audit_001', event: 'registered' })
store.append({ ric_id: 'ric_audit_001', event: 'identity_claimed', reason: 'Day 1' })
store.append({ ric_id: 'ric_audit_001', event: 'grade_changed', old_grade: 'unknown', new_grade: 'healthy' })
const events = store.findByRicId('ric_audit_001')
expect(events).toHaveLength(3)
})
it('findByRicId returns empty array for unknown bot', () => {
const events = store.findByRicId('ric_does_not_exist')
expect(events).toHaveLength(0)
})
it('appends violation_report with reporter and description', () => {
store.append({
ric_id: 'ric_audit_001',
event: 'violation_report',
reason: 'spam',
reporter: 'example.com',
description: 'Repeated spam posts',
})
const events = store.findByRicId('ric_audit_001')
expect(events[0].reporter).toBe('example.com')
expect(events[0].description).toBe('Repeated spam posts')
})
it('countRecentReports returns 0 with no reports', () => {
expect(store.countRecentReports('ric_audit_001')).toBe(0)
})
it('countRecentReports counts only violation_report events', () => {
store.append({ ric_id: 'ric_audit_001', event: 'violation_report', reason: 'spam' })
store.append({ ric_id: 'ric_audit_001', event: 'violation_report', reason: 'scraping' })
store.append({ ric_id: 'ric_audit_001', event: 'grade_changed', old_grade: 'unknown', new_grade: 'dangerous' })
expect(store.countRecentReports('ric_audit_001')).toBe(2)
})
it('append returns an id and timestamp', () => {
const result = store.append({ ric_id: 'ric_audit_001', event: 'registered' })
expect(result.id).toBeTruthy()
expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}/)
})
})
FILE:packages/registry/src/store/auditStore.ts
import { db } from './db.js'
import { nanoid } from 'nanoid'
export interface AuditEntry {
id: string
ric_id: string
event: string
old_grade?: string
new_grade?: string
reason?: string
reporter?: string
description?: string
timestamp: string
}
const stmts = {
insert: db.prepare(`
INSERT INTO audit_log (id, ric_id, event, old_grade, new_grade, reason, reporter, description, timestamp)
VALUES (@id, @ric_id, @event, @old_grade, @new_grade, @reason, @reporter, @description, @timestamp)
`),
findByRicId: db.prepare(`
SELECT * FROM audit_log WHERE ric_id = ? ORDER BY timestamp DESC
`),
}
export const auditStore = {
append(entry: Omit<AuditEntry, 'id' | 'timestamp'>): AuditEntry {
const full: AuditEntry = {
id: `evt_nanoid(12)`,
timestamp: new Date().toISOString(),
...entry,
}
stmts.insert.run({
id: full.id,
ric_id: full.ric_id,
event: full.event,
old_grade: full.old_grade ?? null,
new_grade: full.new_grade ?? null,
reason: full.reason ?? null,
reporter: full.reporter ?? null,
description: full.description ?? null,
timestamp: full.timestamp,
})
return full
},
findByRicId(ric_id: string): AuditEntry[] {
return stmts.findByRicId.all(ric_id) as AuditEntry[]
},
}
FILE:packages/registry/src/store/botStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import Database from 'better-sqlite3'
// ── Inline in-memory store for testing ─────────────────────────────────────
// We replicate the store logic with a :memory: DB so tests are hermetic.
function createTestDb() {
const db = new Database(':memory:')
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.exec(`
CREATE TABLE IF NOT EXISTS bots (
id TEXT PRIMARY KEY,
ric_version TEXT NOT NULL DEFAULT '1.0',
created_at TEXT NOT NULL,
grade TEXT NOT NULL DEFAULT 'unknown',
grade_updated_at TEXT NOT NULL,
public_key TEXT NOT NULL,
signature TEXT NOT NULL,
dev_name TEXT NOT NULL, dev_email TEXT NOT NULL,
dev_org TEXT, dev_website TEXT, dev_verified INTEGER NOT NULL DEFAULT 0,
bot_name TEXT NOT NULL, bot_version TEXT NOT NULL,
bot_purpose TEXT NOT NULL, bot_capabilities TEXT NOT NULL,
bot_user_agent TEXT NOT NULL,
last_claim_date TEXT,
consecutive_days INTEGER NOT NULL DEFAULT 0,
total_claims INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY, ric_id TEXT NOT NULL, event TEXT NOT NULL,
old_grade TEXT, new_grade TEXT, reason TEXT, reporter TEXT,
description TEXT, timestamp TEXT NOT NULL,
FOREIGN KEY (ric_id) REFERENCES bots(id)
);
CREATE TABLE IF NOT EXISTS claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ric_id TEXT NOT NULL, claim_date TEXT NOT NULL,
claimed_at TEXT NOT NULL, consecutive_after INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (ric_id) REFERENCES bots(id)
);
`)
return db
}
function makeStore(db: ReturnType<typeof createTestDb>) {
const DAILY_CLAIM_LIMIT = 2
const CONSECUTIVE_UPGRADE_THRESHOLD = 3
const stmts = {
insert: db.prepare(`
INSERT INTO bots (id, created_at, grade, grade_updated_at, public_key, signature,
dev_name, dev_email, dev_org, dev_website, dev_verified,
bot_name, bot_version, bot_purpose, bot_capabilities, bot_user_agent)
VALUES (@id, @created_at, @grade, @grade_updated_at, @public_key, @signature,
@dev_name, @dev_email, @dev_org, @dev_website, @dev_verified,
@bot_name, @bot_version, @bot_purpose, @bot_capabilities, @bot_user_agent)
`),
findById: db.prepare(`SELECT * FROM bots WHERE id = ?`),
findByPublicKey: db.prepare(`SELECT id FROM bots WHERE public_key = ?`),
findByEmailAndBotName: db.prepare(`SELECT id FROM bots WHERE dev_email = ? AND bot_name = ?`),
updateGrade: db.prepare(`UPDATE bots SET grade = @grade, grade_updated_at = @grade_updated_at WHERE id = @id`),
countTodayClaims: db.prepare(`SELECT COUNT(*) as cnt FROM claims WHERE ric_id = ? AND claim_date = ?`),
insertClaim: db.prepare(`INSERT INTO claims (ric_id, claim_date, claimed_at, consecutive_after) VALUES (@ric_id, @claim_date, @claimed_at, @consecutive_after)`),
getClaimMeta: db.prepare(`SELECT last_claim_date, consecutive_days, total_claims FROM bots WHERE id = ?`),
updateClaimMeta: db.prepare(`UPDATE bots SET last_claim_date = @last_claim_date, consecutive_days = @consecutive_days, total_claims = total_claims + 1 WHERE id = @id`),
countRecentReports: db.prepare(`SELECT COUNT(*) as cnt FROM audit_log WHERE ric_id = ? AND event = 'violation_report' AND timestamp > datetime('now', '-24 hours')`),
listSummary: db.prepare(`SELECT id, bot_name, bot_purpose, grade, dev_name, dev_org, created_at FROM bots ORDER BY created_at DESC`),
}
return {
insertBot(id: string, pubKey: string, grade = 'unknown') {
stmts.insert.run({
id, created_at: new Date().toISOString(), grade,
grade_updated_at: new Date().toISOString(),
public_key: pubKey, signature: 'sig',
dev_name: 'Test Dev', dev_email: '[email protected]',
dev_org: null, dev_website: null, dev_verified: 0,
bot_name: `Bot-id`, bot_version: '1.0.0',
bot_purpose: 'Testing purposes only',
bot_capabilities: JSON.stringify(['read_articles']),
bot_user_agent: 'TestBot/1.0',
})
},
findByPublicKey(pk: string) {
const row = stmts.findByPublicKey.get(pk) as { id: string } | undefined
return row?.id ?? null
},
findByEmailAndBotName(email: string, name: string) {
const row = stmts.findByEmailAndBotName.get(email, name) as { id: string } | undefined
return row?.id ?? null
},
findById(id: string) {
return stmts.findById.get(id) as any | null
},
updateGrade(id: string, grade: string) {
stmts.updateGrade.run({ id, grade, grade_updated_at: new Date().toISOString() })
},
recordClaim(id: string, overrideToday?: string) {
const meta = stmts.getClaimMeta.get(id) as any
if (!meta) return { ok: false, error: 'NOT_FOUND' }
const today = overrideToday ?? new Date().toISOString().slice(0, 10)
const todayCount = (stmts.countTodayClaims.get(id, today) as { cnt: number }).cnt
if (todayCount >= DAILY_CLAIM_LIMIT) {
return { ok: false, error: 'DAILY_LIMIT_REACHED', today_count: todayCount }
}
const baseDate = overrideToday ? new Date(overrideToday).getTime() : Date.now()
const yesterday = new Date(baseDate - 86_400_000).toISOString().slice(0, 10)
let newConsecutive: number
if (meta.last_claim_date === today) {
newConsecutive = meta.consecutive_days
} else if (meta.last_claim_date === yesterday) {
newConsecutive = meta.consecutive_days + 1
} else {
newConsecutive = 1
}
const now = new Date().toISOString()
stmts.insertClaim.run({ ric_id: id, claim_date: today, claimed_at: now, consecutive_after: newConsecutive })
stmts.updateClaimMeta.run({ id, last_claim_date: today, consecutive_days: newConsecutive })
let gradeUpgraded = false
let newGrade: string | undefined
if (newConsecutive >= CONSECUTIVE_UPGRADE_THRESHOLD) {
const cert = stmts.findById.get(id) as any
if (cert?.grade === 'unknown') {
stmts.updateGrade.run({ id, grade: 'healthy', grade_updated_at: now })
gradeUpgraded = true
newGrade = 'healthy'
}
}
return { ok: true, today_count: todayCount + 1, consecutive_days: newConsecutive, grade_upgraded: gradeUpgraded, new_grade: newGrade }
},
countRecentReports(id: string) {
return (stmts.countRecentReports.get(id) as { cnt: number }).cnt
},
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('botStore — uniqueness checks', () => {
let store: ReturnType<typeof makeStore>
beforeEach(() => {
store = makeStore(createTestDb())
})
it('findByPublicKey returns null when no bot registered', () => {
expect(store.findByPublicKey('ed25519:aabbccdd')).toBeNull()
})
it('findByPublicKey finds a registered bot', () => {
store.insertBot('ric_test_001', 'ed25519:aabbccdd')
expect(store.findByPublicKey('ed25519:aabbccdd')).toBe('ric_test_001')
})
it('findByEmailAndBotName returns null when no match', () => {
expect(store.findByEmailAndBotName('[email protected]', 'Bot-ric_test_001')).toBeNull()
})
it('findByEmailAndBotName finds after insert', () => {
store.insertBot('ric_test_002', 'ed25519:deadbeef')
expect(store.findByEmailAndBotName('[email protected]', 'Bot-ric_test_002')).toBe('ric_test_002')
})
})
describe('botStore — recordClaim: daily limits', () => {
let store: ReturnType<typeof makeStore>
beforeEach(() => {
store = makeStore(createTestDb())
store.insertBot('ric_bot_1', 'ed25519:11111111')
})
it('first claim succeeds', () => {
const result = store.recordClaim('ric_bot_1')
expect(result.ok).toBe(true)
expect(result.today_count).toBe(1)
})
it('second claim on same day succeeds', () => {
store.recordClaim('ric_bot_1')
const result = store.recordClaim('ric_bot_1')
expect(result.ok).toBe(true)
expect(result.today_count).toBe(2)
})
it('third claim on same day is rejected with DAILY_LIMIT_REACHED', () => {
store.recordClaim('ric_bot_1')
store.recordClaim('ric_bot_1')
const result = store.recordClaim('ric_bot_1')
expect(result.ok).toBe(false)
expect(result.error).toBe('DAILY_LIMIT_REACHED')
expect(result.today_count).toBe(2)
})
it('returns NOT_FOUND for unknown bot', () => {
const result = store.recordClaim('ric_does_not_exist')
expect(result.ok).toBe(false)
expect(result.error).toBe('NOT_FOUND')
})
})
describe('botStore — recordClaim: streak and grade upgrade', () => {
let store: ReturnType<typeof makeStore>
beforeEach(() => {
store = makeStore(createTestDb())
store.insertBot('ric_streak', 'ed25519:22222222', 'unknown')
})
it('first ever claim sets consecutive_days to 1', () => {
const result = store.recordClaim('ric_streak')
expect(result.consecutive_days).toBe(1)
})
it('does NOT upgrade grade after 1 day', () => {
const result = store.recordClaim('ric_streak')
expect(result.grade_upgraded).toBe(false)
})
it('upgrades grade unknown→healthy after 3 consecutive days', () => {
const today = new Date()
// day 1
const d1 = new Date(today); d1.setDate(today.getDate() - 2)
store.recordClaim('ric_streak', d1.toISOString().slice(0, 10))
// day 2
const d2 = new Date(today); d2.setDate(today.getDate() - 1)
store.recordClaim('ric_streak', d2.toISOString().slice(0, 10))
// day 3 (today)
const result = store.recordClaim('ric_streak', today.toISOString().slice(0, 10))
expect(result.ok).toBe(true)
expect(result.consecutive_days).toBe(3)
expect(result.grade_upgraded).toBe(true)
expect(result.new_grade).toBe('healthy')
})
it('does NOT re-upgrade a healthy bot', () => {
store.updateGrade('ric_streak', 'healthy')
// fast-forward 3 days
const today = new Date()
const d1 = new Date(today); d1.setDate(today.getDate() - 2)
store.recordClaim('ric_streak', d1.toISOString().slice(0, 10))
const d2 = new Date(today); d2.setDate(today.getDate() - 1)
store.recordClaim('ric_streak', d2.toISOString().slice(0, 10))
const result = store.recordClaim('ric_streak', today.toISOString().slice(0, 10))
expect(result.grade_upgraded).toBe(false)
})
it('resets streak on gap day', () => {
const d1 = new Date(); d1.setDate(d1.getDate() - 5) // 5 days ago
store.recordClaim('ric_streak', d1.toISOString().slice(0, 10))
// skip 4 days, claim today
const result = store.recordClaim('ric_streak', new Date().toISOString().slice(0, 10))
expect(result.consecutive_days).toBe(1)
})
})
describe('botStore — updateGrade', () => {
let store: ReturnType<typeof makeStore>
beforeEach(() => {
store = makeStore(createTestDb())
store.insertBot('ric_grade', 'ed25519:33333333', 'unknown')
})
it('updates grade to dangerous', () => {
store.updateGrade('ric_grade', 'dangerous')
const bot = store.findById('ric_grade')
expect(bot.grade).toBe('dangerous')
})
it('updates grade back to healthy', () => {
store.updateGrade('ric_grade', 'dangerous')
store.updateGrade('ric_grade', 'healthy')
const bot = store.findById('ric_grade')
expect(bot.grade).toBe('healthy')
})
})
FILE:packages/registry/src/store/botStore.ts
import { db } from './db.js'
import type { RICCertificate } from '../models/certificate.js'
// ── Row ↔ Certificate conversion ─────────────────────────
function rowToCert(row: any): RICCertificate {
return {
ric_version: '1.0',
id: row.id,
created_at: row.created_at,
grade: row.grade,
grade_updated_at: row.grade_updated_at,
public_key: row.public_key,
signature: row.signature,
developer: {
name: row.dev_name,
email: row.dev_email,
org: row.dev_org ?? undefined,
website: row.dev_website ?? undefined,
verified: row.dev_verified === 1,
},
bot: {
name: row.bot_name,
version: row.bot_version,
purpose: row.bot_purpose,
capabilities: JSON.parse(row.bot_capabilities),
user_agent: row.bot_user_agent,
},
}
}
const DAILY_CLAIM_LIMIT = 2
const CONSECUTIVE_UPGRADE_THRESHOLD = 3
const stmts = {
insert: db.prepare(`
INSERT INTO bots (
id, created_at, grade, grade_updated_at, public_key, signature,
dev_name, dev_email, dev_org, dev_website, dev_verified,
bot_name, bot_version, bot_purpose, bot_capabilities, bot_user_agent
) VALUES (
@id, @created_at, @grade, @grade_updated_at, @public_key, @signature,
@dev_name, @dev_email, @dev_org, @dev_website, @dev_verified,
@bot_name, @bot_version, @bot_purpose, @bot_capabilities, @bot_user_agent
)
`),
findById: db.prepare(`SELECT * FROM bots WHERE id = ?`),
findByPublicKey: db.prepare(`SELECT id FROM bots WHERE public_key = ?`),
findByEmailAndBotName: db.prepare(`
SELECT id FROM bots WHERE dev_email = ? AND bot_name = ?
`),
listSummary: db.prepare(`
SELECT id, bot_name, bot_purpose, grade, dev_name, dev_org, created_at
FROM bots ORDER BY created_at DESC
`),
updateGrade: db.prepare(`
UPDATE bots SET grade = @grade, grade_updated_at = @grade_updated_at WHERE id = @id
`),
countRecentReports: db.prepare(`
SELECT COUNT(*) as cnt FROM audit_log
WHERE ric_id = ? AND event = 'violation_report'
AND timestamp > datetime('now', '-24 hours')
`),
// RFC 9421 nonce statements
insertNonce: db.prepare(`
INSERT OR IGNORE INTO used_nonces (nonce, ric_id, used_at) VALUES (?, ?, ?)
`),
nonceExists: db.prepare(`SELECT 1 FROM used_nonces WHERE nonce = ?`),
sweepNonces: db.prepare(`DELETE FROM used_nonces WHERE used_at < ?`),
// Claim statements
countTodayClaims: db.prepare(`
SELECT COUNT(*) as cnt FROM claims
WHERE ric_id = ? AND claim_date = ?
`),
insertClaim: db.prepare(`
INSERT INTO claims (ric_id, claim_date, claimed_at, consecutive_after)
VALUES (@ric_id, @claim_date, @claimed_at, @consecutive_after)
`),
getClaimMeta: db.prepare(`
SELECT last_claim_date, consecutive_days, total_claims FROM bots WHERE id = ?
`),
updateClaimMeta: db.prepare(`
UPDATE bots
SET last_claim_date = @last_claim_date,
consecutive_days = @consecutive_days,
total_claims = total_claims + 1
WHERE id = @id
`),
}
export const botStore = {
findByPublicKey(publicKey: string): string | null {
const row = stmts.findByPublicKey.get(publicKey) as { id: string } | undefined
return row?.id ?? null
},
findByEmailAndBotName(email: string, botName: string): string | null {
const row = stmts.findByEmailAndBotName.get(email, botName) as { id: string } | undefined
return row?.id ?? null
},
insert(cert: RICCertificate): void {
stmts.insert.run({
id: cert.id,
created_at: cert.created_at,
grade: cert.grade,
grade_updated_at: cert.grade_updated_at,
public_key: cert.public_key,
signature: cert.signature,
dev_name: cert.developer.name,
dev_email: cert.developer.email,
dev_org: cert.developer.org ?? null,
dev_website: cert.developer.website ?? null,
dev_verified: cert.developer.verified ? 1 : 0,
bot_name: cert.bot.name,
bot_version: cert.bot.version,
bot_purpose: cert.bot.purpose,
bot_capabilities: JSON.stringify(cert.bot.capabilities),
bot_user_agent: cert.bot.user_agent,
})
},
findById(id: string): RICCertificate | null {
const row = stmts.findById.get(id)
return row ? rowToCert(row) : null
},
listSummary() {
return (stmts.listSummary.all() as any[]).map((row) => ({
id: row.id,
name: row.bot_name,
purpose: row.bot_purpose,
grade: row.grade,
developer_org: row.dev_org || row.dev_name,
created_at: row.created_at,
}))
},
updateGrade(id: string, grade: string): void {
stmts.updateGrade.run({ id, grade, grade_updated_at: new Date().toISOString() })
},
countRecentReports(id: string): number {
const row = stmts.countRecentReports.get(id) as { cnt: number }
return row.cnt
},
/**
* RFC 9421 nonce uniqueness check.
* Returns true if the nonce is fresh (never seen).
* Marks it as used and sweeps nonces older than 10 minutes.
*/
checkAndMarkNonce(nonce: string, ricId: string, createdSec: number): boolean {
const existing = stmts.nonceExists.get(nonce)
if (existing) return false
stmts.insertNonce.run(nonce, ricId, createdSec)
// Sweep nonces older than 10 minutes
stmts.sweepNonces.run(createdSec - 600)
return true
},
/**
* Attempt a daily claim for a bot.
*
* Rules:
* - Max DAILY_CLAIM_LIMIT (2) claims per calendar day
* - Tracks consecutive daily streak
* - After CONSECUTIVE_UPGRADE_THRESHOLD (3) consecutive days → grade unknown→healthy
*
* Returns the result of the claim or an error code.
*/
recordClaim(id: string): {
ok: boolean
error?: 'DAILY_LIMIT_REACHED' | 'NOT_FOUND'
today_count?: number
consecutive_days?: number
grade_upgraded?: boolean
new_grade?: string
} {
const meta = stmts.getClaimMeta.get(id) as {
last_claim_date: string | null
consecutive_days: number
total_claims: number
} | undefined
if (!meta) return { ok: false, error: 'NOT_FOUND' }
const today = new Date().toISOString().slice(0, 10)
// Check daily limit
const todayCount = (stmts.countTodayClaims.get(id, today) as { cnt: number }).cnt
if (todayCount >= DAILY_CLAIM_LIMIT) {
return { ok: false, error: 'DAILY_LIMIT_REACHED', today_count: todayCount }
}
// Compute new consecutive streak
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)
let newConsecutive: number
if (meta.last_claim_date === today) {
// Second claim on same day — streak unchanged
newConsecutive = meta.consecutive_days
} else if (meta.last_claim_date === yesterday) {
// Claimed yesterday → extend streak
newConsecutive = meta.consecutive_days + 1
} else {
// Gap or first ever claim → reset streak to 1
newConsecutive = 1
}
const now = new Date().toISOString()
// Persist claim record
stmts.insertClaim.run({
ric_id: id,
claim_date: today,
claimed_at: now,
consecutive_after: newConsecutive,
})
// Update bot meta (only update last_claim_date if this is first claim today)
stmts.updateClaimMeta.run({
id,
last_claim_date: today,
consecutive_days: newConsecutive,
})
// Grade upgrade: unknown → healthy after N consecutive days
let gradeUpgraded = false
let newGrade: string | undefined
if (newConsecutive >= CONSECUTIVE_UPGRADE_THRESHOLD) {
const cert = stmts.findById.get(id) as any
if (cert && cert.grade === 'unknown') {
stmts.updateGrade.run({ id, grade: 'healthy', grade_updated_at: now })
gradeUpgraded = true
newGrade = 'healthy'
}
}
return {
ok: true,
today_count: todayCount + 1,
consecutive_days: newConsecutive,
grade_upgraded: gradeUpgraded,
new_grade: newGrade,
}
},
}
FILE:packages/registry/src/store/db.ts
import Database, { type Database as DatabaseType } from 'better-sqlite3'
import path from 'path'
import { fileURLToPath } from 'url'
import { mkdirSync } from 'fs'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DB_PATH = process.env.RIC_DB_PATH || path.join(__dirname, '../../data/registry.db')
mkdirSync(path.dirname(DB_PATH), { recursive: true })
export const db: DatabaseType = new Database(DB_PATH)
// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
// ── Schema ────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS bots (
id TEXT PRIMARY KEY,
ric_version TEXT NOT NULL DEFAULT '1.0',
created_at TEXT NOT NULL,
grade TEXT NOT NULL DEFAULT 'unknown',
grade_updated_at TEXT NOT NULL,
public_key TEXT NOT NULL,
signature TEXT NOT NULL,
-- Developer fields
dev_name TEXT NOT NULL,
dev_email TEXT NOT NULL,
dev_org TEXT,
dev_website TEXT,
dev_verified INTEGER NOT NULL DEFAULT 0,
-- Bot fields
bot_name TEXT NOT NULL,
bot_version TEXT NOT NULL,
bot_purpose TEXT NOT NULL,
bot_capabilities TEXT NOT NULL, -- JSON array
bot_user_agent TEXT NOT NULL,
-- Claim tracking
last_claim_date TEXT, -- ISO date of most recent claim (YYYY-MM-DD)
consecutive_days INTEGER NOT NULL DEFAULT 0, -- streak of consecutive daily claims
total_claims INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
ric_id TEXT NOT NULL,
event TEXT NOT NULL,
old_grade TEXT,
new_grade TEXT,
reason TEXT,
reporter TEXT,
description TEXT,
timestamp TEXT NOT NULL,
FOREIGN KEY (ric_id) REFERENCES bots(id)
);
-- One row per claim attempt, for daily-limit checks and history
CREATE TABLE IF NOT EXISTS claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ric_id TEXT NOT NULL,
claim_date TEXT NOT NULL, -- YYYY-MM-DD
claimed_at TEXT NOT NULL, -- ISO timestamp
consecutive_after INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (ric_id) REFERENCES bots(id)
);
-- RFC 9421 nonce tracking — prevents replay attacks
-- Nonces expire after 10 minutes; a background sweep cleans up old rows
CREATE TABLE IF NOT EXISTS used_nonces (
nonce TEXT PRIMARY KEY,
ric_id TEXT NOT NULL,
used_at INTEGER NOT NULL -- Unix seconds
);
CREATE INDEX IF NOT EXISTS idx_nonces_used_at ON used_nonces(used_at);
`)
export default db
FILE:packages/registry/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
FILE:packages/sdk/README.md
# @robot-id-card/sdk
> Website SDK for verifying Robot ID Card bot identities in Express and Fastify apps.
## Install
```bash
npm install @robot-id-card/sdk
```
## Quick Start
### Express
```typescript
import express from 'express'
import { ricMiddleware } from '@robot-id-card/sdk/middleware/express'
const app = express()
// Block unknown/dangerous bots on protected routes
app.use('/api/write', ricMiddleware({ minGrade: 'healthy' }))
// Only allow healthy bots with post_content capability
app.use('/api/posts', ricMiddleware({
minGrade: 'healthy',
requiredPermissionLevel: 4,
}))
```
### Fastify
```typescript
import Fastify from 'fastify'
import { ricPlugin } from '@robot-id-card/sdk/middleware/fastify'
const server = Fastify()
await server.register(ricPlugin, { minGrade: 'healthy' })
```
### Manual verification
```typescript
import { getRICHeaders, verifyRICRequest } from '@robot-id-card/sdk'
// In your request handler:
const headers = getRICHeaders(req)
const result = await verifyRICRequest(headers, {
registryUrl: 'https://registry.robotidcard.dev',
cacheTtl: 300, // seconds
})
if (!result?.valid) {
// Bot not verified
}
console.log(result.grade) // 'healthy' | 'unknown' | 'dangerous'
console.log(result.permission_level) // 0–5
```
## HTTP Headers (set by the bot)
| Header | Description |
|--------|-------------|
| `X-RIC-ID` | Bot's RIC identifier (`ric_...`) |
| `X-RIC-Timestamp` | Unix timestamp in ms (replay protection) |
| `X-RIC-Signature` | Ed25519 signature over `ric_id:timestamp:message` |
## Grade Levels
| Grade | Meaning | Permission Level |
|-------|---------|-----------------|
| `dangerous` | Reported / flagged | 0 — blocked |
| `unknown` | Registered, not yet trusted | 1 — read only |
| `healthy` | 3+ consecutive daily claims | 1–5 based on capabilities |
FILE:packages/sdk/package.json
{
"name": "@robot-id-card/sdk",
"version": "0.2.0",
"description": "Website SDK for verifying Robot ID Card bot identities",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc",
"test": "node ../../node_modules/vitest/vitest.mjs run src",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@noble/ed25519": "^2.1.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"vitest": "^1.6.1"
},
"peerDependencies": {
"fastify": ">=4"
},
"peerDependenciesMeta": {
"fastify": {
"optional": true
}
},
"publishConfig": {
"access": "public"
}
}
FILE:packages/sdk/src/__tests__/verify.test.ts
import { describe, it, expect } from 'vitest'
import { getRICHeaders, meetsGradeRequirement } from '../verify.js'
describe('getRICHeaders', () => {
it('extracts RIC headers from request', () => {
const req = {
headers: {
'x-ric-id': 'ric_abc123',
'x-ric-timestamp': '1700000000',
'x-ric-signature': 'sig_xyz',
},
}
const result = getRICHeaders(req)
expect(result.ricId).toBe('ric_abc123')
expect(result.timestamp).toBe('1700000000')
expect(result.signature).toBe('sig_xyz')
})
it('returns undefined for missing headers', () => {
const req = { headers: {} }
const result = getRICHeaders(req)
expect(result.ricId).toBeUndefined()
expect(result.timestamp).toBeUndefined()
expect(result.signature).toBeUndefined()
})
})
describe('meetsGradeRequirement', () => {
it('healthy meets all requirements', () => {
expect(meetsGradeRequirement('healthy', 'dangerous')).toBe(true)
expect(meetsGradeRequirement('healthy', 'unknown')).toBe(true)
expect(meetsGradeRequirement('healthy', 'healthy')).toBe(true)
})
it('unknown meets dangerous and unknown requirements', () => {
expect(meetsGradeRequirement('unknown', 'dangerous')).toBe(true)
expect(meetsGradeRequirement('unknown', 'unknown')).toBe(true)
expect(meetsGradeRequirement('unknown', 'healthy')).toBe(false)
})
it('dangerous only meets dangerous requirement', () => {
expect(meetsGradeRequirement('dangerous', 'dangerous')).toBe(true)
expect(meetsGradeRequirement('dangerous', 'unknown')).toBe(false)
expect(meetsGradeRequirement('dangerous', 'healthy')).toBe(false)
})
it('unknown grade defaults to rank 0', () => {
expect(meetsGradeRequirement('nonexistent', 'dangerous')).toBe(true)
expect(meetsGradeRequirement('nonexistent', 'unknown')).toBe(false)
})
})
FILE:packages/sdk/src/index.ts
/**
* @robot-id-card/sdk
*
* Drop-in middleware for websites to verify bot identity and enforce permission levels.
* Compatible with Express, Fastify, Next.js, Koa, and vanilla Node.js.
*/
export { RICMiddleware } from './middleware/express.js'
export { RICFastifyPlugin } from './middleware/fastify.js'
export { verifyRICRequest, getRICHeaders } from './verify.js'
export type { RICVerifyResult, RICPermissionRule, RICMiddlewareOptions } from './types.js'
FILE:packages/sdk/src/middleware/express.ts
/**
* Express.js middleware for RIC verification
*
* @example
* import { RICMiddleware } from '@robot-id-card/sdk'
*
* app.use(RICMiddleware({
* permissions: {
* '/api/posts': { minGrade: 'unknown', level: 1 },
* '/api/comment': { minGrade: 'healthy', level: 4 },
* }
* }))
*/
import { getRICHeaders, verifyRICRequest, meetsGradeRequirement } from '../verify.js'
import type { RICMiddlewareOptions } from '../types.js'
export function RICMiddleware(options: RICMiddlewareOptions = {}) {
return async function ricMiddleware(req: any, res: any, next: () => void) {
const headers = getRICHeaders(req)
// No RIC headers at all — not a bot, pass through
const hasRFC9421 = !!(headers.signatureInput && headers.signature)
const hasLegacy = !!headers.ricId
if (!hasRFC9421 && !hasLegacy) return next()
const result = await verifyRICRequest(headers, {
registryUrl: options.registryUrl,
cacheTtl: options.cacheTtl,
// RFC 9421: pass request context so registry can reconstruct signature base
authority: req.hostname || req.headers?.host?.split(':')[0],
method: req.method,
path: req.path,
})
if (!result) return next()
// Attach RIC info to request for downstream use
req.ric = result
options.onBotDetected?.(result, req)
// Check permission rules for this route
const rule =
options.permissions?.[req.path] ||
options.permissions?.[req.route?.path] ||
options.defaultRule
if (rule) {
if (!result.valid) {
return res.status(401).json({
error: 'Invalid RIC certificate',
code: 'RIC_INVALID',
})
}
if (!meetsGradeRequirement(result.grade, rule.minGrade)) {
return res.status(403).json({
error: `Bot grade 'result.grade' does not meet minimum required grade 'rule.minGrade'`,
code: 'RIC_GRADE_INSUFFICIENT',
your_grade: result.grade,
required_grade: rule.minGrade,
upgrade_info: 'https://robotidcard.dev/upgrade',
})
}
if (result.permission_level < rule.level) {
return res.status(403).json({
error: `Permission level result.permission_level insufficient for this action (requires rule.level)`,
code: 'RIC_PERMISSION_DENIED',
your_level: result.permission_level,
required_level: rule.level,
})
}
}
next()
}
}
FILE:packages/sdk/src/middleware/fastify.ts
import type { FastifyPluginAsync } from 'fastify'
import { getRICHeaders, verifyRICRequest, meetsGradeRequirement } from '../verify.js'
import type { RICMiddlewareOptions } from '../types.js'
export const RICFastifyPlugin: FastifyPluginAsync<RICMiddlewareOptions> = async (
fastify,
options
) => {
fastify.addHook('preHandler', async (request, reply) => {
const headers = getRICHeaders(request as any)
if (!headers.ricId) return
const result = await verifyRICRequest(headers, {
registryUrl: options.registryUrl,
cacheTtl: options.cacheTtl,
})
if (!result) return
;(request as any).ric = result
options.onBotDetected?.(result, request)
const rule = options.permissions?.[request.url] || options.defaultRule
if (!rule) return
if (!result.valid) {
return reply.status(401).send({ error: 'Invalid RIC certificate', code: 'RIC_INVALID' })
}
if (!meetsGradeRequirement(result.grade, rule.minGrade)) {
return reply.status(403).send({
error: `Grade 'result.grade' insufficient`,
required: rule.minGrade,
code: 'RIC_GRADE_INSUFFICIENT',
})
}
if (result.permission_level < rule.level) {
return reply.status(403).send({
error: 'Permission denied',
code: 'RIC_PERMISSION_DENIED',
your_level: result.permission_level,
required_level: rule.level,
})
}
})
}
FILE:packages/sdk/src/types.ts
export type Grade = 'healthy' | 'unknown' | 'dangerous'
export interface RICVerifyResult {
valid: boolean
id: string
bot: { name: string; purpose: string }
developer: { name: string; org?: string }
grade: Grade
permission_level: number // 0-5
permission_label: string
error?: string
}
export interface RICPermissionRule {
minGrade: Grade
level: number
/** Minimum age of bot registration in days before granting this level */
minAgeDays?: number
}
export interface RICMiddlewareOptions {
/** Registry API endpoint. Defaults to official registry. */
registryUrl?: string
/** Per-route permission rules */
permissions?: Record<string, RICPermissionRule>
/** Default rule when no specific route matches */
defaultRule?: RICPermissionRule
/** Called when a bot is detected (valid or not) */
onBotDetected?: (result: RICVerifyResult, req: any) => void
/** Cache TTL in seconds for certificate lookups. Default: 300 */
cacheTtl?: number
}
FILE:packages/sdk/src/verify.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { verifyRICRequest, getRICHeaders, meetsGradeRequirement } from './verify.js'
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeHeaders(overrides: Record<string, string | undefined> = {}) {
return {
headers: {
'x-ric-id': 'ric_aabb1122_xyz12345',
'x-ric-timestamp': String(Date.now()),
'x-ric-signature': 'deadbeef1234',
...overrides,
} as Record<string, string | undefined>,
}
}
const mockVerifyResult = {
valid: true,
id: 'ric_aabb1122_xyz12345',
bot: { name: 'TestBot', purpose: 'Automated testing' },
developer: { name: 'Test Dev', org: undefined },
grade: 'healthy',
permission_level: 3,
permission_label: 'Like / react',
}
// ── getRICHeaders ─────────────────────────────────────────────────────────────
describe('getRICHeaders', () => {
it('extracts all three RIC headers', () => {
const req = makeHeaders()
const headers = getRICHeaders(req)
expect(headers.ricId).toBe('ric_aabb1122_xyz12345')
expect(headers.timestamp).toBeDefined()
expect(headers.signature).toBe('deadbeef1234')
})
it('returns undefined for missing headers', () => {
const req = { headers: {} as Record<string, string | undefined> }
const headers = getRICHeaders(req)
expect(headers.ricId).toBeUndefined()
expect(headers.timestamp).toBeUndefined()
expect(headers.signature).toBeUndefined()
})
})
// ── verifyRICRequest ──────────────────────────────────────────────────────────
describe('verifyRICRequest', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
// Clear module-level cache between tests
// (cache is keyed by ricId:timestamp — using unique timestamps prevents stale cache hits)
})
it('returns null when all headers missing', async () => {
const result = await verifyRICRequest({})
expect(result).toBeNull()
expect(fetch).not.toHaveBeenCalled()
})
it('returns null when any header is missing', async () => {
const result = await verifyRICRequest({ ricId: 'ric_test', timestamp: String(Date.now()) })
expect(result).toBeNull()
})
it('calls registry /v1/verify with correct payload', async () => {
const ts = String(Date.now())
vi.mocked(fetch).mockResolvedValueOnce({
json: async () => mockVerifyResult,
} as Response)
await verifyRICRequest(
{ ricId: 'ric_aabb1122_abc', timestamp: ts, signature: 'sig123' },
{ registryUrl: 'http://localhost:3000' }
)
expect(fetch).toHaveBeenCalledWith(
'http://localhost:3000/v1/verify',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"ric_id":"ric_aabb1122_abc"'),
})
)
})
it('returns registry response on success', async () => {
const ts = String(Date.now() + 1)
vi.mocked(fetch).mockResolvedValueOnce({
json: async () => mockVerifyResult,
} as Response)
const result = await verifyRICRequest(
{ ricId: 'ric_aabb1122_def', timestamp: ts, signature: 'sig456' },
{ registryUrl: 'http://localhost:3000' }
)
expect(result).toMatchObject({ valid: true, grade: 'healthy' })
})
it('returns null when fetch throws (registry unreachable)', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('connection refused'))
const result = await verifyRICRequest(
{ ricId: 'ric_aabb1122_ghi', timestamp: String(Date.now() + 2), signature: 'sig789' },
{ registryUrl: 'http://localhost:3000' }
)
expect(result).toBeNull()
})
})
// ── meetsGradeRequirement ─────────────────────────────────────────────────────
describe('meetsGradeRequirement', () => {
it('healthy meets healthy requirement', () => {
expect(meetsGradeRequirement('healthy', 'healthy')).toBe(true)
})
it('healthy meets unknown requirement', () => {
expect(meetsGradeRequirement('healthy', 'unknown')).toBe(true)
})
it('unknown does NOT meet healthy requirement', () => {
expect(meetsGradeRequirement('unknown', 'healthy')).toBe(false)
})
it('dangerous does NOT meet unknown requirement', () => {
expect(meetsGradeRequirement('dangerous', 'unknown')).toBe(false)
})
it('dangerous meets dangerous requirement', () => {
expect(meetsGradeRequirement('dangerous', 'dangerous')).toBe(true)
})
it('unknown meets unknown requirement', () => {
expect(meetsGradeRequirement('unknown', 'unknown')).toBe(true)
})
it('healthy meets dangerous requirement (all healthy bots clear the bar)', () => {
expect(meetsGradeRequirement('healthy', 'dangerous')).toBe(true)
})
})
FILE:packages/sdk/src/verify.ts
import type { RICVerifyResult } from './types.js'
const DEFAULT_REGISTRY = 'https://registry.robotidcard.dev'
const cache = new Map<string, { result: RICVerifyResult; expiresAt: number }>()
// ── Header extraction ────────────────────────────────────────────────────────
export interface RICHeaders {
// RFC 9421 (preferred)
signatureInput?: string // Signature-Input header value
signature?: string // Signature header value
signatureAgent?: string // Signature-Agent header value
// Derived from RFC 9421 for cache key and quick access
keyid?: string
// Legacy X-RIC-* (deprecated)
ricId?: string
timestamp?: string
legacySignature?: string
}
/**
* Extract RIC identity headers from an incoming request.
* Supports both RFC 9421 (Signature/Signature-Input) and legacy (X-RIC-*).
*/
export function getRICHeaders(req: { headers: Record<string, string | string[] | undefined> }): RICHeaders {
const h = req.headers
const signatureInput = h['signature-input'] as string | undefined
const signature = h['signature'] as string | undefined
const signatureAgent = h['signature-agent'] as string | undefined
let keyid: string | undefined
if (signatureInput) {
// Extract keyid from Signature-Input: ric=(...);keyid="ric_abc_xyz";...
keyid = signatureInput.match(/keyid="([^"]+)"/)?.[1]
}
return {
signatureInput,
signature,
signatureAgent,
keyid,
// Legacy fallback
ricId: h['x-ric-id'] as string | undefined,
timestamp: h['x-ric-timestamp'] as string | undefined,
legacySignature: h['x-ric-signature'] as string | undefined,
}
}
// ── Verification ─────────────────────────────────────────────────────────────
/**
* Verify an incoming bot request by calling the RIC registry.
*
* For RFC 9421 requests, pass the `authority`, `method`, and `path` from
* the original request so the registry can reconstruct the signature base.
*
* Results are cached by keyid+created for `cacheTtl` seconds (default 300).
*/
export async function verifyRICRequest(
headers: RICHeaders,
options: {
registryUrl?: string
cacheTtl?: number
authority?: string
method?: string
path?: string
} = {}
): Promise<RICVerifyResult | null> {
const registryUrl = options.registryUrl || DEFAULT_REGISTRY
const cacheTtl = (options.cacheTtl ?? 300) * 1000
// ── RFC 9421 path ──────────────────────────────────────────────────────────
if (headers.signatureInput && headers.signature) {
const cacheKey = `rfc9421:headers.keyid:headers.signatureInput`
const cached = cache.get(cacheKey)
if (cached && cached.expiresAt > Date.now()) return cached.result
try {
const res = await fetch(`registryUrl/v1/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
signature_input: headers.signatureInput,
signature: headers.signature,
signature_agent: headers.signatureAgent,
authority: options.authority ?? '',
method: options.method ?? 'GET',
path: options.path ?? '/',
}),
})
const result: RICVerifyResult = await res.json()
cache.set(cacheKey, { result, expiresAt: Date.now() + cacheTtl })
return result
} catch {
return null
}
}
// ── Legacy X-RIC-* path ───────────────────────────────────────────────────
const { ricId, timestamp, legacySignature } = headers
if (!ricId || !timestamp || !legacySignature) return null
const cacheKey = `legacy:ricId:timestamp`
const cached = cache.get(cacheKey)
if (cached && cached.expiresAt > Date.now()) return cached.result
try {
const res = await fetch(`registryUrl/v1/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ric_id: ricId,
timestamp: Number(timestamp),
signature: legacySignature,
message: '',
}),
})
const result: RICVerifyResult = await res.json()
cache.set(cacheKey, { result, expiresAt: Date.now() + cacheTtl })
return result
} catch {
return null
}
}
const GRADE_RANK: Record<string, number> = { dangerous: 0, unknown: 1, healthy: 2 }
export function meetsGradeRequirement(grade: string, minGrade: string): boolean {
return (GRADE_RANK[grade] ?? 0) >= (GRADE_RANK[minGrade] ?? 0)
}
FILE:packages/sdk/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
FILE:render.yaml
services:
- type: web
name: ric-registry
runtime: docker
dockerfilePath: ./packages/registry/Dockerfile
dockerContext: .
healthCheckPath: /health
envVars:
- key: NODE_ENV
value: production
- key: RIC_ADMIN_KEY
generateValue: true # Render auto-generates a secret — copy from dashboard
disk:
name: ric-data
mountPath: /data
sizeGB: 1
FILE:scripts/test-local.sh
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────
# RIC v0.2 本机完整测试脚本
# 用法: bash scripts/test-local.sh
# 前提: registry 已在 localhost:3000 运行
# ──────────────────────────────────────────────────────────────
set -euo pipefail
REGISTRY="http://localhost:3000"
export RIC_REGISTRY="$REGISTRY"
PASS=0
FAIL=0
TMP=$(mktemp -d)
trap "rm -rf $TMP" EXIT
# ── 颜色 ──────────────────────────────────────────────────────
GREEN="\033[0;32m"; RED="\033[0;31m"; YELLOW="\033[1;33m"; RESET="\033[0m"; BOLD="\033[1m"
pass() { echo -e " GREEN✓RESET $1"; PASS=$((PASS + 1)); }
fail() { echo -e " RED✗RESET $1"; FAIL=$((FAIL + 1)); }
section() { echo -e "\nBOLDYELLOW▶ $1RESET"; }
# ── 工具函数 ──────────────────────────────────────────────────
post() { curl -s -X POST "$REGISTRY$1" -H "Content-Type: application/json" -d "$2"; }
get() { curl -s "$REGISTRY$1"; }
# 生成唯一公钥
gen_key() { echo "ed25519:$(python3 -c "import secrets; print(secrets.token_hex(32))")"; }
# ─────────────────────────────────────────────────────────────
section "0. Registry 健康检查"
# ─────────────────────────────────────────────────────────────
HEALTH=$(get /health)
VERSION=$(echo "$HEALTH" | python3 -c "import sys,json; print(json.load(sys.stdin)['version'])" 2>/dev/null)
if [[ "$VERSION" == "0.3.0" ]]; then
pass "Registry 运行正常 (v$VERSION)"
else
fail "Registry 未响应或版本不对: $HEALTH"
echo -e "RED请先启动 registry: npm run dev --workspace=packages/registryRESET"
exit 1
fi
# ─────────────────────────────────────────────────────────────
section "1. CLI keygen — 生成密钥对"
# ─────────────────────────────────────────────────────────────
cd "$(dirname "$0")/.."
KEY_FILE="$TMP/bot.key.json"
npx tsx packages/cli/src/index.ts keygen --out "$KEY_FILE" > /dev/null 2>&1
if [[ -f "$KEY_FILE" ]]; then
PUB=$(python3 -c "import json; d=json.load(open('$KEY_FILE')); print(d['public_key'])")
PRIV=$(python3 -c "import json; d=json.load(open('$KEY_FILE')); print(d['private_key_hex'])")
if [[ "$PUB" == ed25519:* ]] && [[ #PUB -eq 72 ]]; then
pass "keygen 生成文件正确 (公钥: 0:20...)"
else
fail "公钥格式不对: $PUB"
fi
else
fail "keygen 未生成密钥文件"
fi
# ─────────────────────────────────────────────────────────────
section "2. CLI register — 正常注册"
# ─────────────────────────────────────────────────────────────
CERT_FILE="$TMP/bot.ric.json"
npx tsx packages/cli/src/index.ts register \
--name "LocalTestBot" \
--purpose "Automated local test bot for RIC v0.2 validation" \
--developer "[email protected]" \
--org "TestOrg" \
--key "$KEY_FILE" \
--out "$CERT_FILE" > /dev/null 2>&1
if [[ -f "$CERT_FILE" ]]; then
RIC_ID=$(python3 -c "import json; print(json.load(open('$CERT_FILE'))['id'])")
GRADE=$(python3 -c "import json; print(json.load(open('$CERT_FILE'))['grade'])")
if [[ "$RIC_ID" == ric_* ]] && [[ "$GRADE" == "unknown" ]]; then
pass "CLI 注册成功 (ID: $RIC_ID, 初始评级: $GRADE)"
else
fail "证书内容异常: id=$RIC_ID grade=$GRADE"
fi
else
fail "CLI 注册未生成证书文件"
RIC_ID=""
fi
# ─────────────────────────────────────────────────────────────
section "3. 唯一性 — 重复公钥拦截"
# ─────────────────────────────────────────────────────────────
RES=$(post /v1/bots/register "{
\"developer\":{\"name\":\"X\",\"email\":\"[email protected]\"},
\"bot\":{\"name\":\"OtherBot\",\"version\":\"1.0.0\",\"purpose\":\"trying to steal a key\",\"capabilities\":[\"read_articles\"],\"user_agent\":\"OtherBot/1.0\"},
\"public_key\":\"$PUB\"
}")
CODE=$(echo "$RES" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code',''))" 2>/dev/null)
if [[ "$CODE" == "DUPLICATE_KEY" ]]; then
pass "重复公钥被拦截 (code: DUPLICATE_KEY)"
else
fail "重复公钥未被拦截: $RES"
fi
# ─────────────────────────────────────────────────────────────
section "4. 唯一性 — 相同 email+名称拦截"
# ─────────────────────────────────────────────────────────────
NEW_KEY=$(gen_key)
RES=$(post /v1/bots/register "{
\"developer\":{\"name\":\"localtest\",\"email\":\"[email protected]\"},
\"bot\":{\"name\":\"LocalTestBot\",\"version\":\"2.0.0\",\"purpose\":\"same name different key attempt\",\"capabilities\":[\"read_articles\"],\"user_agent\":\"LocalTestBot/2.0\"},
\"public_key\":\"$NEW_KEY\"
}")
CODE=$(echo "$RES" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code',''))" 2>/dev/null)
if [[ "$CODE" == "DUPLICATE_BOT" ]]; then
pass "重复 email+名称被拦截 (code: DUPLICATE_BOT)"
else
fail "重复 bot 未被拦截: $RES"
fi
# ─────────────────────────────────────────────────────────────
section "5. API GET /v1/bots/:id — 查询证书"
# ─────────────────────────────────────────────────────────────
if [[ -n "$RIC_ID" ]]; then
RES=$(get "/v1/bots/$RIC_ID")
BOT_NAME=$(echo "$RES" | python3 -c "import sys,json; print(json.load(sys.stdin)['bot']['name'])" 2>/dev/null)
if [[ "$BOT_NAME" == "LocalTestBot" ]]; then
pass "GET /v1/bots/:id 返回正确证书"
else
fail "查询结果异常: $RES"
fi
fi
# ─────────────────────────────────────────────────────────────
section "6. CLI status — 查看 bot 状态"
# ─────────────────────────────────────────────────────────────
if [[ -n "$RIC_ID" ]]; then
STATUS_OUT=$(npx tsx packages/cli/src/index.ts status --cert "$CERT_FILE" 2>/dev/null)
if echo "$STATUS_OUT" | grep -q "LocalTestBot"; then
pass "CLI status 正常显示 bot 信息"
else
fail "CLI status 输出异常: $STATUS_OUT"
fi
fi
# ─────────────────────────────────────────────────────────────
section "7. GET /v1/bots — 列表"
# ─────────────────────────────────────────────────────────────
RES=$(get /v1/bots)
TOTAL=$(echo "$RES" | python3 -c "import sys,json; print(json.load(sys.stdin)['total'])" 2>/dev/null)
if [[ "$TOTAL" -ge 1 ]]; then
pass "GET /v1/bots 返回列表 (共 $TOTAL 个 bot)"
else
fail "列表返回异常: $RES"
fi
# ─────────────────────────────────────────────────────────────
section "8. 举报 + 自动降级 (3次→DANGEROUS)"
# ─────────────────────────────────────────────────────────────
if [[ -n "$RIC_ID" ]]; then
# 用新 bot 来测试举报(不污染 LocalTestBot)
REPORT_KEY=$(gen_key)
REPORT_RES=$(post /v1/bots/register "{
\"developer\":{\"name\":\"report-dev\",\"email\":\"[email protected]\"},
\"bot\":{\"name\":\"ReportTestBot\",\"version\":\"1.0.0\",\"purpose\":\"Bot used for auto-flag test\",\"capabilities\":[\"read_articles\"],\"user_agent\":\"ReportTestBot/1.0\"},
\"public_key\":\"$REPORT_KEY\"
}")
REPORT_BOT_ID=$(echo "$REPORT_RES" | python3 -c "import sys,json; print(json.load(sys.stdin)['certificate']['id'])" 2>/dev/null)
if [[ -n "$REPORT_BOT_ID" ]]; then
for i in 1 2 3; do
post /v1/audit/report "{\"ric_id\":\"$REPORT_BOT_ID\",\"reporter_domain\":\"site$i.com\",\"reason\":\"spam\",\"description\":\"Test report $i\"}" > /dev/null
done
GRADE_AFTER=$(get "/v1/bots/$REPORT_BOT_ID" | python3 -c "import sys,json; print(json.load(sys.stdin)['grade'])" 2>/dev/null)
if [[ "$GRADE_AFTER" == "dangerous" ]]; then
pass "3次举报后自动降级为 DANGEROUS"
else
fail "自动降级未触发,当前评级: $GRADE_AFTER"
fi
fi
fi
# ─────────────────────────────────────────────────────────────
section "9. 审计日志"
# ─────────────────────────────────────────────────────────────
if [[ -n "$REPORT_BOT_ID" ]]; then
RES=$(get "/v1/audit/$REPORT_BOT_ID")
EVENTS=$(echo "$RES" | python3 -c "import sys,json; print(json.load(sys.stdin)['total'])" 2>/dev/null)
if [[ "$EVENTS" -ge 4 ]]; then
pass "审计日志完整 ($EVENTS 条事件: registered + 3×report + grade_changed)"
else
fail "审计日志事件数不够: $EVENTS"
fi
fi
# ─────────────────────────────────────────────────────────────
section "10. Admin grade 更新 (需要 admin_key)"
# ─────────────────────────────────────────────────────────────
if [[ -n "$RIC_ID" ]]; then
# 无 admin_key → 应被拒绝
RES=$(post /v1/audit/grade "{\"ric_id\":\"$RIC_ID\",\"grade\":\"healthy\",\"reason\":\"Test\",\"admin_key\":\"wrong-key\"}")
HTTP_CODE=$(echo "$RES" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error',''))" 2>/dev/null)
if [[ "$HTTP_CODE" == "Forbidden" ]]; then
pass "错误 admin_key 被拒绝 (403 Forbidden)"
else
fail "admin_key 验证未生效: $RES"
fi
# 正确 admin_key → 应成功
RES=$(post /v1/audit/grade "{\"ric_id\":\"$RIC_ID\",\"grade\":\"healthy\",\"reason\":\"Passed local testing\",\"admin_key\":\"dev-admin-key-change-me\"}")
NEW_GRADE=$(echo "$RES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('message',''))" 2>/dev/null)
if echo "$NEW_GRADE" | grep -q "healthy"; then
pass "管理员评级更新成功 → HEALTHY"
else
fail "评级更新失败: $RES"
fi
fi
# ─────────────────────────────────────────────────────────────
section "11. 重启持久化验证"
# ─────────────────────────────────────────────────────────────
if [[ -n "$RIC_ID" ]]; then
GRADE_NOW=$(get "/v1/bots/$RIC_ID" | python3 -c "import sys,json; print(json.load(sys.stdin)['grade'])" 2>/dev/null)
if [[ "$GRADE_NOW" == "healthy" ]]; then
pass "SQLite 持久化正常 (评级 HEALTHY 已存入数据库)"
else
fail "持久化可能有问题: grade=$GRADE_NOW"
fi
fi
# ─────────────────────────────────────────────────────────────
section "12. 无效公钥格式校验"
# ─────────────────────────────────────────────────────────────
RES=$(post /v1/bots/register "{
\"developer\":{\"name\":\"X\",\"email\":\"[email protected]\"},
\"bot\":{\"name\":\"BadKeyBot\",\"version\":\"1.0.0\",\"purpose\":\"testing invalid key format\",\"capabilities\":[\"read_articles\"],\"user_agent\":\"BadKeyBot/1.0\"},
\"public_key\":\"notakey\"
}")
ERR=$(echo "$RES" | python3 -c "import sys,json; print('error' in json.load(sys.stdin))" 2>/dev/null)
if [[ "$ERR" == "True" ]]; then
pass "无效公钥格式被拒绝"
else
fail "无效公钥未被拦截"
fi
# ─────────────────────────────────────────────────────────────
echo -e "\nBOLD══════════════════════════════════════RESET"
echo -e "BOLD 测试结果: GREENPASS 通过RESETBOLD / REDFAIL 失败RESET"
echo -e "BOLD══════════════════════════════════════RESET\n"
[[ "$FAIL" -eq 0 ]] && exit 0 || exit 1
小红书博主分析全流程 skill — 拆解账号定位、提炼爆款选题公式、输出结构化报告和商业版 Word。内置三型博主分类体系(荒诞美学型 / 共鸣命名型 / 现实策略型),基于多位真实博主的深度分析沉淀。支持单账号深度拆解、多账号对比、自定义学习报告,证据分级(公开主页→截图→第三方公开资料→推断),所有交付物可...
---
name: xhsfenxi
description: |
小红书博主分析全流程 skill — 拆解账号定位、提炼爆款选题公式、输出结构化报告和商业版 Word。内置三型博主分类体系(荒诞美学型 / 共鸣命名型 / 现实策略型),基于多位真实博主的深度分析沉淀。支持单账号深度拆解、多账号对比、自定义学习报告,证据分级(公开主页→截图→第三方公开资料→推断),所有交付物可一键转为 Markdown + 商业 Word。Use it when the user asks to analyze a Xiaohongshu blogger/account, extract viral topic formulas, compare creators, or produce business-grade deliverables.
keywords:
- xiaohongshu
- RED
- 小红书
- 小红书分析
- 博主分析
- 账号拆解
- 对标分析
- 爆款选题
- 爆款选题公式
- 内容策略
- 个人IP分析
- 创作者分析
- 三型博主
- 荒诞美学型
- 共鸣命名型
- 现实策略型
- 品牌符号
- 结构化总结报告
- Word报告
- 商业版报告
- 选题公式学习
- 博主拆解
- 小红书运营
- 内容账号分析
- 竞品分析
- 账号定位
- benchmark account
- topic formula
- creator research
- content audit
- account positioning
- competitor analysis
- archetype classification
- viral topic
- markdown report
- word report
- 小红书博主对比
- 爆款内容拆解
- 内容IP打法
metadata:
openclaw:
runtime:
node: ">=18"
---
# xhsfenxi — 小红书博主分析 Skill
> 公开页分析 · 三型博主分类 · 爆款选题公式提炼 · 多账号对比 · Markdown/商业Word交付
---
## When to Use
Use this skill when the user asks to:
- analyze a Xiaohongshu / RED creator account
- identify which archetype a creator belongs to (荒诞美学型 / 共鸣命名型 / 现实策略型)
- summarize an account's positioning, audience, style, or content pillars
- extract a **viral topic formula** or reusable content method
- compare two or more benchmark creators
- learn what can be copied vs what must stay differentiated
- turn the analysis into a **Markdown report**, **Word report**, or **business-style deliverable**
Typical trigger phrases:
- "分析这个小红书博主"
- "拆一下这个账号"
- "做爆款选题公式"
- "帮我做对标分析"
- "做成商业版 Word"
- "这个博主是哪一型?"
- "compare these two RED creators"
- "extract the topic formula from this account"
---
## Three-Archetype Classification System
Distilled from real analyses of multiple Xiaohongshu creators.
### Type A — 荒诞美学型 (Absurdist Aesthetics)
**Representative archetype:**
Core pattern: wraps serious or philosophical content in absurdist humor and high-quality visuals.
| Dimension | Characteristics |
|-----------|----------------|
| Content kernel | Absurdist filter on everyday life; philosophical undertone |
| What users get | Entertained + moved + aesthetically immersed |
| Title mechanism | Contrast (grand setting × mundane action) + unified brand symbol |
| Brand symbol | A single recurring tag, e.g. "(劲爆)" |
| Expression style | Humorous, high-production, literary naming |
| Commercial fit | High-end lifestyle, travel, photography, luxury brands |
| Replication difficulty | High — depends on accumulated visual aesthetic sensibility |
**Identifying markers:**
- All content tied together by a signature phrase or visual symbol
- Serious topics expressed lightly; absurd topics expressed earnestly
- Titles feel like literary naming, not just descriptions
---
### Type B — 共鸣命名型 (Resonance & Naming)
**Representative archetype:**
Core pattern: translates personal experience into universally relatable life-stage propositions, named in memorable ways.
| Dimension | Characteristics |
|-----------|----------------|
| Content kernel | Youth growth, worldview expression, life-stage naming |
| What users get | Understood + named + given a new perspective |
| Title mechanism | Proposition feeling + metaphor + judgment |
| Brand symbol | Signature conceptual phrases and naming patterns |
| Expression style | Warm, perceptive, aesthetically refined |
| Commercial fit | Growth/education/creative platforms, mid-range lifestyle |
| Replication difficulty | Medium — method learnable, requires genuine observation |
**Identifying markers:**
- Content "names" vague emotional states people couldn't articulate
- Private experience → universal proposition
- Titles read like thoughtful questions or redefinitions
---
### Type C — 现实策略型 (Reality & Strategy)
**Representative archetype:**
Core pattern: breaks down unspoken real-world rules and provides executable strategies for ordinary people to move upward.
| Dimension | Characteristics |
|-----------|----------------|
| Content kernel | Ordinary woman's upward mobility, reality rule-breaking |
| What users get | Validated + activated + empowered with actionable moves |
| Title mechanism | Conflict words + counter-intuitive framing + strong stance |
| Brand symbol | Self-labeling with perceived weakness (先夺走羞耻感) |
| Expression style | Sharp, realistic, strong-attitude conclusions |
| Commercial fit | Mass consumer, career, e-commerce, practical tools |
| Replication difficulty | Medium — framework learnable; don't copy the surface aggression |
**Identifying markers:**
- Titles feel slightly improper but undeniably accurate
- Each piece breaks an unspoken rule or collapses an information gap
- Users feel "刺但有用" — uncomfortable but useful
---
### Mixed Formula (Advanced)
The most powerful content often combines two archetypes:
> **Type B resonance/naming × Type C reality/strategy**
> = Content that both "understands you" and "tells you what to do next"
Use `scripts/archetype.js` to quickly identify which archetype(s) an account fits.
---
## Core Operating Rules
1. **Use public evidence first.** Prefer search pages, homepage/profile pages, and other publicly visible surfaces.
2. **Do not fake detail-page access.** If post detail pages are blocked by risk control, say so clearly.
3. **Grade evidence quality.** Treat sources in this order:
- Level A: visible Xiaohongshu public pages / user screenshots
- Level B: other public sources (podcasts, interviews, encyclopedias, analytics sites)
- Level C: synthesis / inference based on repeated visible patterns
4. **Do not present public estimates as backend truth.** Third-party follower or pricing data are supporting clues, not audited facts.
5. **Ask for 3–10 representative post links or screenshots** when the user wants deep post-level analysis.
6. **Deliver something useful even when blocked.** If detail pages fail, still produce a homepage-level strategic report.
7. **Always identify the archetype.** Every analysis should name which of the three types (or hybrid) the account belongs to — this frames the entire report.
---
## Default Workflow
### 1) Clarify the task shape
Lock the requested output mode before heavy research:
- Structured account report
- Viral topic formula
- Archetype identification
- Two-or-more-account comparison
- "How to learn from this creator without copying them"
- Business-style Word deliverable
Use `scripts/intake.js` for a fast intake template.
### 2) Gather source inputs
Collect whatever the user already has:
- account name
- Xiaohongshu homepage/search URL
- screenshots
- external links (podcasts, news, interviews)
- target output path
- whether they need Word / clickable TOC / business styling
### 3) Research with the source ladder
Read `references/workflow.md` for the full ladder.
Preferred tool pattern:
- `web_search` for public discovery
- `browser` or `browser-use` for homepage/search-page inspection
- `web_fetch` for readable public pages
- `image` for screenshot analysis
- `read` for any local Markdown reports already created
### 4) Classify the archetype
Before deep analysis, make a preliminary archetype call:
- Run `node scripts/archetype.js <creator-name>` to print the classification prompt
- Answer: Type A (荒诞美学) / Type B (共鸣命名) / Type C (现实策略) / Mixed
- The archetype frames the entire lens of the analysis
### 5) Build the account model
Extract and write down the five layers:
1. **Identity** — who the creator is framed as
2. **Audience contract** — why people follow them
3. **Topic system** — what recurring problems/desires they cover
4. **Expression system** — how titles and openings work, including any brand symbol
5. **Transferability** — what another account can actually learn
### 6) Produce the deliverables
Always output the three standard DOCX files directly — no intermediate Markdown step needed:
| Output file | When to produce |
|-------------|----------------|
| `账号名-结构化总结报告.docx` | Single creator analysis |
| `账号名-爆款选题公式.docx` | Single creator topic formula |
| `选题公式学习-综合版.docx` | Multi-creator comparison |
Use `scripts/docx-plan.js <creator-name>` to get the exact build command.
See `references/workflow.md` for the Word TOC fix checklist if needed.
---
## Deliverables
Three standard output files. Always produce as `.docx` directly.
### 账号名-结构化总结报告.docx
Single creator full breakdown. Sections:
1. Account snapshot
2. Archetype classification + rationale
3. Strategic positioning
4. Audience and emotional contract
5. Content pillars
6. Title / hook system + brand symbol analysis
7. Narrative structure
8. Commercialization clues
9. What to learn
10. What not to blindly copy
11. Conclusion
### 账号名-爆款选题公式.docx
Single creator topic formula. Sections:
1. Why this creator produces strong topics (archetype-based)
2. Total formula
3. 3–6 recurring topic models
4. Title formulas
5. Body structure formulas
6. Distribution / comment triggers
7. How to migrate to another account
8. 10–30 ready-to-use topic directions
### 选题公式学习-综合版.docx
Multi-creator comparison. Sections:
1. Why compare these creators together
2. Archetype map
3. Shared foundations + key differences
4. Hybrid formula
5. Which path fits which use case
6. 30 combined topic directions
7. Final recommendation
---
## Word Output
Run `node scripts/docx-plan.js <creator-name>` to get the exact build command.
Standard build: `python3 build_docx6.py <creator-name>`
If TOC / bookmark issues appear: run `inspect_docx.py` then `fix_final2.py`.
See `references/workflow.md` for the full fix checklist.
---
## Output Files
Three files, always `.docx`:
- `账号名-结构化总结报告.docx`
- `账号名-爆款选题公式.docx`
- `选题公式学习-综合版.docx` (comparison / multi-creator)
Use `scripts/report-plan.js <creator-name>` to confirm filenames.
---
## Scripts
| Command | Purpose |
|--------|---------|
| `node scripts/intake.js` | Print a structured intake template for a new analysis task |
| `node scripts/intake.js --json` | Same as above, JSON format |
| `node scripts/archetype.js <creator-name>` | Print the archetype classification prompt |
| `node scripts/archetype.js <creator-name> --quick` | Print a quick-read archetype cheatsheet |
| `node scripts/report-plan.js <creator-name>` | Print recommended deliverables and filenames |
| `node scripts/report-plan.js <A> <B> --mode compare` | Print a comparison deliverable plan |
| `node scripts/docx-plan.js <creator-name>` | Print Word generation plan using build_docx*.py |
---
## Limitation Policy
Always state which situation applies:
- **Homepage-level only**: enough for strategic analysis, not enough for exact post-body claims
- **Mixed-source analysis**: Xiaohongshu public pages + third-party public clues
- **Deep-dive mode**: based on user-provided screenshots / links / transcripts
If blocked by risk control, say so and continue with the strongest available public evidence.
---
## What Good Output Looks Like
A strong output is:
- structured
- archetype-aware (names and reasons the type)
- evidence-aware (grades sources)
- strategically useful
- honest about access limits
- directly reusable in future reports
Avoid vague praise, empty creator admiration, or unsupported claims about hidden metrics.
---
## References
Read only when needed:
- `references/workflow.md` — detailed evidence ladder, execution runbook, Word TOC fix guide
- `references/templates.md` — report templates, archetype-specific section structures, naming patterns
---
## Proven Analyses Archive
Three full analyses have been completed and archived in:
```
openclaw_cosmo/afa/小红书分析与工作流归档/01-分析报告与选题公式/
```
| Archetype | Type | Key Insight |
|-----------|------|-------------|
| 荒诞美学型 | Type A | 品牌符号 + 反差标题 + 哲思轻量化输出 |
| 共鸣命名型 | Type B | 私人经历→公共命题,给模糊状态命名 |
| 现实策略型 | Type C | 困境→说破→规则→策略→爽感 |
These archetypes are the ground truth for the classification system.
---
*Version: 2.1.0 · Created: 2026-04-08 · Updated: 2026-04-09*
FILE:_meta.json
{
"ownerId": "kn79bebfnwg15sb0g7cj5z5nyd83gxh0",
"slug": "xhsfenxi",
"version": "2.1.0",
"publishedAt": null
}
FILE:package.json
{
"name": "xhsfenxi",
"version": "2.1.0",
"description": "xhsfenxi — Xiaohongshu creator-account analysis workflow with three-archetype classification (荒诞美学型/共鸣命名型/现实策略型), viral topic formula extraction, comparison studies, and Markdown/Word deliverables.",
"keywords": [
"xiaohongshu",
"RED",
"小红书",
"博主分析",
"账号拆解",
"爆款选题",
"三型博主",
"荒诞美学",
"共鸣命名",
"现实策略",
"topic-formula",
"creator-research",
"content-strategy",
"benchmark-analysis",
"archetype-classification",
"word-report",
"business-report"
],
"author": "cosmofang",
"license": "MIT",
"scripts": {
"intake": "node scripts/intake.js",
"intake:json": "node scripts/intake.js --json",
"plan": "node scripts/report-plan.js",
"archetype": "node scripts/archetype.js",
"archetype:quick": "node scripts/archetype.js --quick",
"docx-plan": "node scripts/docx-plan.js",
"toc-fix": "node scripts/docx-plan.js --toc-fix"
}
}
FILE:references/templates.md
# Templates Reference — xhsfenxi v2.0
---
## Output Files
Always produce as `.docx` directly. Three standard files:
| File | When |
|------|------|
| `账号名-结构化总结报告.docx` | Single creator analysis |
| `账号名-爆款选题公式.docx` | Single creator topic formula |
| `选题公式学习-综合版.docx` | Multi-creator comparison |
---
## Structured Report Skeleton (v2 — Archetype-Aware)
```markdown
# 账号名 结构化总结报告
> 说明:本报告基于[数据来源说明]。适合用于[账号定位/内容策略/竞品研究/爆款机制提炼]。
## 一、执行摘要
[2–4段: 博主是谁,核心特征,最值得记住的一句话]
## 二、账号基础信息
[表格: 昵称/小红书号/粉丝/获赞收藏/笔记数/内容类型/账号阶段]
[补充: 全平台历史数据(如有)]
## 三、博主背景(公开资料)
[人物背景、职业起点、创作理念(来源于公开访谈)]
## 四、账号类型判断
**类型:** Type A/B/C 或 混合型
**判断依据:** [2–3条可见信号]
**核心内核:** [一句话总结]
## 五、博主核心内核拆解
[外壳是什么 / 真正的内核是什么 / 人设三层结构 / 最强能力]
## 六、内容结构拆解
[内容支柱分布(表格)/ 内容类型比例 / 统一主题]
## 七、标题机制与爆点分析
[最重要特征 / 品牌符号分析 / 高热内容分析(表格)/ 标题公式归纳]
## 八、与其他博主的横向对比(可选)
[对比表格: 多维度 × 多博主]
## 九、商业化判断
[当前阶段 / 为什么品牌会找他 / 适合的合作方向 / 不适合的方向]
## 十、最值得学习的核心能力
[3–5个能力,每个含: 说明 + 可学习点]
## 十一、后续建议
[路径A: 深入单篇分析 / 路径B: 制作爆款选题公式 / 路径C: 跨博主综合版]
## 十二、最终总结
[1–2段核心结论]
## 附录:笔记完整列表(可选)
[表格: 排名 / 类型 / 标题 / 点赞]
```
---
## Viral Topic Formula Skeleton (v2)
```markdown
# 账号名 爆款选题公式
> 说明:本文件不是复刻[账号名]的表面语气,而是提炼真正有效的选题逻辑、标题机制、传播结构、人设打法与可迁移方法。
## 一、先说结论:为什么能做出爆款?
[账号类型 + 5个叠在一起的核心能力 + 总公式一句话]
## 二、总爆款公式
[5步骤公式 + 压缩版关键词]
## 三、最常见的 3–6 类爆款选题模型
[每个模型: 公式 / 典型表达方向 / 为什么有效 / 可套用句式 / 适合主题]
## 四、标题公式:可以直接拆用
[5–6个标题公式,每个含: 结构说明 + 套用模板]
## 五、内容结构公式
[2–3个结构公式,每个含: 步骤 / 适合做的主题]
## 六、隐形公式 / 最值钱的底层打法
[3–4个]
## 七、如何迁移到自己的内容里
[4个步骤: 找现实母题 / 改写生活故事 / 用冲突词 / 4问检验]
## 八、可直接复用的 20 个选题方向
[编号列表]
## 九、哪些能学,哪些不能硬抄
[可以学的 / 不能硬抄的]
## 十、最终总结
[一句话核心结论]
```
---
## Comparison Skeleton (v2 — Archetype Map)
```markdown
# 选题公式学习(综合版)
## ——结合[博主A]与[博主B](以及[博主C])的内容方法论
> 说明:这份文档不是简单拼接报告,而是把各博主最有效的内容能力抽取出来,重新整理成可学习、可迁移、可直接应用的选题方法。
## 一、为什么把这几位博主放在一起学?
[各博主关键词 / 各博主强项一句话 / 为什么组合更完整]
## 二、账号类型对照表
[表格: 博主 / 类型 / 核心方向 / 用户获得感 / 内容气质 / 标题风格 / 爆点来源]
## 三、共同底层:真正的选题不是题材,而是命题
[5个共同点 / 核心结论]
## 四、双系统(或多系统)选题公式
[系统A(共鸣命名型): 适用场景 / 核心公式 / 压缩版 / 标题类型]
[系统B(现实策略型): 同上]
[(如有)系统C(荒诞美学型): 同上]
## 五、混合公式
[混合公式定义 + 例子 + 为什么更强]
## 六、标题公式综合版
[6–8个跨类型标题公式]
## 七、内容结构综合版
[结构A / B / C,各含步骤和用户获得感]
## 八、可直接复用的 30 个综合选题方向
[A组(偏共鸣命名)/ B组(偏现实策略)/ C组(偏荒诞美学)/ D组(混合)]
## 九、结论与行动建议
[最重要的学习提醒 + 最终公式]
```
---
## Archetype Comparison Table Template
Use this table when comparing 2–3+ accounts:
```markdown
| 维度 | [博主A] | [博主B] | [博主C] |
|---|---|---|---|
| 账号类型 | Type A 荒诞美学 | Type B 共鸣命名 | Type C 现实策略 |
| 核心方向 | | | |
| 用户获得感 | | | |
| 内容气质 | | | |
| 标题风格 | | | |
| 品牌符号 | | | |
| 爆点来源 | | | |
| 商业化方向 | | | |
| 可复制难度 | | | |
```
---
## Evidence Label Reference
Use these labels in your internal working notes (do not need to show all in final report):
| Label | Meaning |
|-------|---------|
| `A1` | Xiaohongshu homepage visible fact |
| `A2` | Xiaohongshu screenshot fact |
| `B1` | Third-party public clue (interview, news, etc.) |
| `C1` | Synthesis / interpretation |
---
## Business Word Checklist
When building a business-style Word file:
- [ ] Cover page
- [ ] TOC with `_TocH1_XXX` bookmarks (internal jump, not external link)
- [ ] Page numbers
- [ ] Conclusion page
- [ ] Restrained typography (no loud decorative color blocks unless requested)
- [ ] Tested in Word: TOC entries jump correctly
- [ ] No duplicate headings accidentally inserted in body
---
## Brand Symbol Analysis Template
For Type A accounts or any account with a unified recurring symbol:
```markdown
### 品牌符号分析
**符号:** [(劲爆)/ 其他]
**出现频率:** [几乎所有 / 大部分 / 部分]
**品牌效果:**
- 识别度: [描述]
- 反差机制: [描述]
- 先发制人效果: [描述]
**可学习点:** [其他账号如何建立自己的品牌符号]
```
FILE:references/workflow.md
# Workflow Reference — xhsfenxi v2.0
---
## Source Ladder
Use sources in this order whenever possible:
### Level A — Primary public evidence
Highest trust for visible claims:
- Xiaohongshu search result pages
- creator homepage/profile pages
- user-provided screenshots from Xiaohongshu
- user-provided post links that are actually accessible
What you can safely claim from Level A:
- visible follower / note counts shown on page
- visible account ID / self-introduction
- visible recent titles / visible public engagement numbers
- posting cadence clues visible on page
- obvious content categories
- brand symbols and signature phrases (like "(劲爆)")
### Level B — Secondary public clues
Use as support, never as backend truth:
- podcast interviews
- encyclopedia entries
- public creator bios elsewhere
- analytics websites / public creator databases
- public brand collaboration listings
What Level B is good for:
- filling identity / background context
- confirming repeated themes
- spotting public commercialization clues
- triangulating a bigger narrative
### Level C — Synthesis / inference
Allowed only when clearly framed as interpretation.
Examples:
- "This account behaves more like a Type A 荒诞美学 creator than a Type B 共鸣命名 creator."
- "The recurring topic logic appears to be: contrast × literary naming × unified symbol."
- "The transferable lesson is not the tone, but the framing system."
Never present Level C synthesis as raw observed fact.
---
## Standard Execution Runbook
### Step 1 — Confirm the deliverable
Choose one or more:
- structured report
- viral topic formula
- archetype classification
- comparison report
- customized learning report
- Word / business version
### Step 2 — Capture the minimum task brief
Minimum useful brief:
- creator name
- available links or screenshots
- target output format
- whether a business Word version is required
### Step 3 — Archetype pre-classification
Before deep analysis, make a preliminary archetype call using visible signals:
| Signal | Likely archetype |
|--------|-----------------|
| Unified brand symbol/tag on every post | Type A 荒诞美学型 |
| High-production video, absurdist or philosophical content | Type A 荒诞美学型 |
| Posts name vague emotions or life stages | Type B 共鸣命名型 |
| Titles feel like thoughtful questions or redefinitions | Type B 共鸣命名型 |
| Titles contain conflict words ("骗子", "不要脸", "装") | Type C 现实策略型 |
| Content breaks unspoken workplace/relationship/money rules | Type C 现实策略型 |
| Mix of resonance + strategy | Mixed B+C |
### Step 4 — Research in layers
1. Find the account and public homepage/search data
2. Save visible account facts (follower count, note count, visible titles, engagement)
3. Pull representative visible titles — note brand symbols, title patterns
4. Add external public clues only where helpful
5. Record limitations before interpretation
### Step 5 — Extract the account system (Five Layers)
Always map these five layers:
1. **Identity** — who the creator is framed as; any self-labeling strategy
2. **Audience contract** — why people follow them; what emotional/strategic need is met
3. **Topic system** — what recurring problems/desires they cover; 3–6 topic models
4. **Expression system** — title formulas, brand symbols, opening patterns
5. **Transferability** — what another account can actually learn; what must not be copied
### Step 6 — Produce the right file
#### Structured report
Recommended top-level sections:
1. Account snapshot
2. Archetype classification + rationale
3. Positioning judgment
4. Audience and demand
5. Content pillars
6. Title / hook system (including brand symbol analysis)
7. Narrative structure
8. Commercialization clues
9. What to learn
10. What not to copy
11. Conclusion
12. (Optional) Full note list sorted by engagement
#### Viral topic formula
Recommended top-level sections:
1. Why this creator produces strong topics (archetype-based)
2. Total formula
3. 3–6 recurring topic models
4. Title formulas
5. Body structure formulas
6. Distribution / comment trigger clues
7. How to migrate it to another account
8. 10–30 topic directions
#### Comparison report (2–3+ accounts)
Recommended top-level sections:
1. Why compare these creators together
2. Archetype map (each creator's type)
3. Shared foundations
4. Key differences
5. Hybrid formula
6. Which path fits which use case
7. Suggested topic directions
8. Final recommendation
---
## Archetype-Specific Analysis Lenses
### Type A — 荒诞美学型
Focus questions:
- What is the brand symbol? (unified tag, phrase, or visual marker)
- How does the title create contrast? (grand × mundane, serious × absurd)
- Where does the humor come from? (earnest treatment of absurd things)
- What is the underlying philosophical/emotional core?
- Why is replication hard? (visual sensibility, accumulated aesthetic)
Key learning for other accounts:
- Find your own "brand symbol" — a unifying tag that creates recognition
- Train "contrast naming" — don't describe the place, name the feeling
- Practice "absurdist framing" — pick topics that are ridiculous when taken seriously
---
### Type B — 共鸣命名型
Focus questions:
- What life stages or emotional states does the account name?
- How does private experience become universal proposition?
- What are the signature conceptual phrases?
- What is the "new way to understand yourself" users get?
Key learning for other accounts:
- Practice "命题化" — reframe personal experience as a universal human situation
- Build a personal vocabulary of named states ("Odyssey时期", etc.)
- The formula: experience → proposition → naming → judgment → resonance
---
### Type C — 现实策略型
Focus questions:
- What real-world rules does the account break open?
- How does the self-labeling strategy work? (antifragile identity)
- What is the conflict word in each title?
- What is the "can-do action" users walk away with?
Key learning for other accounts:
- Find your "reality母题" — the real困境 you speak to
- Practice rewriting weak framings into strong rule-breaking framings
- The formula: 困境 → 说破 → 规则 → 策略 → 爽感
---
## Limitation Language
Use direct wording like:
- "This round is based primarily on public homepage/search-page evidence."
- "Single-post detail pages were not stably accessible due to platform risk control."
- "Third-party figures are used as directional clues, not audited backend data."
- "Archetype classification is based on visible patterns; the creator may operate differently at the full content level."
---
## Deep-Dive Upgrade Path
If the user wants finer analysis, ask for 3–10 representative posts, ideally with:
- screenshots of cover + title
- screenshots of body pages / subtitles
- comments screenshots
- transcript or summary if video-based
Then upgrade from account-level to post-level analysis.
---
## Word Output Workflow
### Overview
Always generate Markdown first, then convert to Word. Iterating Markdown is cheaper than re-generating DOCX.
### Script Selection Guide
Use the workflow archive scripts at:
```
openclaw_cosmo/afa/小红书分析与工作流归档/02-Word生成与目录修复脚本/
```
| Script | Use when |
|--------|----------|
| `build_docx6.py` | Default — most stable, use this first |
| `build_docx.py` → `build_docx5.py` | Earlier iterations, kept for reference/debugging |
| `inspect_docx.py` | Inspect internal XML, bookmarks, link structure |
| `fix_final2.py` | Final TOC / attribute fix after build |
| `fix_attrs.py` | Fix XML namespace attribute writing errors |
| `fix_wrong.py` | Targeted corrections for known wrong output |
| `check_it.py` | Verify output correctness |
### Word TOC Fix Checklist
When a generated .docx has broken TOC links:
1. Run `inspect_docx.py` — check `word/document.xml` for bookmark names and link types
2. Verify heading bookmarks use format `_TocH1_XXX` (not `#_TocH1_XXX`)
3. Verify internal links use Word-native anchor style, not external URL rels
4. If `elem.set()` errors appear, use full namespace: `elem.set('{' + W_NS + '}id', value)`
5. Run `fix_final2.py` after any XML edits
6. Test by opening in Word — click TOC entries to verify jump
### Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| Bookmark name wrong format | Missing `_TocH1_` prefix | Rename via `fix_attrs.py` |
| `anchor` contains `#` | Wrong anchor format | Strip `#` prefix |
| TOC uses external link rels | Used `http://` link type for internal jump | Switch to `w:instr` field or anchor |
| Duplicate headings in body | TOC headings accidentally inserted into body | Use `fix_wrong.py` to clean |
| Namespace attribute error | Missing full namespace URI in `elem.set()` | Use `'{' + W_NS + '}id'` form |
### Best Practice
- Keep only `build_docx6.py` as the production script
- Keep all earlier versions as debugging reference, not for production
- One Markdown → one `build_docx6.py` call → inspect → fix if needed
FILE:scripts/archetype.js
#!/usr/bin/env node
/**
* xhsfenxi — archetype.js
* Purpose: Print the archetype classification prompt for a Xiaohongshu creator.
* Helps identify whether an account is Type A (荒诞美学), Type B (共鸣命名),
* Type C (现实策略), or a hybrid — based on the three proven archetypes
* distilled from real analyses of multiple Xiaohongshu creators.
* Usage:
* node scripts/archetype.js <creator-name>
* node scripts/archetype.js <creator-name> --quick
*/
const args = process.argv.slice(2);
const quick = args.includes('--quick');
const name = args.filter(a => !a.startsWith('--'))[0];
if (!name) {
console.error('Usage: node scripts/archetype.js <creator-name> [--quick]');
process.exit(1);
}
if (quick) {
console.log(`
=== xhsfenxi archetype cheatsheet ===
TYPE A — 荒诞美学型 (Absurdist Aesthetics)
Example: 荒诞美学博主(vlogger型)
Signals:
- Unified brand symbol/tag on every post (e.g. "(劲爆)")
- Absurdist or philosophical topics treated earnestly
- High-production video; literary title naming
- Serious × absurd contrast in every piece
Formula: 荒诞滤镜下的生活哲学
TYPE B — 共鸣命名型 (Resonance & Naming)
Example: 成长世界观表达博主
Signals:
- Posts name vague emotional states or life stages
- Private experience → universal proposition
- Titles feel like thoughtful questions or redefinitions
Formula: 经历 → 命题 → 命名 → 判断 → 共鸣
TYPE C — 现实策略型 (Reality & Strategy)
Example: 普通女孩上行策略博主
Signals:
- Titles contain conflict words ("骗子", "不要脸", "装")
- Content breaks unspoken workplace/relationship/money rules
- Self-labels with perceived weakness to pre-empt criticism
Formula: 困境 → 说破 → 规则 → 策略 → 爽感
MIXED B+C — Most powerful hybrid
Combines: resonance/naming × reality/strategy
= "You understand me" + "Now I know what to do"
`);
process.exit(0);
}
console.log(`
=== xhsfenxi archetype classification: name ===
TASK
Classify the Xiaohongshu account "name" into one of these three archetypes
(or a hybrid), based on visible public evidence.
─────────────────────────────────────────────
THREE ARCHETYPES
─────────────────────────────────────────────
TYPE A — 荒诞美学型 (Absurdist Aesthetics)
Core: Wraps philosophical/serious content in absurdist humor + high visual quality
Proven example: 荒诞美学vlogger型 (unified brand symbol; grand × mundane contrast; literary naming)
Key signals to look for:
- Does every post share a unified recurring symbol or phrase?
- Do titles contrast a grand/serious setting with a mundane/absurd action?
- Is the visual/production quality noticeably higher than peers?
- Are serious topics treated with humor and humor treated earnestly?
TYPE B — 共鸣命名型 (Resonance & Naming)
Core: Names vague emotions and life stages so users feel "finally, someone said it"
Proven example: 成长世界观表达型 (youth growth, worldview expression, concept naming)
Key signals to look for:
- Do posts give names or definitions to emotional states most people can't articulate?
- Does the account turn personal stories into universal life propositions?
- Do titles feel like thoughtful philosophical questions or redefinitions?
- Is the tone warm, perceptive, aesthetically refined?
TYPE C — 现实策略型 (Reality & Strategy)
Core: Breaks unspoken real-world rules; provides executable strategies for ordinary people
Proven example: 普通女孩上行策略型 (antifragile identity; rule-breaking; 普通人上行)
Key signals to look for:
- Do titles contain conflict words or counter-intuitive framing?
- Does the account self-label with a perceived weakness to pre-empt criticism?
- Does content expose hidden rules in workplace/relationships/money/consumption?
- Does every piece end with a clear, actionable conclusion or stance?
MIXED HYBRID
Some accounts combine two archetypes. Most powerful is B+C:
"I understand your situation (Type B) AND here's what you can actually do (Type C)"
─────────────────────────────────────────────
CLASSIFICATION PROMPT
─────────────────────────────────────────────
Based on publicly visible data for "name", answer:
1. Which archetype does this account most closely match? (A / B / C / Mixed)
2. What are 2–3 concrete visible signals that support this classification?
3. If mixed: what percentage is each type, and does one dominate?
4. What is the single sentence that describes this account's core "filter" on the world?
5. Is there a brand symbol or signature phrase? If yes, what is it and how does it function?
Then proceed with the full analysis using this archetype as the lens.
`);
FILE:scripts/docx-plan.js
#!/usr/bin/env node
/**
* xhsfenxi — docx-plan.js
* Purpose: Print the Word generation plan for a completed Xiaohongshu analysis,
* including which build_docx*.py script to use, the full workflow steps,
* and TOC fix guidance from the workflow archive.
* Usage:
* node scripts/docx-plan.js <creator-name>
* node scripts/docx-plan.js <creator-name> --mode comparison
* node scripts/docx-plan.js --toc-fix
*/
const ARCHIVE_BASE = '~/Desktop/cosmocloud/Deeplumen/cosmowork/openclaw_cosmo/afa/小红书分析与工作流归档/02-Word生成与目录修复脚本';
const args = process.argv.slice(2);
const tocFix = args.includes('--toc-fix');
const modeIdx = args.indexOf('--mode');
const mode = modeIdx !== -1 ? args[modeIdx + 1] : 'single';
const name = args.filter((a, i) => {
if (a.startsWith('--')) return false;
if (modeIdx !== -1 && i === modeIdx + 1) return false;
return true;
})[0];
if (tocFix) {
console.log(`
=== xhsfenxi Word TOC Fix Guide ===
Archive scripts location:
ARCHIVE_BASE/
STEP-BY-STEP FIX PROCESS
1. Run inspect_docx.py to diagnose the issue:
python3 ARCHIVE_BASE/inspect_docx.py <your-file.docx>
→ Check: bookmark names, link types, heading structure
2. Verify the following are correct:
- Bookmark names follow format: _TocH1_XXX (NOT #_TocH1_XXX)
- Internal TOC links use Word-native anchor, NOT external http:// rels
- No duplicate headings accidentally inserted in document body
- Namespace attributes written as: elem.set('{' + W_NS + '}id', value)
3. Run the appropriate fix script:
- For attribute/namespace errors: python3 fix_attrs.py <file.docx>
- For final TOC + heading cleanup: python3 fix_final2.py <file.docx>
- For known wrong output patterns: python3 fix_wrong.py <file.docx>
4. Verify with check_it.py:
python3 ARCHIVE_BASE/check_it.py <your-fixed-file.docx>
5. Open in Word and test: click each TOC entry to confirm internal jump works.
COMMON ERROR TABLE
Bookmark format wrong → rename using fix_attrs.py
anchor contains # → strip # prefix in fix_final2.py
TOC uses external rels → switch to w:instr field or Word anchor
Duplicate headings → fix_wrong.py
XML namespace error → use full namespace URI in elem.set()
`);
process.exit(0);
}
if (!name && !tocFix) {
console.error('Usage: node scripts/docx-plan.js <creator-name> [--mode comparison]');
console.error(' node scripts/docx-plan.js --toc-fix');
process.exit(1);
}
const isComparison = mode === 'comparison';
const mdFile = isComparison
? `选题公式学习-综合版.md`
: `name-结构化总结报告.md`;
const docxFile = isComparison
? `选题公式学习-综合版.docx`
: `name-结构化总结报告-商业版.docx`;
console.log(`
=== xhsfenxi Word generation plan: name || 'comparison' ===
Mode: 'single creator'
─────────────────────────────────────────────
STEP 1 — Confirm Markdown is complete
─────────────────────────────────────────────
Make sure this file is finalized:
mdFile
─────────────────────────────────────────────
STEP 2 — Run build_docx6.py (recommended)
─────────────────────────────────────────────
Primary script (most stable):
python3 ARCHIVE_BASE/build_docx6.py mdFile docxFile
If build_docx6.py fails, fall back to earlier versions:
build_docx5.py → build_docx4.py → build_docx3.py
─────────────────────────────────────────────
STEP 3 — Inspect the output
─────────────────────────────────────────────
python3 ARCHIVE_BASE/inspect_docx.py docxFile
Check for:
✓ Headings have _TocH1_XXX bookmarks
✓ TOC links are internal (not external URL rels)
✓ No duplicate heading text in body
✓ Page count and section structure look correct
─────────────────────────────────────────────
STEP 4 — Fix if needed
─────────────────────────────────────────────
Run: node scripts/docx-plan.js --toc-fix
for the full TOC fix checklist.
Most common fix:
python3 ARCHIVE_BASE/fix_final2.py docxFile
─────────────────────────────────────────────
STEP 5 — Final check
─────────────────────────────────────────────
python3 ARCHIVE_BASE/check_it.py docxFile
→ Open in Word, click TOC entries, verify internal jumps work
─────────────────────────────────────────────
EXPECTED OUTPUT FILES
─────────────────────────────────────────────
docxFile
` ${name-结构化总结报告.md (keep as source of truth)\n name-爆款选题公式.md (if also produced)`}
─────────────────────────────────────────────
ARCHIVE LOCATION
─────────────────────────────────────────────
Scripts: ARCHIVE_BASE/
Stable production script: build_docx6.py
Inspection tool: inspect_docx.py
`);
FILE:scripts/intake.js
#!/usr/bin/env node
/**
* xhsfenxi — intake.js
* Purpose: Print a structured intake template for a Xiaohongshu creator-analysis task.
* Usage:
* node scripts/intake.js
* node scripts/intake.js --json
*/
const args = process.argv.slice(2);
const asJson = args.includes('--json');
const template = {
task: 'xiaohongshu-creator-analysis',
creatorNames: ['<creator-name>'],
links: ['<homepage-or-search-url>'],
screenshots: ['<optional-local-path-or-url>'],
deliverables: ['structured-report'],
outputDir: '<optional-output-dir>',
needWord: false,
needBusinessVersion: false,
needComparison: false,
notes: 'What exactly should be learned from this creator?'
};
if (asJson) {
console.log(JSON.stringify(template, null, 2));
process.exit(0);
}
console.log(`
=== xhsfenxi intake template ===
Provide or confirm the following before deep research:
- creator name(s)
- homepage/search URL(s)
- screenshots or links for representative posts (optional but helpful)
- requested deliverable(s): structured-report / topic-formula / comparison / business-word
- output directory
- any specific question to answer
JSON template:
JSON.stringify(template, null, 2)
`);
FILE:scripts/report-plan.js
#!/usr/bin/env node
/**
* xhsfenxi — report-plan.js
* Purpose: Print recommended deliverables and filenames for a Xiaohongshu analysis request.
* Usage:
* node scripts/report-plan.js <creator-name>
* node scripts/report-plan.js <creator-a> <creator-b> --mode compare
*/
const args = process.argv.slice(2);
const modeIdx = args.indexOf('--mode');
const mode = modeIdx !== -1 ? args[modeIdx + 1] : 'single';
const names = args.filter((a, i) => {
if (a === '--mode') return false;
if (modeIdx !== -1 && i === modeIdx + 1) return false;
return true;
});
if (!names.length) {
console.error('Usage: node scripts/report-plan.js <creator-name>');
console.error(' node scripts/report-plan.js <creator-a> <creator-b> --mode compare');
process.exit(1);
}
if (mode === 'compare') {
if (names.length < 2) {
console.error('Compare mode requires two creator names.');
process.exit(1);
}
const [a, b] = names;
const files = [
`选题公式学习-综合版.docx`
];
console.log(`
=== xhsfenxi report plan ===
Mode: comparison
Creators: a / b
Recommended deliverables:
- comparison report
- hybrid topic-formula study
- optional business Word version
Recommended filenames:
files.map(f => `- ${f`).join('\n')}
`);
process.exit(0);
}
const name = names[0];
const files = [
`name-结构化总结报告.docx`,
`name-爆款选题公式.docx`
];
console.log(`
=== xhsfenxi report plan ===
Mode: single creator
Creator: name
Recommended deliverables:
- structured report
- viral topic formula
- optional business Word version
Recommended filenames:
files.map(f => `- ${f`).join('\n')}
`);
openclaw-healthcheck is a self-diagnostic skill that checks and repairs openclaw's communication channels and scheduled tasks. It diagnoses Feishu/lark-cli a...
---
name: openclaw-push-doctor
description: |
openclaw-healthcheck is a self-diagnostic skill that checks and repairs openclaw's communication channels and scheduled tasks. It diagnoses Feishu/lark-cli auth expiry, Telegram bot silence, WeChat bridge disconnects, and cron job failures — then guides the agent through targeted repairs without rebuilding everything from scratch.
Trigger words: 飞书断了, 飞书没反应, telegram没反应, 微信断了, 定时任务没推送, cron没跑, 推送失败, 自检一下, 通讯检查, healthcheck, 检查通讯, 定时任务检查, 推送自检, 重连飞书, 重连telegram, 验证码失效, 配对码过期
keywords: openclaw healthcheck, 飞书自检, telegram修复, 定时任务检查, cron检查, 通讯自检, 推送失败修复, lark-cli auth, bot status, 连接诊断, 断联修复, 自定时任务, 推送验证, 配对码
requirements:
node: ">=18"
binaries:
- name: curl
required: true
description: "Used for Telegram bot API health checks (getMe, getUpdates, sendMessage)."
- name: python3
required: true
description: "Used to parse JSON responses from Telegram API and update config files."
- name: pgrep
required: true
description: "Used to check if WeChat bridge process and openclaw daemon are running."
- name: lark-cli
required: false
description: "Feishu/Lark CLI — required if using Feishu channel."
- name: crontab
required: false
description: "Used to read, deduplicate, and repair system cron entries."
env:
- name: TELEGRAM_BOT_TOKEN
required: false
description: "Telegram bot token — required for Telegram channel checks and test sends."
- name: TELEGRAM_CHAT_ID
required: false
description: "Telegram chat/group ID — used to send test push messages to verify delivery."
- name: OPENCLAW_CONFIG_DIR
required: false
description: "Override path to openclaw config directory. Defaults to ~/.openclaw."
- name: FEISHU_APP_ID
required: false
description: "Feishu App ID — used to verify config matches auth state."
metadata:
openclaw:
always: false
disable-model-invocation: true
---
# openclaw-healthcheck — 通讯自检与修复工具
> 一键诊断飞书断联、Telegram 无响应、定时任务失效 — 精准修复,不用从头重配
---
## 何时使用
- 飞书消息推不进来,或 lark-cli 命令报 401/token expired
- Telegram bot 长时间无响应,或需要重新验证配对码
- 微信 bridge 断线
- 定时任务(cron/push)静默失败,或同一任务重复触发
- 例行自检 — 每天/每周确认所有通道都活着
---
## Scripts
| Script | 用途 |
|--------|------|
| `check-all.js` | 全量诊断:所有通道 + 所有 cron 任务,输出健康报告 |
| `check-feishu.js` | 飞书 lark-cli auth 状态、token 有效期、测试推送 |
| `check-telegram.js` | Telegram bot API 连通性、webhook 状态、测试推送 |
| `check-wechat.js` | 微信 bridge 进程状态、连接测试 |
| `check-crons.js` | 列出所有定时任务、检测失败/重复/超时未跑 |
| `fix-feishu.js` | 引导飞书 token 刷新或重新 OAuth 登录 |
| `fix-crons.js` | 去重、修复、重启失效的定时任务 |
---
## 快速用法
```bash
# 全量自检(推荐先跑这个)
node scripts/check-all.js
# 只查飞书
node scripts/check-feishu.js
# 只查 Telegram
node scripts/check-telegram.js
# 只查定时任务
node scripts/check-crons.js
# 修复飞书 auth
node scripts/fix-feishu.js
# 修复 cron 重复任务
node scripts/fix-crons.js --dedup
```
---
## 输出格式
每个 check 脚本输出一份健康报告写入 `data/health-report.json`:
```json
{
"checkedAt": "<ISO 时间戳>",
"channels": {
"feishu": { "status": "OK | EXPIRED | DISCONNECTED | NOT_CONFIGURED", "detail": "..." },
"telegram": { "status": "OK | SILENT | TOKEN_INVALID | NOT_CONFIGURED", "detail": "..." },
"wechat": { "status": "OK | BRIDGE_DOWN | NOT_CONFIGURED", "detail": "..." }
},
"crons": [
{
"id": "morning-push",
"schedule": "0 8 * * *",
"lastRun": "<ISO>",
"status": "OK | MISSED | DUPLICATE | FAILED",
"duplicateCount": 0
}
],
"overallStatus": "HEALTHY | DEGRADED | CRITICAL",
"actionsNeeded": ["fix-feishu", "dedup-crons"]
}
```
---
## 常见问题速查
| 症状 | 根因 | 修复命令 |
|------|------|---------|
| 飞书收不到推送 | lark-cli token 过期 | `node scripts/fix-feishu.js` |
| Telegram bot 无响应 | webhook 断开 / token 失效 | `node scripts/check-telegram.js` → 按提示操作 |
| 定时任务重复触发 | cron 条目重复注册 | `node scripts/fix-crons.js --dedup` |
| 定时任务静默不跑 | cron 进程崩溃 / 配置丢失 | `node scripts/check-crons.js` → 查看 MISSED 条目 |
| 配对码过期 | Telegram device code 超时 | `node scripts/check-telegram.js --reauth` |
---
*openclaw-healthcheck v1.0.0 — 用 Automatic Skill 流水线生成*
FILE:_meta.json
{
"ownerId": "kn72tjjxhyhdmfp6ffxyzn0bb983hx4m",
"slug": "openclaw-push-doctor",
"version": "1.0.2",
"publishedAt": 1775378677448
}
FILE:package.json
{
"name": "openclaw-push-doctor",
"version": "1.0.0",
"description": "Self-diagnostic skill for openclaw: checks Feishu/Telegram/WeChat connectivity and cron task health",
"keywords": [
"openclaw", "healthcheck", "feishu", "telegram", "wechat", "cron", "自检", "通讯诊断"
],
"author": "jiajiaoy",
"license": "MIT",
"scripts": {
"check": "node scripts/check-all.js",
"check:feishu": "node scripts/check-feishu.js",
"check:telegram": "node scripts/check-telegram.js",
"check:wechat": "node scripts/check-wechat.js",
"check:crons": "node scripts/check-crons.js",
"fix:feishu": "node scripts/fix-feishu.js",
"fix:crons": "node scripts/fix-crons.js"
}
}
FILE:scripts/check-all.js
#!/usr/bin/env node
/**
* openclaw-healthcheck — check-all.js
* PROMPT GENERATOR ONLY — no outbound network requests.
* Outputs a full diagnostic prompt covering all channels + cron tasks.
*
* Usage:
* node scripts/check-all.js
* node scripts/check-all.js --lang en
* node scripts/check-all.js --fix # auto-apply repairs after diagnosis
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
const autoFix = args.includes('--fix');
const configDir = '~/.openclaw';
if (lang === 'en') {
console.log(`=== openclaw-healthcheck — Full Diagnostic ===
Config dir: configDir
Mode: 'Diagnose only (add --fix to repair)'
You are an AI agent running openclaw's self-diagnostic. Check every channel and
every cron task in sequence, then output a consolidated health report.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 1 — FEISHU (lark-cli)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lark-cli --version 2>/dev/null || echo "NOT_INSTALLED"
lark-cli auth status 2>/dev/null || echo "NOT_AUTHENTICATED"
Decision:
→ NOT_INSTALLED → feishu: NOT_CONFIGURED
→ NOT_AUTHENTICATED → feishu: EXPIRED — run: node scripts/fix-feishu.js
→ Shows user + app ID → feishu: OK
→ Token expired error → feishu: EXPIRED — run: node scripts/fix-feishu.js ''
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 2 — TELEGRAM (bot API)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Check token is set
test -n "$TELEGRAM_BOT_TOKEN" && echo "TOKEN_SET" || echo "TOKEN_MISSING"
# Test bot API reachability
curl -s --max-time 10 "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe" | \\
python3 -c "import sys,json; d=json.load(sys.stdin); print('OK:', d['result']['username'])" \\
2>/dev/null || echo "API_UNREACHABLE"
# Check for recent updates (silence = possible webhook issue)
curl -s --max-time 10 "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=1" | \\
python3 -c "import sys,json; d=json.load(sys.stdin); print('UPDATES:', len(d['result']))" \\
2>/dev/null
Decision:
→ TOKEN_MISSING → telegram: NOT_CONFIGURED
→ API_UNREACHABLE → telegram: TOKEN_INVALID — run: node scripts/check-telegram.js --reauth
→ OK: <username> → telegram: OK
→ OK but UPDATES: 0 and bot was previously active → telegram: SILENT (possible webhook drop)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 3 — WECHAT (bridge process)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Check for running wechat bridge process
pgrep -f "wechat\\|wx-bridge\\|wechaty" 2>/dev/null && echo "BRIDGE_RUNNING" || echo "BRIDGE_DOWN"
# Check openclaw wechat config
test -f ~/.openclaw/wechat/session.json && echo "SESSION_FILE_EXISTS" || echo "NO_SESSION"
Decision:
→ BRIDGE_DOWN + NO_SESSION → wechat: NOT_CONFIGURED
→ BRIDGE_DOWN + SESSION → wechat: BRIDGE_DOWN — restart bridge: node scripts/check-wechat.js
→ BRIDGE_RUNNING → wechat: OK (verify with test message if needed)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 4 — CRON TASKS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# List all openclaw cron entries (try openclaw native first, fallback to system cron)
openclaw cron list 2>/dev/null || crontab -l 2>/dev/null | grep -i openclaw || echo "NO_CRON_FOUND"
# Check for duplicates (same schedule + same command appearing more than once)
crontab -l 2>/dev/null | sort | uniq -d | head -20
# Check recent execution logs
ls -lt ~/.openclaw/logs/*.log 2>/dev/null | head -10
tail -20 ~/.openclaw/logs/cron.log 2>/dev/null || echo "NO_CRON_LOG"
For each cron entry found, determine:
→ Last run timestamp vs expected schedule — is it OVERDUE?
→ Same schedule + same script appearing 2+ times → DUPLICATE
→ Exit code in log is non-zero → FAILED
→ No log entry in the last full interval → MISSED
run node scripts/fix-crons.js --dedup for any DUPLICATE entries found.` : ''
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OUTPUT — Write health report to data/health-report.json
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{
"checkedAt": "<ISO timestamp>",
"channels": {
"feishu": { "status": "OK | EXPIRED | DISCONNECTED | NOT_CONFIGURED", "detail": "..." },
"telegram": { "status": "OK | SILENT | TOKEN_INVALID | NOT_CONFIGURED", "detail": "..." },
"wechat": { "status": "OK | BRIDGE_DOWN | NOT_CONFIGURED", "detail": "..." }
},
"crons": [
{ "id": "<name>", "schedule": "<cron expr>", "lastRun": "<ISO>",
"status": "OK | MISSED | DUPLICATE | FAILED", "duplicateCount": 0 }
],
"overallStatus": "HEALTHY | DEGRADED | CRITICAL",
"actionsNeeded": ["<fix-feishu | fix-crons --dedup | check-telegram --reauth | ...>"]
}
After writing:
→ HEALTHY: "All channels and cron tasks are healthy. No action needed."
→ DEGRADED: List each degraded item and its recommended fix command.
→ CRITICAL: At least one channel is completely down. Run the fix commands immediately.
`);
} else {
console.log(`=== openclaw-healthcheck — 全量自检 ===
配置目录:configDir
模式:'仅诊断(加 --fix 参数可自动修复)'
你是一个 AI Agent,正在执行 openclaw 的通讯自检。
按顺序检查每个通道和每个定时任务,最后输出综合健康报告。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第 1 步 — 飞书(lark-cli)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lark-cli --version 2>/dev/null || echo "NOT_INSTALLED"
lark-cli auth status 2>/dev/null || echo "NOT_AUTHENTICATED"
决策:
→ NOT_INSTALLED → feishu: NOT_CONFIGURED
→ NOT_AUTHENTICATED → feishu: EXPIRED — 运行:node scripts/fix-feishu.js
→ 显示用户名 + App ID → feishu: OK
→ token expired 错误 → feishu: EXPIRED — 运行:node scripts/fix-feishu.js ''
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第 2 步 — Telegram(Bot API)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 检查 token 是否已设置
test -n "$TELEGRAM_BOT_TOKEN" && echo "TOKEN_SET" || echo "TOKEN_MISSING"
# 测试 Bot API 可达性
curl -s --max-time 10 "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe" | \\
python3 -c "import sys,json; d=json.load(sys.stdin); print('OK:', d['result']['username'])" \\
2>/dev/null || echo "API_UNREACHABLE"
# 检查最近更新(静默可能是 webhook 断开)
curl -s --max-time 10 "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=1" | \\
python3 -c "import sys,json; d=json.load(sys.stdin); print('UPDATES:', len(d['result']))" \\
2>/dev/null
决策:
→ TOKEN_MISSING → telegram: NOT_CONFIGURED
→ API_UNREACHABLE → telegram: TOKEN_INVALID — 运行:node scripts/check-telegram.js --reauth
→ OK: <用户名> → telegram: OK
→ OK 但 UPDATES: 0 且之前活跃 → telegram: SILENT(可能 webhook 掉了)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第 3 步 — 微信(bridge 进程)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 检查微信 bridge 进程是否在跑
pgrep -f "wechat\\|wx-bridge\\|wechaty" 2>/dev/null && echo "BRIDGE_RUNNING" || echo "BRIDGE_DOWN"
# 检查 openclaw 微信配置
test -f ~/.openclaw/wechat/session.json && echo "SESSION_FILE_EXISTS" || echo "NO_SESSION"
决策:
→ BRIDGE_DOWN + NO_SESSION → wechat: NOT_CONFIGURED
→ BRIDGE_DOWN + SESSION → wechat: BRIDGE_DOWN — 运行:node scripts/check-wechat.js
→ BRIDGE_RUNNING → wechat: OK(如需要可发测试消息验证)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第 4 步 — 定时任务(Cron)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 列出所有 openclaw 定时任务(先试 openclaw 原生命令,失败则查系统 cron)
openclaw cron list 2>/dev/null || crontab -l 2>/dev/null | grep -i openclaw || echo "NO_CRON_FOUND"
# 检查重复条目(相同调度 + 相同命令出现超过一次)
crontab -l 2>/dev/null | sort | uniq -d | head -20
# 检查最近执行日志
ls -lt ~/.openclaw/logs/*.log 2>/dev/null | head -10
tail -20 ~/.openclaw/logs/cron.log 2>/dev/null || echo "NO_CRON_LOG"
对每个 cron 条目判断:
→ 上次执行时间 vs 预期周期 — 是否超时未跑 (MISSED)?
→ 相同调度 + 相同脚本出现 2 次以上 → DUPLICATE
→ 日志中退出码非零 → FAILED
→ 最近一个完整周期内无日志 → MISSED
''
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
输出 — 写入 data/health-report.json
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{
"checkedAt": "<ISO 时间戳>",
"channels": {
"feishu": { "status": "OK | EXPIRED | DISCONNECTED | NOT_CONFIGURED", "detail": "..." },
"telegram": { "status": "OK | SILENT | TOKEN_INVALID | NOT_CONFIGURED", "detail": "..." },
"wechat": { "status": "OK | BRIDGE_DOWN | NOT_CONFIGURED", "detail": "..." }
},
"crons": [
{ "id": "<名称>", "schedule": "<cron 表达式>", "lastRun": "<ISO>",
"status": "OK | MISSED | DUPLICATE | FAILED", "duplicateCount": 0 }
],
"overallStatus": "HEALTHY | DEGRADED | CRITICAL",
"actionsNeeded": ["<fix-feishu | fix-crons --dedup | check-telegram --reauth | ...>"]
}
写入后:
→ HEALTHY: "所有通道和定时任务均正常,无需操作。"
→ DEGRADED: 列出每个降级项及推荐的修复命令。
→ CRITICAL: 至少一个通道完全断开,立即执行修复命令。
`);
}
FILE:scripts/check-crons.js
#!/usr/bin/env node
/**
* openclaw-healthcheck — check-crons.js
* PROMPT GENERATOR ONLY — no outbound network requests.
*
* Usage:
* node scripts/check-crons.js
* node scripts/check-crons.js --lang en
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
if (lang === 'en') {
console.log(`=== openclaw-healthcheck — Cron Task Check ===
Run the following checks:
# List openclaw cron tasks
openclaw cron list 2>/dev/null || \\
crontab -l 2>/dev/null | grep -i "openclaw\\|morning-push\\|daily-push" | cat || \\
echo "NO_CRON_FOUND"
# Check for duplicates
crontab -l 2>/dev/null | sort | uniq -d
# Check recent logs
tail -30 ~/.openclaw/logs/cron.log 2>/dev/null || echo "NO_CRON_LOG"
For each task found, assess:
→ Duplicate lines (same schedule + command) → DUPLICATE → run fix-crons.js --dedup
→ Last run timestamp overdue by >1 interval → MISSED → run fix-crons.js --restart
→ Non-zero exit code in logs → FAILED → check the failing script
→ Everything looks normal → OK
Output each task as:
{ "id": "<name>", "schedule": "<cron>", "lastRun": "<ISO>",
"status": "OK | MISSED | DUPLICATE | FAILED", "duplicateCount": 0 }
`);
} else {
console.log(`=== openclaw-healthcheck — 定时任务检查 ===
运行以下检查:
# 列出 openclaw 定时任务
openclaw cron list 2>/dev/null || \\
crontab -l 2>/dev/null | grep -i "openclaw\\|morning-push\\|daily-push" | cat || \\
echo "NO_CRON_FOUND"
# 检查重复条目
crontab -l 2>/dev/null | sort | uniq -d
# 检查最近日志
tail -30 ~/.openclaw/logs/cron.log 2>/dev/null || echo "NO_CRON_LOG"
对每个发现的任务:
→ 重复行(相同调度 + 命令) → DUPLICATE → 运行 fix-crons.js --dedup
→ 上次运行已超过一个完整周期 → MISSED → 运行 fix-crons.js --restart
→ 日志中退出码非零 → FAILED → 检查失败的脚本
→ 一切正常 → OK
输出每个任务:
{ "id": "<名称>", "schedule": "<cron>", "lastRun": "<ISO>",
"status": "OK | MISSED | DUPLICATE | FAILED", "duplicateCount": 0 }
`);
}
FILE:scripts/check-feishu.js
#!/usr/bin/env node
/**
* openclaw-healthcheck — check-feishu.js
* PROMPT GENERATOR ONLY — no outbound network requests.
*
* Usage:
* node scripts/check-feishu.js
* node scripts/check-feishu.js --lang en
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
if (lang === 'en') {
console.log(`=== openclaw-healthcheck — Feishu Status Check ===
Run the following checks and report results:
lark-cli --version 2>/dev/null || echo "NOT_INSTALLED"
lark-cli auth status 2>/dev/null || echo "NOT_AUTHENTICATED"
Interpret results:
→ NOT_INSTALLED feishu: NOT_CONFIGURED
→ NOT_AUTHENTICATED feishu: EXPIRED — run: node scripts/fix-feishu.js
→ "Token expired" feishu: EXPIRED — run: node scripts/fix-feishu.js
→ "Logged in as <user>" feishu: OK
If OK, run a quick smoke test:
lark-cli calendar +agenda --dry-run 2>/dev/null && echo "SMOKE_OK" || echo "SMOKE_FAILED"
Output:
"feishu": { "status": "OK | EXPIRED | NOT_CONFIGURED", "detail": "..." }
`);
} else {
console.log(`=== openclaw-healthcheck — 飞书状态检查 ===
运行以下检查并报告结果:
lark-cli --version 2>/dev/null || echo "NOT_INSTALLED"
lark-cli auth status 2>/dev/null || echo "NOT_AUTHENTICATED"
结果判断:
→ NOT_INSTALLED feishu: NOT_CONFIGURED
→ NOT_AUTHENTICATED feishu: EXPIRED — 运行:node scripts/fix-feishu.js
→ "Token expired" feishu: EXPIRED — 运行:node scripts/fix-feishu.js
→ "Logged in as <用户>" feishu: OK
如 OK,运行快速冒烟测试:
lark-cli calendar +agenda --dry-run 2>/dev/null && echo "SMOKE_OK" || echo "SMOKE_FAILED"
输出:
"feishu": { "status": "OK | EXPIRED | NOT_CONFIGURED", "detail": "..." }
`);
}
FILE:scripts/check-telegram.js
#!/usr/bin/env node
/**
* openclaw-healthcheck — check-telegram.js
* PROMPT GENERATOR ONLY — no outbound network requests.
* Diagnoses Telegram bot status: token validity, webhook, silence detection.
* Handles re-auth (new bot token or re-pair device code).
*
* Usage:
* node scripts/check-telegram.js
* node scripts/check-telegram.js --lang en
* node scripts/check-telegram.js --reauth # force re-pair flow
* node scripts/check-telegram.js --webhook # check/reset webhook
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
const reauth = args.includes('--reauth');
const checkWebhook = args.includes('--webhook');
if (lang === 'en') {
console.log(`=== openclaw-healthcheck — Telegram Diagnostics ===
Mode: checkWebhook ? 'Webhook check' : 'Full check'
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 1 — Check token configuration
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Check env var
test -n "$TELEGRAM_BOT_TOKEN" && echo "TOKEN_SET" || echo "TOKEN_MISSING"
# Check openclaw config file
cat ~/.openclaw/telegram/config.json 2>/dev/null | \\
python3 -c "import sys,json; d=json.load(sys.stdin); print('CONFIG:', 'token' in d)" \\
2>/dev/null || echo "NO_CONFIG_FILE"
→ TOKEN_MISSING + NO_CONFIG_FILE: Telegram not configured. Skip to STEP 4 to configure.
→ TOKEN_SET or config has token: proceed to STEP 2.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 2 — Test bot API connectivity
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
curl -s --max-time 15 \\
"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe"
Expected: { "ok": true, "result": { "username": "...", "id": ... } }
→ ok=true: bot is alive. Record username.
→ ok=false: "Unauthorized" → token is invalid. Proceed to STEP 4 (re-pair).
→ Timeout / connection refused: network issue. Check internet and retry.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 3 — Check for silence (bot alive but not receiving)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Get last update timestamp
curl -s --max-time 15 \\
"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=1&timeout=5"
# Check webhook status
curl -s --max-time 15 \\
"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo"
Analyze:
→ webhook url is set + has_custom_certificate=false + last_error_date is recent:
WEBHOOK_ERROR — the endpoint is unreachable. Run with --webhook to reset.
→ webhook url is empty + updates array is empty + bot was previously sending:
SILENT — possible getUpdates polling conflict. Restart openclaw cron push.
→ webhook url is empty + updates array has recent items:
OK — bot is receiving normally via polling.
If webhook error detected:
# Clear the webhook to fall back to polling
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/deleteWebhook"
→ Then restart the openclaw push cron.
"Your Telegram bot token is invalid or expired. To fix this:
1. Open Telegram and search for @BotFather
2. Send /mybots → select your bot → API Token → Revoke current token → Generate new token
3. Set the new token in YOUR OWN terminal (do NOT paste the token here):
export TELEGRAM_BOT_TOKEN='your-new-token-here'
Then update the openclaw config yourself:
python3 -c \"import json,os; p=os.path.expanduser('~/.openclaw/telegram/config.json'); c=json.load(open(p)); c['token']=os.environ['TELEGRAM_BOT_TOKEN']; json.dump(c,open(p,'w')); print('Done.')\"
Let me know when you have run those two commands."
OR: If you want a new bot, send /newbot to @BotFather and follow the steps."
⚠️ NEVER ask the user to paste or type the token value into the conversation.
The user must set TELEGRAM_BOT_TOKEN in their own terminal.
The config update command above reads from os.environ — no token value passes through chat.
After user confirms they have set the token in their terminal:
# Verify — reads $TELEGRAM_BOT_TOKEN from env, never exposes the value
curl -s --max-time 10 "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe"
→ ok=true + username returned: SUCCESS
→ ok=false (Unauthorized): token still invalid — ask user to re-check the steps above
` : ''━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OUTPUT — Update data/health-report.json
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"telegram": {
"status": "OK | SILENT | TOKEN_INVALID | NOT_CONFIGURED | WEBHOOK_ERROR",
"botUsername": "<username if known>",
"webhookStatus": "none | active | error",
"detail": "..."
}
`);
} else {
console.log(`=== openclaw-healthcheck — Telegram 诊断 ===
模式:checkWebhook ? 'Webhook 检查' : '全量检查'
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第 1 步 — 检查 token 配置
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 检查环境变量
test -n "$TELEGRAM_BOT_TOKEN" && echo "TOKEN_SET" || echo "TOKEN_MISSING"
# 检查 openclaw 配置文件
cat ~/.openclaw/telegram/config.json 2>/dev/null | \\
python3 -c "import sys,json; d=json.load(sys.stdin); print('CONFIG:', 'token' in d)" \\
2>/dev/null || echo "NO_CONFIG_FILE"
→ TOKEN_MISSING + NO_CONFIG_FILE:Telegram 未配置,跳到第 4 步配置。
→ TOKEN_SET 或配置文件有 token:进入第 2 步。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第 2 步 — 测试 Bot API 连通性
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
curl -s --max-time 15 \\
"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe"
期望返回:{ "ok": true, "result": { "username": "...", "id": ... } }
→ ok=true:bot 存活,记录用户名。
→ ok=false("Unauthorized"):token 无效,进入第 4 步重新配对。
→ 超时 / 连接拒绝:网络问题,检查网络后重试。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第 3 步 — 检测静默(bot 活着但收不到消息)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 获取最新更新时间戳
curl -s --max-time 15 \\
"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=1&timeout=5"
# 检查 webhook 状态
curl -s --max-time 15 \\
"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo"
分析:
→ webhook url 已设置 + last_error_date 是近期时间:
WEBHOOK_ERROR — 端点不可达,加 --webhook 参数重置。
→ webhook url 为空 + updates 为空 + bot 之前有推送记录:
SILENT — 可能 getUpdates 轮询冲突,重启 openclaw push cron。
→ webhook url 为空 + updates 有近期消息:
OK — bot 正在通过轮询正常接收。
如检测到 webhook 错误:
# 清除 webhook,回退到轮询模式
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/deleteWebhook"
→ 然后重启 openclaw push cron。
//api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe"
→ ok=true + 返回用户名:成功
→ ok=false(Unauthorized):token 仍无效,请用户重新检查上述步骤
` : ''━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
输出 — 更新 data/health-report.json
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"telegram": {
"status": "OK | SILENT | TOKEN_INVALID | NOT_CONFIGURED | WEBHOOK_ERROR",
"botUsername": "<用户名(如已知)>",
"webhookStatus": "none | active | error",
"detail": "..."
}
`);
}
FILE:scripts/check-wechat.js
#!/usr/bin/env node
/**
* openclaw-healthcheck — check-wechat.js
* PROMPT GENERATOR ONLY — no outbound network requests.
*
* Usage:
* node scripts/check-wechat.js
* node scripts/check-wechat.js --lang en
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
if (lang === 'en') {
console.log(`=== openclaw-healthcheck — WeChat Bridge Status ===
Run the following checks:
# Check for WeChat bridge process
pgrep -f "wechat|wx-bridge|wechaty" 2>/dev/null && echo "BRIDGE_RUNNING" || echo "BRIDGE_DOWN"
# Check session file
test -f ~/.openclaw/wechat/session.json && echo "SESSION_EXISTS" || echo "NO_SESSION"
# Check openclaw wechat config
cat ~/.openclaw/wechat/config.json 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "NO_CONFIG"
Interpret results:
→ BRIDGE_DOWN + NO_SESSION wechat: NOT_CONFIGURED
→ BRIDGE_DOWN + SESSION_EXISTS wechat: BRIDGE_DOWN — restart bridge manually
→ BRIDGE_RUNNING wechat: OK (verify by checking recent message log)
If BRIDGE_DOWN and session exists:
Tell user: "WeChat bridge is down. Restart it with your usual startup command,
or re-scan the QR code if the session has expired."
Output:
"wechat": { "status": "OK | BRIDGE_DOWN | NOT_CONFIGURED", "detail": "..." }
`);
} else {
console.log(`=== openclaw-healthcheck — 微信 Bridge 状态检查 ===
运行以下检查:
# 检查微信 bridge 进程
pgrep -f "wechat|wx-bridge|wechaty" 2>/dev/null && echo "BRIDGE_RUNNING" || echo "BRIDGE_DOWN"
# 检查 session 文件
test -f ~/.openclaw/wechat/session.json && echo "SESSION_EXISTS" || echo "NO_SESSION"
# 检查 openclaw 微信配置
cat ~/.openclaw/wechat/config.json 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "NO_CONFIG"
结果判断:
→ BRIDGE_DOWN + NO_SESSION wechat: NOT_CONFIGURED
→ BRIDGE_DOWN + SESSION_EXISTS wechat: BRIDGE_DOWN — 手动重启 bridge
→ BRIDGE_RUNNING wechat: OK(通过检查最近消息日志确认)
如 BRIDGE_DOWN 且 session 存在:
告知用户:"微信 bridge 已断开。请用您平时的启动命令重启,
如果 session 已过期,需要重新扫描二维码。"
输出:
"wechat": { "status": "OK | BRIDGE_DOWN | NOT_CONFIGURED", "detail": "..." }
`);
}
FILE:scripts/fix-crons.js
#!/usr/bin/env node
/**
* openclaw-healthcheck — fix-crons.js
* PROMPT GENERATOR ONLY — no outbound network requests.
* Guides agent through diagnosing and repairing openclaw cron tasks:
* deduplication, restart failed tasks, verify push delivery.
*
* Usage:
* node scripts/fix-crons.js
* node scripts/fix-crons.js --lang en
* node scripts/fix-crons.js --dedup # remove duplicate cron entries
* node scripts/fix-crons.js --restart # restart all openclaw cron jobs
* node scripts/fix-crons.js --verify # send test push for each active job
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
const dedup = args.includes('--dedup');
const restart = args.includes('--restart');
const verify = args.includes('--verify');
const mode = dedup ? 'dedup' : restart ? 'restart' : verify ? 'verify' : 'full';
if (lang === 'en') {
console.log(`=== openclaw-healthcheck — Fix Cron Tasks ===
Mode: mode === 'dedup' ? 'Deduplicate only' :
mode === 'restart' ? 'Restart jobs only' : 'Verify push delivery only'
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 1 — List all openclaw cron tasks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Try openclaw native cron manager first
openclaw cron list 2>/dev/null
# Fallback: read system crontab filtered to openclaw
crontab -l 2>/dev/null | grep -i "openclaw\\|morning-push\\|daily-push\\|skill" | cat
# Also check openclaw-specific cron config files
ls ~/.openclaw/cron/ 2>/dev/null
cat ~/.openclaw/cron/*.json 2>/dev/null | python3 -m json.tool 2>/dev/null
For each task found, record:
- Task ID / name
- Schedule (cron expression)
- Target channel (feishu / telegram / wechat)
- Script or command being run
- Last run timestamp (from logs if available)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
identical schedule + identical command appears 2+ times in crontab.
This causes the same message to be pushed multiple times.
# Find exact duplicates in system crontab
crontab -l 2>/dev/null | sort | uniq -d
# Find near-duplicates (same script, different schedule spacing by <5 min)
crontab -l 2>/dev/null | grep -i "openclaw\\|morning-push" | sort
If duplicates found:
1. Show user the duplicate lines
2. Confirm which to keep (usually the first one, or the one with the correct schedule)
3. Remove duplicates:
# Export, edit, reimport
crontab -l > /tmp/crontab_backup.txt
# Edit /tmp/crontab_backup.txt to remove duplicate lines
# Review the diff:
diff /tmp/crontab_backup.txt <(sort -u /tmp/crontab_backup.txt)
⚠️ PAUSE: Show the diff output to the user and ask:
"I'm about to replace your crontab with the deduplicated version above.
The following lines will be removed: [list duplicate lines].
Do you confirm? (yes/no)"
Only proceed after the user explicitly confirms.
# Apply ONLY after explicit user confirmation:
crontab /tmp/crontab_backup.txt
# Verify:
crontab -l | grep openclaw
For openclaw native cron:
openclaw cron remove <duplicate-task-id>
` : '''2' — Restart failed or stalled jobs
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Check if openclaw daemon/cron is running
pgrep -f "openclaw" | head -5
openclaw status 2>/dev/null || echo "openclaw status not available"
# Check cron daemon
pgrep -x cron || pgrep -x crond || echo "CRON_DAEMON_NOT_RUNNING"
If cron daemon is not running:
# macOS:
launchctl start com.vix.cron 2>/dev/null || sudo launchctl start com.vix.cron
If openclaw push daemon is not running:
openclaw push-on 2>/dev/null || openclaw cron restart 2>/dev/null
# Verify restart:
openclaw status
` : ''}dedup ? '2' : restart ? '2' : '2' — Verify push delivery with test messages
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
For each active push task, send a test message to its target channel:
Feishu test:
lark-cli im +messages-send --chat-id oc_xxx \\
--text "🔧 openclaw cron health check — $(date)" --dry-run
# Remove --dry-run when ready to send for real
Telegram test:
curl -s -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \\
-d "chat_id=$TELEGRAM_CHAT_ID&text=🔧 openclaw cron health check — $(date)"
→ 200 OK + message_id returned = delivery confirmed
→ Error = channel still broken, re-run channel-specific fix script
` : ''}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OUTPUT — Update data/health-report.json crons array
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
For each task, update its entry:
{ "id": "<name>", "schedule": "<expr>", "status": "OK",
"duplicateCount": 0, "lastFixed": "<ISO timestamp>" }
Summary line:
"Cron repair complete. <N> duplicates removed. <M> jobs restarted. All delivery tests passed."
`);
} else {
console.log(`=== openclaw-healthcheck — 修复定时任务 ===
模式:mode === 'dedup' ? '仅去重' :
mode === 'restart' ? '仅重启任务' : '仅验证推送'
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第 1 步 — 列出所有 openclaw 定时任务
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 先尝试 openclaw 原生定时任务管理器
openclaw cron list 2>/dev/null
# 回退:读取系统 crontab 中的 openclaw 相关条目
crontab -l 2>/dev/null | grep -i "openclaw\\|morning-push\\|daily-push\\|skill" | cat
# 检查 openclaw 专属 cron 配置文件
ls ~/.openclaw/cron/ 2>/dev/null
cat ~/.openclaw/cron/*.json 2>/dev/null | python3 -m json.tool 2>/dev/null
对每个发现的任务,记录:
- 任务 ID / 名称
- 调度表达式(cron expression)
- 目标通道(飞书 / Telegram / 微信)
- 执行的脚本或命令
- 上次运行时间戳(从日志获取)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
'''2' 步 — 重启失败或卡住的任务
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 检查 openclaw 守护进程是否在运行
pgrep -f "openclaw" | head -5
openclaw status 2>/dev/null || echo "openclaw status 命令不可用"
# 检查系统 cron 守护进程
pgrep -x cron || pgrep -x crond || echo "CRON_DAEMON_NOT_RUNNING"
如果 cron 守护进程未运行:
# macOS:
launchctl start com.vix.cron 2>/dev/null || sudo launchctl start com.vix.cron
如果 openclaw 推送守护进程未运行:
openclaw push-on 2>/dev/null || openclaw cron restart 2>/dev/null
# 验证重启:
openclaw status
` : ''}'2' 步 — 发送测试消息验证推送
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
对每个活跃的推送任务,向目标通道发送测试消息:
飞书测试:
lark-cli im +messages-send --chat-id oc_xxx \\
--text "🔧 openclaw cron 健康检查 — $(date)" --dry-run
# 确认无误后去掉 --dry-run 发送真实消息
Telegram 测试:
curl -s -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \\
-d "chat_id=$TELEGRAM_CHAT_ID&text=🔧 openclaw cron 健康检查 — $(date)"
→ 返回 200 OK + message_id = 投递确认
→ 报错 = 通道仍有问题,重新运行对应的通道修复脚本
` : ''}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
输出 — 更新 data/health-report.json crons 数组
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
对每个任务更新其条目:
{ "id": "<名称>", "schedule": "<表达式>", "status": "OK",
"duplicateCount": 0, "lastFixed": "<ISO 时间戳>" }
总结:
"定时任务修复完成。删除重复条目 <N> 个,重启任务 <M> 个,所有推送测试通过。"
`);
}
FILE:scripts/fix-feishu.js
#!/usr/bin/env node
/**
* openclaw-healthcheck — fix-feishu.js
* PROMPT GENERATOR ONLY — no outbound network requests.
* Guides the agent through Feishu/lark-cli re-authentication.
* Handles both token refresh (quick) and full OAuth re-login (browser step).
*
* Usage:
* node scripts/fix-feishu.js
* node scripts/fix-feishu.js --lang en
* node scripts/fix-feishu.js --full # force full OAuth re-login
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
const fullReauth = args.includes('--full');
if (lang === 'en') {
console.log(`=== openclaw-healthcheck — Fix Feishu Auth ===
Mode: 'Try token refresh first, fall back to re-login'
Feishu (lark-cli) tokens expire and must be refreshed or re-issued.
This guide covers both the quick refresh path and the full browser OAuth path.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 1 — Diagnose current auth state
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lark-cli auth status
Outcomes:
A) Shows "Logged in as <user>" with no expiry warning → auth is fine, no action needed
B) Shows "Token expires in <N> days" → proceed to STEP 2 (refresh)
C) Shows "Token expired" or "Unauthorized" → proceed to STEP 2 (refresh)
D) Command not found → lark-cli not installed, run feishu-cli-setup skill first
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 2 — Try token refresh (no browser needed)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lark-cli auth refresh
→ If succeeds: run lark-cli auth status to confirm new expiry date
→ If fails with "refresh token expired" or "invalid_grant": proceed to STEP 3 (full re-login)
→ If fails with network error: check internet connection and retry
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP '3' — Full OAuth re-login ⚠️ BROWSER STEP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Run in background and capture output:
lark-cli auth login --recommend --no-wait
⚠️ EXTRACT THE AUTHORIZATION URL from the output. It looks like:
"Please open the following URL to authorize: https://accounts.feishu.cn/..."
Send this message to the user:
"Your Feishu token has expired and needs to be renewed.
Please open this URL in your browser to re-authorize lark-cli:
[paste URL here]
Log in to Feishu and click Authorize. Tell me when done."
After user confirms:
lark-cli auth status # Must show: Logged in as <user>
If still failing (device code expired):
lark-cli auth login --device-code <DEVICE_CODE from earlier output>
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP '4' — Verify and test push
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lark-cli auth status # confirm logged in
lark-cli im +messages-send --chat-id oc_xxx --text "✅ lark-cli reconnected at $(date)" --dry-run
→ If --dry-run succeeds, remove --dry-run for a real test message (optional)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OUTPUT — Update data/health-report.json
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Update the feishu entry:
"feishu": { "status": "OK", "detail": "Re-authenticated at <timestamp>. Token valid." }
`);
} else {
console.log(`=== openclaw-healthcheck — 修复飞书认证 ===
模式:'先尝试 token 刷新,失败再重新登录'
飞书 lark-cli token 会过期,需要刷新或重新授权。
本指南覆盖快速刷新和完整浏览器 OAuth 两条路径。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第 1 步 — 诊断当前认证状态
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lark-cli auth status
结果判断:
A) 显示 "Logged in as <用户>" 且无过期警告 → auth 正常,无需操作
B) 显示 "Token expires in <N> days" → 进入第 2 步(刷新)
C) 显示 "Token expired" 或 "Unauthorized" → 进入第 2 步(刷新)
D) 命令未找到 → lark-cli 未安装,先运行 feishu-cli-setup skill
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第 2 步 — 尝试 token 刷新(无需浏览器)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lark-cli auth refresh
→ 成功:运行 lark-cli auth status 确认新的过期日期
→ 失败("refresh token expired" / "invalid_grant"):进入第 3 步(完整重新登录)
→ 失败(网络错误):检查网络后重试
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第 '3' 步 — 完整 OAuth 重新登录 ⚠️ 浏览器步骤
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
在后台运行并捕获输出:
lark-cli auth login --recommend --no-wait
⚠️ 立即从输出中提取授权 URL,格式类似:
"Please open the following URL to authorize: https://accounts.feishu.cn/..."
向用户发送此消息:
"您的飞书 token 已过期,需要重新授权。
请在浏览器中打开这个链接:
[粘贴 URL]
登录飞书并点击"授权",完成后告诉我。"
用户确认后:
lark-cli auth status # 必须显示:已登录为 <用户>
如仍失败(device code 过期):
lark-cli auth login --device-code <上面输出中的 DEVICE_CODE>
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第 '4' 步 — 验证并测试推送
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lark-cli auth status # 确认已登录
lark-cli im +messages-send --chat-id oc_xxx --text "✅ lark-cli 已重新连接 $(date)" --dry-run
→ --dry-run 成功后,去掉 --dry-run 发送真实测试消息(可选)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
输出 — 更新 data/health-report.json
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
更新飞书条目:
"feishu": { "status": "OK", "detail": "已于 <时间戳> 重新认证。Token 有效。" }
`);
}
Step-by-step AI agent guide for installing and configuring lark-cli (飞书/Lark CLI). Designed for Claude, Manus, and OpenClaw to proactively guide users throug...
---
name: Feishu CLI Setup
description: |
Step-by-step AI agent guide for installing and configuring lark-cli (飞书/Lark CLI).
Designed for Claude, Manus, and OpenClaw to proactively guide users through:
prerequisites check → npm install → Feishu app config → OAuth login → verification → first commands.
Handles the browser-based OAuth flow by extracting authorization URLs and presenting them to the user.
Covers all 20 built-in lark-cli Agent Skills across Calendar, IM, Docs, Base, Sheets, Tasks, Mail, and more.
keywords:
- feishu
- lark
- lark-cli
- feishu-cli
- install
- setup
- 飞书
- 飞书cli
- 飞书安装
- 飞书配置
- lark install
- lark setup
- claude
- manus
- openclaw
- ai agent
- agent setup
- npm install
- oauth
- auth login
- 认证
- 安装向导
- calendar
- im
- docs
- sheets
- 日历
- 消息
- 文档
- 表格
- 任务
- automation
- 自动化
- 集成
metadata:
openclaw:
runtime:
node: ">=18"
---
# Feishu CLI Setup
An AI-native installation guide for **lark-cli** — the official Lark/Feishu CLI tool (6.7k ⭐, maintained by larksuite). Enables Claude, Manus, and OpenClaw to proactively guide users from zero to a fully authenticated lark-cli in minutes.
> **Why does this skill exist?** Installing lark-cli involves a browser OAuth step that AI agents cannot complete on behalf of users. This skill provides the exact prompts and workflows for agents to extract authorization URLs from CLI output and present them to users at the right moment — making the otherwise tricky setup seamless.
## What is lark-cli?
| Item | Detail |
|------|--------|
| Repo | [github.com/larksuite/cli](https://github.com/larksuite/cli) |
| Latest | v1.0.4 |
| Commands | 200+ |
| Agent Skills | 20 built-in (Calendar, IM, Docs, Base, Sheets, Tasks, Mail, Wiki, …) |
| License | MIT |
| Install | `npm install -g @larksuite/cli` |
## Scripts
| Script | Purpose |
|--------|---------|
| `scripts/check.js` | Detect OS, Node.js version, and lark-cli install state |
| `scripts/install.js` | Guide `npm install -g @larksuite/cli` + skills add |
| `scripts/config.js` | Guide `lark-cli config init --new` — extracts browser URL for user |
| `scripts/auth.js` | Guide `lark-cli auth login --recommend` — extracts OAuth URL for user |
| `scripts/verify.js` | Run `lark-cli auth status` and summarize available Agent Skills |
| `scripts/learn.js` | Show first commands to try (calendar, im, docs, tasks) |
| `scripts/setup.js` | Full guided pipeline — runs all steps in sequence |
## Usage
```bash
# Full guided setup (recommended)
node scripts/setup.js
# Individual stages
node scripts/check.js # detect current state
node scripts/install.js # install lark-cli
node scripts/config.js # configure Feishu app credentials
node scripts/auth.js # authenticate via OAuth
node scripts/verify.js # verify and list available skills
node scripts/learn.js # first commands guide
# Language options
node scripts/setup.js --lang en
node scripts/setup.js --lang zh # default
```
## Agent Workflow (4 Steps)
```
Step 1 — Install
npm install -g @larksuite/cli
npx skills add larksuite/cli -y -g
Step 2 — Configure app credentials (run in background, extract URL → send to user)
lark-cli config init --new
Step 3 — Login (run in background, extract URL → send to user)
lark-cli auth login --recommend
Step 4 — Verify
lark-cli auth status
```
## 20 Built-in Agent Skills (after setup)
| Skill | Domain |
|-------|--------|
| `lark-shared` | Auth, config, identity (auto-loaded) |
| `lark-calendar` | Calendar, agenda, events |
| `lark-im` | Messages, group chats, reactions |
| `lark-doc` | Documents (Markdown) |
| `lark-drive` | Files, uploads, downloads |
| `lark-sheets` | Spreadsheets |
| `lark-base` | Tables, records, views, dashboards |
| `lark-task` | Tasks, subtasks, reminders |
| `lark-mail` | Email (send, reply, search) |
| `lark-contact` | User search by name/email/phone |
| `lark-wiki` | Knowledge spaces & nodes |
| `lark-event` | WebSocket event subscriptions |
| `lark-vc` | Meeting records & minutes |
| `lark-whiteboard` | Whiteboard/chart DSL |
| `lark-minutes` | Meeting AI artifacts |
| `lark-openapi-explorer` | API documentation explorer |
| `lark-skill-maker` | Custom skill framework |
| `lark-approval` | Approval tasks & workflows |
| `lark-workflow-meeting-summary` | Meeting summary workflow |
| `lark-workflow-standup-report` | Standup report workflow |
## Security Notes
lark-cli runs under your Feishu/Lark user identity. Keep these in mind:
- Do not share your `LARK_APP_ID` / `LARK_APP_SECRET` in public repos
- Use `--dry-run` for commands with side effects before executing
- Do not add the bot to group chats if you want to avoid permission exposure
- Credentials are stored in the OS native keychain (not plaintext)
---
*Version: 1.0.0 · Source: [github.com/larksuite/cli](https://github.com/larksuite/cli) · Updated: 2026-04-05*
FILE:_meta.json
{
"ownerId": "kn79bebfnwg15sb0g7cj5z5nyd83gxh0",
"slug": "feishu-cli-setup",
"version": "1.0.0",
"publishedAt": null
}
FILE:package.json
{
"name": "feishu-cli-setup",
"version": "1.0.0",
"description": "AI agent guide for installing and configuring lark-cli (飞书/Lark CLI) — handles npm install, Feishu app config, and OAuth login flow.",
"main": "scripts/setup.js",
"scripts": {
"setup": "node scripts/setup.js",
"check": "node scripts/check.js",
"install": "node scripts/install.js",
"config": "node scripts/config.js",
"auth": "node scripts/auth.js",
"verify": "node scripts/verify.js",
"learn": "node scripts/learn.js"
},
"engines": {
"node": ">=18"
}
}
FILE:scripts/auth.js
#!/usr/bin/env node
/**
* feishu-cli-setup — auth.js
* PROMPT GENERATOR ONLY — no outbound network requests.
* Outputs an agent prompt to guide OAuth login via lark-cli auth login.
*
* CRITICAL: lark-cli auth login outputs a browser URL that the agent must
* extract and send to the user. The user completes OAuth in the browser.
*
* Usage:
* node scripts/auth.js
* node scripts/auth.js --lang en
* node scripts/auth.js --domain calendar,im,docs # specific domains only
* node scripts/auth.js --mode full # all scopes (default: recommend)
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
const domainIdx = args.indexOf('--domain');
const domain = domainIdx !== -1 ? args[domainIdx + 1] : null;
const modeIdx = args.indexOf('--mode');
const mode = modeIdx !== -1 ? args[modeIdx + 1] : 'recommend';
const loginCmd = domain
? `lark-cli auth login --domain domain --no-wait`
: mode === 'full'
? `lark-cli auth login --no-wait`
: `lark-cli auth login --recommend --no-wait`;
const loginCmdNote = domain
? `Domain-specific login: domain`
: mode === 'full'
? 'Full scope login (all domains)'
: 'Recommended scopes (most common domains — calendar, im, docs, tasks, mail)';
if (lang === 'en') {
console.log(`=== Feishu CLI Setup — Step 3: OAuth Login ===
Mode: loginCmdNote
Log in to Feishu/Lark via OAuth. This step requires a browser action from the user.
The CLI outputs an authorization URL — extract it and send it to the user immediately.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
AGENT INSTRUCTIONS: Read carefully before running
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 1 — Run auth login (non-blocking, with --no-wait)
Run in the background:
loginCmd
The command immediately outputs something like:
"Please open the following URL to authorize:
https://accounts.feishu.cn/suite/passport/oauth/authorize?..."
⚠️ The --no-wait flag makes the command return immediately with the URL.
⚠️ Extract the full URL from the output right away.
STEP 2 — Send the authorization URL to the user
Tell the user:
"Please open this link in your browser to authorize lark-cli:
<extracted authorization URL>
This will ask you to log in to Feishu and grant permissions to the app.
Click 'Authorize' when prompted. Come back and tell me when you're done."
STEP 3 — Resume polling after user completes authorization
Once the user says they've authorized in the browser, run:
lark-cli auth login --device-code <DEVICE_CODE>
(The device code appears in the original command output alongside the URL)
OR: just run lark-cli auth status to check if login succeeded automatically.
STEP 4 — Verify login
lark-cli auth status
→ Must show: logged in as <user email or name>
→ Must show: granted scopes list
→ If still "not authenticated": ask user to confirm they clicked Authorize in the browser
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SCOPE MODES (for reference)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
--recommend Grants commonly-used scopes: calendar, im, docs, tasks, contacts, mail
--domain X,Y Only grants scopes for specific domains
(no flag) Interactive TUI — user selects scopes manually
--scope "X" Exact scope string
Tip for agents: Start with --recommend for most users. They can add more scopes later with:
lark-cli auth login --domain <new-domain>
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TROUBLESHOOTING
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Problem: Authorization URL expired
→ Re-run: loginCmd
Problem: "app not configured" error
→ Run config step first: node scripts/config.js
Problem: Login shows success but auth status fails
→ Try: lark-cli auth logout && loginCmd
Problem: Scope not granted after authorization
→ Check: lark-cli auth scopes (lists all available scopes for this app)
→ Re-login with specific scope: lark-cli auth login --scope "calendar:calendar"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RESULT REPORT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Report:
Logged in as: <user display name or email>
Granted scopes: <count> (e.g., "12 scopes")
Domains active: <calendar, im, docs, ...>
Status: SUCCESS | FAILED | WAITING_FOR_USER
If SUCCESS: proceed to Step 4 — run: node scripts/verify.js
`);
} else {
console.log(`=== 飞书 CLI 安装向导 — 第 3 步:OAuth 登录 ===
模式:loginCmdNote
通过 OAuth 登录飞书/Lark。本步骤需要用户在浏览器中操作。
CLI 会输出授权 URL — 立即提取并发送给用户。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Agent 操作说明:执行前请仔细阅读
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
步骤 1 — 运行 auth login(非阻塞,使用 --no-wait)
在后台运行:
loginCmd
命令会立即输出类似以下内容:
"Please open the following URL to authorize:
https://accounts.feishu.cn/suite/passport/oauth/authorize?..."
⚠️ --no-wait 标志让命令立即返回 URL。
⚠️ 立即从输出中提取完整 URL。
步骤 2 — 将授权 URL 发送给用户
告知用户:
"请在浏览器中打开以下链接来授权 lark-cli:
<提取的授权 URL>
这将要求您登录飞书并授权应用权限。
出现提示时点击"授权"。完成后告诉我。"
步骤 3 — 用户授权后恢复轮询
用户表示已在浏览器中授权后,运行:
lark-cli auth login --device-code <DEVICE_CODE>
(device code 出现在原始命令输出中,与 URL 一起显示)
或者:直接运行 lark-cli auth status 检查是否已自动登录成功。
步骤 4 — 验证登录
lark-cli auth status
→ 必须显示:已登录为 <用户邮箱或姓名>
→ 必须显示:已授权的 scope 列表
→ 如仍显示"未认证":询问用户是否确认在浏览器中点击了"授权"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Scope 模式说明(参考)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
--recommend 授权常用 scope:日历、消息、文档、任务、通讯录、邮件
--domain X,Y 仅授权指定域的 scope
(无标志) 交互式 TUI — 用户手动选择 scope
--scope "X" 指定精确的 scope 字符串
Agent 提示:大多数用户建议使用 --recommend。之后可通过以下命令添加更多 scope:
lark-cli auth login --domain <新域>
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
故障排查
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
问题:授权 URL 已过期
→ 重新运行:loginCmd
问题:"app not configured" 错误
→ 先完成配置步骤:node scripts/config.js
问题:登录显示成功但 auth status 失败
→ 尝试:lark-cli auth logout && loginCmd
问题:授权后 scope 未生效
→ 检查:lark-cli auth scopes(列出该应用所有可用 scope)
→ 用指定 scope 重新登录:lark-cli auth login --scope "calendar:calendar"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
结果报告
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
报告:
已登录为: <用户姓名或邮箱>
已授权 scope: <数量>(如"12 个 scope")
激活的域: <calendar, im, docs, ...>
状态: SUCCESS | FAILED | WAITING_FOR_USER
如果 SUCCESS:进入第 4 步 — 运行:node scripts/verify.js
`);
}
FILE:scripts/check.js
#!/usr/bin/env node
/**
* feishu-cli-setup — check.js
* PROMPT GENERATOR ONLY — no outbound network requests.
* Outputs an agent prompt to detect OS, Node.js version, and lark-cli install state.
*
* Usage:
* node scripts/check.js
* node scripts/check.js --lang en
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
if (lang === 'en') {
console.log(`=== Feishu CLI Setup — Step 0: Environment Check ===
Before installing lark-cli, check the user's environment and report findings.
Run each command and report the result clearly.
── DETECTION STEPS ──────────────────────────────────────────
CHECK 1 — Operating System
uname -s 2>/dev/null || systeminfo | findstr "OS Name"
→ Report: macOS / Linux / Windows
CHECK 2 — Node.js & npm version
node --version
npm --version
→ Required: Node.js ≥ 18 and npm installed
→ If missing: tell user to install from https://nodejs.org
CHECK 3 — lark-cli install state
lark-cli --version 2>/dev/null || echo "NOT_INSTALLED"
→ If shows a version (e.g., v1.0.4): already installed — skip to check.js step 3
→ If NOT_INSTALLED: proceed to install.js
CHECK 4 — lark-cli auth status (only if installed)
lark-cli auth status 2>/dev/null || echo "NOT_AUTHENTICATED"
→ If shows user info: already authenticated — skip to verify.js
→ If NOT_AUTHENTICATED: proceed to config.js then auth.js
── REPORT FORMAT ────────────────────────────────────────────
After running all checks, summarize:
Environment Report:
OS: <macOS 14.x / Ubuntu 22.04 / Windows 11>
Node.js: <v20.x.x> ✓ | ✗ (need ≥ v18)
npm: <10.x.x> ✓ | ✗
lark-cli: <v1.0.4 installed> | NOT INSTALLED
auth status: <logged in as [email protected]> | NOT AUTHENTICATED
Next step recommendation:
→ If Node.js missing: Install Node.js first from https://nodejs.org, then re-run.
→ If lark-cli missing: Proceed to install — run: node scripts/install.js
→ If lark-cli installed but not authenticated: run: node scripts/config.js
→ If fully authenticated: run: node scripts/verify.js
`);
} else {
console.log(`=== 飞书 CLI 安装向导 — 第 0 步:环境检测 ===
安装 lark-cli 之前,先检测用户的环境并报告结果。
逐一运行以下命令并清晰报告结果。
── 检测步骤 ──────────────────────────────────────────────────
检查 1 — 操作系统
uname -s 2>/dev/null || systeminfo | findstr "OS Name"
→ 报告:macOS / Linux / Windows
检查 2 — Node.js 和 npm 版本
node --version
npm --version
→ 需要:Node.js ≥ 18,且已安装 npm
→ 如果缺失:告知用户前往 https://nodejs.org 安装
检查 3 — lark-cli 安装状态
lark-cli --version 2>/dev/null || echo "NOT_INSTALLED"
→ 如显示版本(如 v1.0.4):已安装 — 跳至检查 4
→ 如显示 NOT_INSTALLED:需要安装 — 继续运行 install.js
检查 4 — lark-cli 认证状态(仅在已安装时运行)
lark-cli auth status 2>/dev/null || echo "NOT_AUTHENTICATED"
→ 如显示用户信息:已认证 — 可直接运行 verify.js
→ 如显示 NOT_AUTHENTICATED:需要配置和登录 — 继续运行 config.js
── 报告格式 ──────────────────────────────────────────────────
完成所有检查后,输出汇总:
环境检测报告:
操作系统: <macOS 14.x / Ubuntu 22.04 / Windows 11>
Node.js: <v20.x.x> ✓ | ✗(需要 ≥ v18)
npm: <10.x.x> ✓ | ✗
lark-cli: <v1.0.4 已安装> | 未安装
认证状态: <已登录 [email protected]> | 未认证
下一步建议:
→ Node.js 缺失:先去 https://nodejs.org 安装 Node.js,完成后重新运行本检测
→ lark-cli 未安装:继续安装 — 运行:node scripts/install.js
→ 已安装但未认证:运行:node scripts/config.js
→ 已完整认证:运行:node scripts/verify.js
`);
}
FILE:scripts/config.js
#!/usr/bin/env node
/**
* feishu-cli-setup — config.js
* PROMPT GENERATOR ONLY — no outbound network requests.
* Outputs an agent prompt to guide lark-cli app credential configuration.
*
* CRITICAL: lark-cli config init --new runs interactively and outputs a browser URL.
* The agent must extract this URL and present it to the user.
*
* Usage:
* node scripts/config.js
* node scripts/config.js --lang en
* node scripts/config.js --existing # user already has a Feishu app
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
const existing = args.includes('--existing');
if (lang === 'en') {
console.log(`=== Feishu CLI Setup — Step 2: Configure App Credentials ===
This step creates a Feishu/Lark app and links its credentials to lark-cli.
'This requires a browser step — extract the URL from CLI output and send it to the user.'
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
AGENT INSTRUCTIONS: Read carefully before running
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
User has an existing Feishu app
Run the standard config init (interactive mode):
lark-cli config init
The CLI will prompt for:
1. App ID → user pastes their LARK_APP_ID
2. App Secret → user pastes their LARK_APP_SECRET
Tell the user: "Please paste your Feishu App ID and App Secret.
You can find these at: https://open.feishu.cn/app → select your app → Credentials & Basic Info"
After entering credentials, the CLI saves them to the OS keychain.
` : `SCENARIO: New Feishu app creation (most users)
STEP 1 — Run config init in the background (non-blocking)
Run this command and capture its output:
lark-cli config init --new
⚠️ IMPORTANT: This command outputs a URL like:
"Please open the following URL in your browser: https://open.feishu.cn/..."
DO NOT wait for the command to complete before acting.
Extract the URL immediately from the output.
STEP 2 — Send the URL to the user
Tell the user:
"Please open this URL in your browser to create a Feishu app:
<extracted URL>
The browser will guide you through creating an app on the Feishu Open Platform.
This is a one-time setup. Come back and let me know when you've completed it."
STEP 3 — Wait for user confirmation
Wait until the user says they've finished the browser setup.
The CLI command will exit automatically once setup is complete.
If it doesn't exit: run lark-cli config init (without --new) to enter credentials manually.
`
STEP 4 — Verify configuration
lark-cli config show 2>/dev/null || lark-cli auth status
→ Should show app ID (not empty)
→ If shows "no config": repeat Step 1-3
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TROUBLESHOOTING
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Problem: No URL appears in output
→ Run without --new and enter App ID/Secret manually: lark-cli config init
→ Get credentials from: https://open.feishu.cn/app
Problem: URL expired (>10 minutes)
→ Re-run: lark-cli config init --new (generates a fresh URL)
Problem: "permission denied" or keychain error
→ macOS: grant terminal keychain access in System Preferences → Privacy & Security
→ Linux: install libsecret: apt install libsecret-1-0 or set LARK_CONFIG_FILE env var
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RESULT REPORT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Report:
App configured: yes | no
App ID visible: yes (first 8 chars: cli_xxxx...) | no
Status: SUCCESS | FAILED | WAITING_FOR_USER
If SUCCESS: proceed to Step 3 — run: node scripts/auth.js
`);
} else {
console.log(`=== 飞书 CLI 安装向导 — 第 2 步:配置应用凭证 ===
本步骤创建飞书应用并将凭证与 lark-cli 关联。
'此步骤需要浏览器操作 — 从 CLI 输出中提取 URL 并发送给用户。'
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Agent 操作说明:执行前请仔细阅读
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
//open.feishu.cn/app → 选择您的应用 → 凭证与基础信息"
输入凭证后,CLI 将其保存到系统钥匙串。
` : `情景:新建飞书应用(大多数用户)
步骤 1 — 在后台运行 config init(非阻塞)
运行此命令并捕获其输出:
lark-cli config init --new
⚠️ 重要:此命令会输出类似以下内容的 URL:
"Please open the following URL in your browser: https://open.feishu.cn/..."
不要等命令执行完再操作。
立即从输出中提取该 URL。
步骤 2 — 将 URL 发送给用户
告知用户:
"请在浏览器中打开这个链接来创建飞书应用:
<提取的 URL>
浏览器会引导您在飞书开放平台上创建应用。
这是一次性设置。完成后告诉我,我们继续下一步。"
步骤 3 — 等待用户确认
等待用户说已完成浏览器操作。
用户完成后,CLI 命令会自动退出。
如未自动退出:运行 lark-cli config init(不带 --new)手动输入凭证。
`
步骤 4 — 验证配置
lark-cli config show 2>/dev/null || lark-cli auth status
→ 应显示 App ID(非空)
→ 如显示 "no config":重复步骤 1-3
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
故障排查
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
问题:输出中没有 URL
→ 不带 --new 运行,手动输入 App ID/Secret:lark-cli config init
→ 凭证获取地址:https://open.feishu.cn/app
问题:URL 已过期(>10 分钟)
→ 重新运行:lark-cli config init --new(生成新 URL)
问题:"permission denied" 或钥匙串错误
→ macOS:在系统偏好设置 → 隐私与安全 中允许终端访问钥匙串
→ Linux:安装 libsecret:apt install libsecret-1-0,或设置 LARK_CONFIG_FILE 环境变量
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
结果报告
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
报告:
应用已配置:是 | 否
App ID 可见:是(前 8 位:cli_xxxx...)| 否
状态:SUCCESS | FAILED | WAITING_FOR_USER
如果 SUCCESS:进入第 3 步 — 运行:node scripts/auth.js
`);
}
FILE:scripts/install.js
#!/usr/bin/env node
/**
* feishu-cli-setup — install.js
* PROMPT GENERATOR ONLY — no outbound network requests.
* Outputs an agent prompt to install lark-cli via npm and load Agent Skills.
*
* Usage:
* node scripts/install.js
* node scripts/install.js --lang en
* node scripts/install.js --source npm # default
* node scripts/install.js --source source # build from source (requires Go 1.23+)
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
const srcIdx = args.indexOf('--source');
const source = srcIdx !== -1 ? args[srcIdx + 1] : 'npm';
if (lang === 'en') {
console.log(`=== Feishu CLI Setup — Step 1: Install ===
Install method: 'npm (recommended)'
Install lark-cli and load the 20 built-in Agent Skills.
Follow all steps in order. Report the output of each command.
── INSTALL STEPS ────────────────────────────────────────────
Build from Source
Requires: Go v1.23+, Python 3, git
STEP 1 — Clone and build
git clone https://github.com/larksuite/cli.git
cd cli
make install
→ On success: lark-cli --version should print a version number
→ Common errors:
- "go: command not found" → Install Go from https://go.dev/dl/ (v1.23+)
- "make: command not found" → Install build tools (brew install make / apt install make)
` : `
METHOD: npm (Recommended)
STEP 1 — Install the CLI globally
npm install -g @larksuite/cli
→ Installs lark-cli to your global npm prefix
→ Verify: lark-cli --version
→ Expected: v1.0.4 (or latest)
→ Common errors:
- "EACCES: permission denied" → Run: sudo npm install -g @larksuite/cli
Or fix npm permissions: https://docs.npmjs.com/resolving-eacces-permissions-errors
- "npm: command not found" → Install Node.js first: https://nodejs.org
- China mirror (slow npm): npm install -g @larksuite/cli --registry https://registry.npmmirror.com
`
STEP 2 — Load Agent Skills (required — do not skip)
npx skills add larksuite/cli -y -g
→ This installs 20 pre-built Agent Skills (lark-calendar, lark-im, lark-doc, etc.)
→ The -g flag installs globally so all agents can use them
→ Verify: npx skills list | grep lark
→ Expected: see lark-shared, lark-calendar, lark-im, lark-doc, and more
STEP 3 — Confirm installation
lark-cli --version
lark-cli --help | head -20
→ Both commands must succeed with exit code 0
── RESULT REPORT ────────────────────────────────────────────
Report back:
lark-cli version: <v1.0.4>
Skills loaded: <yes / no>
Install method: <npm / source>
Status: SUCCESS | FAILED
If FAILED: describe the exact error message and which step failed.
If SUCCESS: proceed to Step 2 — run: node scripts/config.js
`);
} else {
console.log(`=== 飞书 CLI 安装向导 — 第 1 步:安装 ===
安装方式:'npm 安装(推荐)'
安装 lark-cli 并加载 20 个内置 Agent Skills。
按顺序执行所有步骤,并报告每个命令的输出结果。
── 安装步骤 ──────────────────────────────────────────────────
//github.com/larksuite/cli.git
cd cli
make install
→ 成功后:lark-cli --version 应打印版本号
→ 常见错误:
- "go: command not found" → 从 https://go.dev/dl/ 安装 Go(v1.23+)
- "make: command not found" → 安装构建工具(brew install make / apt install make)
` : `
方式:npm 安装(推荐)
步骤 1 — 全局安装 CLI
npm install -g @larksuite/cli
→ 将 lark-cli 安装到全局 npm 前缀目录
→ 验证:lark-cli --version
→ 预期:v1.0.4(或最新版本)
→ 常见错误:
- "EACCES: permission denied" → 运行:sudo npm install -g @larksuite/cli
- "npm: command not found" → 先安装 Node.js:https://nodejs.org
- 国内镜像加速:npm install -g @larksuite/cli --registry https://registry.npmmirror.com
`
步骤 2 — 加载 Agent Skills(必须执行,不可跳过)
npx skills add larksuite/cli -y -g
→ 安装 20 个预置 Agent Skills(lark-calendar、lark-im、lark-doc 等)
→ -g 标志全局安装,所有 agent 均可使用
→ 验证:npx skills list | grep lark
→ 预期:看到 lark-shared、lark-calendar、lark-im、lark-doc 等
步骤 3 — 确认安装成功
lark-cli --version
lark-cli --help | head -20
→ 两个命令均须以退出码 0 成功
── 结果报告 ──────────────────────────────────────────────────
报告以下内容:
lark-cli 版本: <v1.0.4>
Skills 已加载: <是 / 否>
安装方式: <npm / 源码>
状态: SUCCESS | FAILED
如果 FAILED:描述具体报错信息和失败的步骤。
如果 SUCCESS:进入第 2 步 — 运行:node scripts/config.js
`);
}
FILE:scripts/learn.js
#!/usr/bin/env node
/**
* feishu-cli-setup — learn.js
* PROMPT GENERATOR ONLY — no outbound network requests.
* Outputs an agent prompt with a hands-on first-steps guide for lark-cli.
* Teaches users the three-layer command system through real examples.
*
* Usage:
* node scripts/learn.js
* node scripts/learn.js --lang en
* node scripts/learn.js --domain calendar # focus on one domain
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
const domainIdx = args.indexOf('--domain');
const domain = domainIdx !== -1 ? args[domainIdx + 1] : null;
if (lang === 'en') {
console.log(`=== Feishu CLI Setup — First Steps Guide ===
Guide the user through their first lark-cli commands.
Use --dry-run for write operations so the user can preview safely.
Explain each command before running it.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
UNDERSTANDING THE THREE-LAYER SYSTEM
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lark-cli has three ways to run commands:
Layer 1 — Shortcuts (+prefix): easiest, AI-friendly, smart defaults
lark-cli calendar +agenda
Layer 2 — API Commands (auto-generated): precise, maps to platform API
lark-cli calendar calendars list
Layer 3 — Raw API (full coverage): any of 2500+ Lark APIs
lark-cli api GET /open-apis/calendar/v4/calendars
Start with Layer 1 (shortcuts). Move to Layer 2/3 for advanced needs.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
${domain.toUpperCase()` : 'HANDS-ON TOUR — 5 DOMAINS'}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
00" --end "2026-04-06T11:00" --dry-run
` : ''find a chat by name
lark-cli im +chats-list | grep "your-chat-name"
` : ''''''''
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
USEFUL FLAGS TO KNOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
--dry-run Preview the request without executing it (always safe)
--format table Human-readable table output
--format json Full JSON response (for agents/scripting)
--format csv CSV output (for spreadsheets)
--page-all Auto-paginate through all results
--as user Act as your user identity (default)
--as bot Act as the bot identity
SCHEMA INSPECTION (when you don't know the params):
lark-cli schema # list all services
lark-cli schema calendar.events.create # see params for this endpoint
lark-cli schema im.messages.delete
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
AGENT GUIDANCE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Ask the user what they'd like to do. Suggested prompts to offer:
• "Show me today's calendar agenda"
• "Send a test message to a chat"
• "Create a task for me"
• "Search my documents for ..."
• "Find a contact by name"
For any domain the user wants, use lark-cli <service> --help to discover commands.
`);
} else {
console.log(`=== 飞书 CLI 安装向导 — 上手第一步指南 ===
引导用户运行他们的第一批 lark-cli 命令。
对写入操作使用 --dry-run,让用户安全预览效果。
运行每条命令之前先向用户解释它的作用。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
了解三层命令体系
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lark-cli 有三种使用方式:
第一层 — 快捷命令(+ 前缀):最简单,AI 友好,智能默认值
lark-cli calendar +agenda
第二层 — API 命令(自动生成):精确,与平台 API 一一对应
lark-cli calendar calendars list
第三层 — 原始 API(完整覆盖):可调用 2500+ 飞书 API
lark-cli api GET /open-apis/calendar/v4/calendars
新手从第一层(快捷命令)开始,进阶需求再用第二/三层。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
domain ? `专注域:${domain.toUpperCase()` : '上手体验 — 5 个常用域'}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
00" --end "2026-04-06T11:00" --dry-run
` : ''''''''''
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
常用标志
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
--dry-run 预览请求,不实际执行(始终安全)
--format table 人类可读的表格输出
--format json 完整 JSON 响应(适合脚本/agent)
--format csv CSV 输出(适合导入表格)
--page-all 自动翻页,获取全部结果
--as user 以用户身份执行(默认)
--as bot 以机器人身份执行
Schema 查询(不确定参数时使用):
lark-cli schema # 列出所有服务
lark-cli schema calendar.events.create # 查看此接口的参数
lark-cli schema im.messages.delete
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Agent 引导提示
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
询问用户想要做什么。可主动提供以下选项:
• "查看今天的日历日程"
• "向某个群发送一条测试消息"
• "为我创建一个任务"
• "搜索我的文档"
• "按姓名查找一个联系人"
对于用户感兴趣的任何域,使用 lark-cli <service> --help 发现更多命令。
`);
}
FILE:scripts/setup.js
#!/usr/bin/env node
/**
* feishu-cli-setup — setup.js
* PROMPT GENERATOR ONLY — no outbound network requests.
* Full guided pipeline: outputs a complete agent prompt covering all 4 steps
* of lark-cli installation (check → install → config → auth → verify).
*
* The agent reads this prompt and executes each step in sequence, handling
* the browser OAuth flow by extracting URLs and sending them to the user.
*
* Usage:
* node scripts/setup.js
* node scripts/setup.js --lang en
* node scripts/setup.js --resume config # start from a specific step
* node scripts/setup.js --existing-app # user already has a Feishu app
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
const resumeIdx = args.indexOf('--resume');
const resume = resumeIdx !== -1 ? args[resumeIdx + 1] : null;
const existingApp = args.includes('--existing-app');
const steps = ['check', 'install', 'config', 'auth', 'verify'];
const startStep = resume ? steps.indexOf(resume) : 0;
const activeSteps = steps.slice(Math.max(0, startStep));
if (lang === 'en') {
console.log(`=== Feishu CLI Setup — Full Guided Installation ===
Source: https://github.com/larksuite/cli
Version: lark-cli v1.0.4 (latest)
Step ${startStep + 1 (resume)` : 'Starting from: Step 1 (environment check)'}
existing Feishu app (skip new app creation)' : 'App mode: create new Feishu app'
You are an AI agent (Claude / Manus / OpenClaw) helping a user install lark-cli.
Follow all steps in order. Do not skip steps. Pause at browser steps and wait for user confirmation.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
BEFORE YOU START — AGENT RULES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Run commands one at a time and report results before proceeding.
2. When a command outputs a URL (auth or config), extract it and send it to the user immediately.
3. Wait for user confirmation after any browser step before continuing.
4. Use --dry-run for any command that modifies data.
5. If a step fails, diagnose the error before retrying.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
node --version # Must be ≥ v18
npm --version # Must be installed
lark-cli --version 2>/dev/null || echo "NOT_INSTALLED"
lark-cli auth status 2>/dev/null || echo "NOT_AUTHENTICATED"
Decision tree:
→ Node.js missing: stop and tell user to install from https://nodejs.org
→ lark-cli missing: proceed to Step 2
→ lark-cli installed but not authenticated: skip to Step 3
→ lark-cli installed and authenticated: skip to Step 5
` : ''sudo npm install -g @larksuite/cli
# China mirror: npm install -g @larksuite/cli --registry https://registry.npmmirror.com
# Verify installation
lark-cli --version # Expected: v1.0.4
# Load 20 Agent Skills (required)
npx skills add larksuite/cli -y -g
# Verify skills loaded
npx skills list | grep lark
Only proceed to Step 3 when lark-cli --version succeeds.
` : ''lark-cli config init
→ Enter App ID and App Secret when prompted.
→ Tell user: "Find your credentials at https://open.feishu.cn/app → your app → Credentials & Basic Info"
` : `Run in background and capture output:
lark-cli config init --new
⚠️ EXTRACT THE URL from the output immediately. It looks like:
"Please open the following URL in your browser: https://open.feishu.cn/..."
Send this message to the user:
"Please open this URL in your browser to create your Feishu app:
[paste URL here]
Complete the guided setup in the browser, then let me know when done."
Wait for user confirmation before proceeding.
`
Verify: lark-cli auth status (should show an app ID)
` : ''}lark-cli auth login --recommend --no-wait
⚠️ EXTRACT THE AUTHORIZATION URL from the output immediately. It looks like:
"Please open the following URL to authorize: https://accounts.feishu.cn/..."
Send this message to the user:
"Please open this URL to authorize lark-cli:
[paste URL here]
Log in to Feishu and click Authorize. Come back and tell me when done."
After user confirms:
lark-cli auth status # Must show: logged in as <user>
If still not authenticated, run:
lark-cli auth login --device-code <DEVICE_CODE from earlier output>
` : ''✅ lark-cli is ready!
• Logged in as: <user>
• 20 Agent Skills available: lark-calendar, lark-im, lark-doc, lark-base, lark-task, lark-mail, and more
First commands to try:
lark-cli calendar +agenda # today's schedule
lark-cli task +list # your tasks
lark-cli im +messages-send --chat-id oc_xxx --text "Hello" --dry-run
Run: node scripts/learn.js for a full first-steps guide.
` : ''━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
QUICK REFERENCE (save for later)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lark-cli auth status # check login status
lark-cli auth login --domain calendar,im # add more scopes
lark-cli auth logout # sign out
lark-cli <service> --help # discover commands for any service
lark-cli schema # browse all API schemas
node scripts/learn.js # first-steps guide
`);
} else {
console.log(`=== 飞书 CLI 安装向导 — 完整引导安装 ===
来源:https://github.com/larksuite/cli
版本:lark-cli v1.0.4(最新)
resume ? `从第 ${startStep + 1 步(resume)继续` : '从第 1 步(环境检测)开始'}
'应用模式:新建飞书应用'
你是一个 AI Agent(Claude / Manus / OpenClaw),正在帮助用户安装 lark-cli。
按顺序执行所有步骤,不要跳步。遇到浏览器步骤时暂停并等待用户确认。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
开始前 — Agent 行为规则
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. 每次运行一条命令,报告结果后再继续。
2. 命令输出 URL(auth 或 config)时,立即提取并发送给用户。
3. 任何浏览器步骤后,等待用户确认后再继续。
4. 对修改数据的命令,使用 --dry-run 预览。
5. 步骤失败时,先诊断错误再重试。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
//nodejs.org 安装
→ lark-cli 未安装:进入第 2 步
→ 已安装但未认证:跳至第 3 步
→ 已安装且已认证:跳至第 5 步
` : ''//registry.npmmirror.com
# 验证安装
lark-cli --version # 预期:v1.0.4
# 加载 20 个 Agent Skills(必须执行)
npx skills add larksuite/cli -y -g
# 验证 Skills 已加载
npx skills list | grep lark
lark-cli --version 成功后才进入第 3 步。
` : ''//open.feishu.cn/app → 选择您的应用 → 凭证与基础信息"
` : `在后台运行并捕获输出:
lark-cli config init --new
⚠️ 立即从输出中提取 URL,格式类似:
"Please open the following URL in your browser: https://open.feishu.cn/..."
向用户发送此消息:
"请在浏览器中打开这个链接,创建您的飞书应用:
[粘贴 URL]
在浏览器中完成引导设置后,回来告诉我。"
等待用户确认后再继续。
`
验证:lark-cli auth status(应显示 App ID)
` : ''}https://accounts.feishu.cn/..."
向用户发送此消息:
"请在浏览器中打开这个链接来授权 lark-cli:
[粘贴 URL]
登录飞书后点击"授权"。完成后告诉我。"
用户确认后:
lark-cli auth status # 必须显示:已登录为 <用户>
如仍未认证,运行:
lark-cli auth login --device-code <上面输出中的 DEVICE_CODE>
` : ''''━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
快速参考(保存备用)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lark-cli auth status # 检查登录状态
lark-cli auth login --domain calendar,im # 添加更多 scope
lark-cli auth logout # 退出登录
lark-cli <service> --help # 发现任意服务的命令
lark-cli schema # 浏览所有 API Schema
node scripts/learn.js # 上手第一步指南
`);
}
FILE:scripts/verify.js
#!/usr/bin/env node
/**
* feishu-cli-setup — verify.js
* PROMPT GENERATOR ONLY — no outbound network requests.
* Outputs an agent prompt to verify lark-cli setup is complete and summarize
* which of the 20 Agent Skills are now usable.
*
* Usage:
* node scripts/verify.js
* node scripts/verify.js --lang en
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
if (lang === 'en') {
console.log(`=== Feishu CLI Setup — Step 4: Verify & Summary ===
Verify the complete lark-cli installation and summarize what the user can now do.
Run all checks and present results in a user-friendly summary.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
VERIFICATION CHECKS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHECK 1 — CLI version
lark-cli --version
→ Expected: v1.0.4 (or higher)
→ ✓ if version string printed ✗ if command not found
CHECK 2 — Authentication status
lark-cli auth status
→ Expected: shows user name/email and list of granted scopes
→ ✓ if authenticated ✗ if "not logged in"
CHECK 3 — List all authenticated identities
lark-cli auth list
→ Shows all users/bots available (user identity and/or bot identity)
CHECK 4 — Quick smoke test (calendar agenda)
lark-cli calendar +agenda --dry-run 2>/dev/null || lark-cli auth check calendar:calendar:readonly
→ If dry-run succeeds or auth check exits 0: calendar scope is active
→ This confirms the CLI can actually reach the Feishu API
CHECK 5 — Available Agent Skills
npx skills list 2>/dev/null | grep lark || echo "skills not listed"
→ Count how many lark-* skills appear
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PRESENT THE SUMMARY TO THE USER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
After running the checks, present this friendly summary to the user:
---
✅ lark-cli is set up and ready!
📋 Status:
• Version: <version>
• Logged in as: <name / email>
• Scopes granted: <N> (covers <domains list>)
🤖 20 Agent Skills now available:
Core:
• lark-shared — auth, config, identity switching (always active)
Communication:
• lark-im — send/reply messages, manage group chats
• lark-mail — browse, search, send/reply emails
Productivity:
• lark-calendar — view agenda, create events, check availability
• lark-task — create tasks, subtasks, set reminders
• lark-doc — create, read, update documents (Markdown)
• lark-drive — upload/download files, manage permissions
• lark-sheets — read/write/search spreadsheets
• lark-base — tables, records, views, dashboards
• lark-wiki — knowledge spaces and nodes
People:
• lark-contact — search users by name/email/phone
Meetings:
• lark-vc — meeting records and minutes
• lark-minutes — meeting AI summaries, todos, transcripts
Advanced:
• lark-event — real-time WebSocket event subscriptions
• lark-whiteboard — whiteboard/chart rendering
• lark-openapi-explorer — browse 2500+ Lark APIs
• lark-skill-maker — create custom skills
Workflows:
• lark-workflow-meeting-summary — structured meeting reports
• lark-workflow-standup-report — standup summaries
Approval:
• lark-approval — query, approve/reject tasks and instances
💡 Try these first commands:
lark-cli calendar +agenda # today's calendar
lark-cli im +messages-send --chat-id oc_xxx --text "Hello" --dry-run
lark-cli task +list # your tasks
🔐 Security reminder:
• Use --dry-run before commands that modify data
• Only add the Feishu bot to private chats, not public groups
• Run lark-cli auth scopes to review granted permissions
Run: node scripts/learn.js for a hands-on first-steps guide.
---
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RESULT REPORT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Report:
CLI installed: ✓ <version> | ✗
Authenticated: ✓ <user> | ✗
Smoke test: ✓ | ✗
Skills available: <N>/20
Overall: FULLY_READY | PARTIAL | FAILED
If FAILED: identify which check failed and direct user back to the appropriate step.
If FULLY_READY: suggest running node scripts/learn.js for first commands.
`);
} else {
console.log(`=== 飞书 CLI 安装向导 — 第 4 步:验证与总结 ===
验证 lark-cli 安装是否完整,并向用户总结现在可以做什么。
运行所有检查,以友好方式呈现结果。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
验证检查
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
检查 1 — CLI 版本
lark-cli --version
→ 预期:v1.0.4(或更高)
→ ✓ 打印版本号 ✗ 命令未找到
检查 2 — 认证状态
lark-cli auth status
→ 预期:显示用户姓名/邮箱及已授权的 scope 列表
→ ✓ 已认证 ✗ "未登录"
检查 3 — 列出所有认证身份
lark-cli auth list
→ 显示所有可用的用户/机器人身份
检查 4 — 快速冒烟测试(日历日程)
lark-cli calendar +agenda --dry-run 2>/dev/null || lark-cli auth check calendar:calendar:readonly
→ 如 dry-run 成功或 auth check 退出码为 0:日历 scope 已激活
→ 确认 CLI 可以实际访问飞书 API
检查 5 — 可用 Agent Skills
npx skills list 2>/dev/null | grep lark || echo "skills not listed"
→ 统计出现了多少个 lark-* 技能
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
向用户展示安装完成总结
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
完成检查后,向用户展示以下友好摘要:
---
✅ lark-cli 已准备就绪!
📋 状态:
• 版本:<版本号>
• 已登录为:<姓名 / 邮箱>
• 已授权 scope:<N> 个(覆盖 <域列表>)
🤖 现已可用的 20 个 Agent Skills:
核心:
• lark-shared — 认证、配置、身份切换(始终激活)
沟通:
• lark-im — 发送/回复消息,管理群聊
• lark-mail — 浏览、搜索、发送/回复邮件
效率:
• lark-calendar — 查看日程、创建活动、检查空闲时间
• lark-task — 创建任务、子任务、设置提醒
• lark-doc — 创建、阅读、更新文档(Markdown)
• lark-drive — 上传/下载文件、管理权限
• lark-sheets — 读写搜索电子表格
• lark-base — 多维表格、记录、视图、仪表盘
• lark-wiki — 知识空间和节点
联系人:
• lark-contact — 按姓名/邮箱/手机搜索用户
会议:
• lark-vc — 会议记录和纪要
• lark-minutes — 会议 AI 摘要、待办、逐字稿
进阶:
• lark-event — 实时 WebSocket 事件订阅
• lark-whiteboard — 白板/图表渲染
• lark-openapi-explorer — 浏览 2500+ 飞书 API
• lark-skill-maker — 创建自定义 skill
工作流:
• lark-workflow-meeting-summary — 结构化会议报告
• lark-workflow-standup-report — 每日站会摘要
审批:
• lark-approval — 查询、审批/拒绝任务和审批实例
💡 推荐先试这几个命令:
lark-cli calendar +agenda # 今日日程
lark-cli im +messages-send --chat-id oc_xxx --text "你好" --dry-run
lark-cli task +list # 我的任务
🔐 安全提醒:
• 对有副作用的命令先用 --dry-run 预览
• 飞书机器人只加入私聊,不要加入公开群
• 运行 lark-cli auth scopes 查看已授权权限
运行:node scripts/learn.js 查看上手第一步指南。
---
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
结果报告
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
报告:
CLI 已安装: ✓ <版本> | ✗
已认证: ✓ <用户> | ✗
冒烟测试: ✓ | ✗
Skills 可用: <N>/20
总体状态: FULLY_READY | PARTIAL | FAILED
如果 FAILED:找出哪项检查失败,引导用户回到对应步骤。
如果 FULLY_READY:建议运行 node scripts/learn.js 开始使用。
`);
}
Daily profession-targeted news from X (Twitter), Google News, Grok, and global media. Supports bilingual (zh/en), multi-profession subscriptions, keyword fil...
---
name: Career News
description: |
Daily profession-targeted news from X (Twitter), Google News, Grok, and global media.
Supports bilingual (zh/en), multi-profession subscriptions, keyword filters, and scheduled morning push.
Users can subscribe to news from multiple professions beyond their primary one.
keywords:
- news
- career
- profession
- daily
- x
- google
- grok
- industry
- multi-profession
- subscription
- morning-brief
- 职业新闻
- 早报
- 行业动态
- 多职业订阅
metadata:
openclaw:
runtime:
node: ">=18"
---
# Career News
Aggregates the most relevant industry news for professionals every morning from **X (Twitter), Google News, Grok, and global media**. Each user receives a concise, high-value brief tailored to their profession(s).
Users can subscribe to news from **multiple professions** — a developer who also wants investor and marketing news gets three separate briefs every morning.
## Supported Professions
| Slug | Chinese | English |
|------|---------|---------|
| `doctor` | 医生/医疗从业者 | Doctor / Healthcare |
| `lawyer` | 律师/法律从业者 | Lawyer / Legal |
| `engineer` | 工程师(泛) | Engineer |
| `developer` | 软件开发者 | Software Developer |
| `designer` | 设计师 | Designer |
| `product-manager` | 产品经理 | Product Manager |
| `investor` | 投资人/金融从业者 | Investor / Finance |
| `teacher` | 教师/教育从业者 | Teacher / Educator |
| `journalist` | 记者/媒体从业者 | Journalist / Media |
| `entrepreneur` | 创业者 | Entrepreneur |
| `researcher` | 研究员/学者 | Researcher |
| `marketing` | 市场营销 | Marketing |
| `hr` | 人力资源 | HR |
| `sales` | 销售 | Sales |
## Scripts
| Script | Function |
|--------|----------|
| `scripts/morning-push.js` | Daily 7:00 AM push — generates one brief per profession per user |
| `scripts/news-query.js` | Instant query for any profession (or all of a user's subscriptions) |
| `scripts/register.js` | Register / view / list users |
| `scripts/manage-professions.js` | Add / remove / list extra profession subscriptions |
| `scripts/push-toggle.js` | Enable / disable push for a user |
## Usage
```bash
# Register a user
node scripts/register.js alice --profession developer --lang zh
node scripts/register.js bob --profession investor --lang en
# Manage multi-profession subscriptions
node scripts/manage-professions.js --userId alice --add investor
node scripts/manage-professions.js --userId alice --add marketing
node scripts/manage-professions.js --userId alice --list
node scripts/manage-professions.js --userId alice --remove marketing
node scripts/manage-professions.js --userId alice --clear # remove all extras
node scripts/manage-professions.js --suggest alice # AI suggests new subscriptions
# Instant query
node scripts/news-query.js developer
node scripts/news-query.js investor --lang en --region us
node scripts/news-query.js --userId alice # query all of alice's professions
node scripts/news-query.js --userId alice --all-professions
# Trigger push manually
node scripts/morning-push.js
node scripts/morning-push.js --user alice
node scripts/morning-push.js --profession doctor # override profession
# Toggle push
node scripts/push-toggle.js --userId alice # toggle on/off
node scripts/push-toggle.js # show cron command
```
## Cron Setup
```bash
openclaw cron add "0 7 * * *" "cd /path/to/career-news && node scripts/morning-push.js"
```
## Multi-Profession Subscription
Each user has one **primary profession** and any number of **extra profession subscriptions**:
- Morning push generates **one brief per profession**, primary first
- `manage-professions.js --suggest` asks the AI to recommend complementary professions based on career overlaps, knowledge amplification, and adjacent fields
- Extra subscriptions are preserved when re-registering
Example — a developer who adds investor and marketing:
```
╔══ alice · 今日 3 个职业早报 · 2026年4月4日 ══╗
[Career News | developer ✦ primary | ...]
────────────────────────────────────────────────────────────
[Career News | investor ★ extra subscription | ...]
────────────────────────────────────────────────────────────
[Career News | marketing ★ extra subscription | ...]
╚══ End of alice's 3 briefs. ══╝
```
## News Source Strategy
Push prompts instruct the agent to search in this order:
1. **X (Twitter)** — latest high-engagement posts matching profession keywords
2. **Google News** — past 24 hours in this profession's field
3. **Grok** — AI-synthesized summary of today's top developments
4. **Global media** — Bloomberg, Reuters, TechCrunch, Nature, etc. matched to profession
## User Data Schema
`data/users/<userId>.json`:
```json
{
"userId": "alice",
"profession": "developer",
"extraProfessions": ["investor", "marketing"],
"language": "zh",
"region": "cn",
"keywords": ["AI", "开源"],
"pushEnabled": true,
"createdAt": "2026-04-04T00:00:00.000Z",
"updatedAt": "2026-04-04T00:00:00.000Z"
}
```
---
*Version: 1.1.0 · Updated: 2026-04-04*
FILE:_meta.json
{
"ownerId": "kn79bebfnwg15sb0g7cj5z5nyd83gxh0",
"slug": "career-news",
"version": "1.0.0",
"publishedAt": null
}
FILE:package.json
{
"name": "career-news",
"version": "1.1.0",
"description": "Daily profession-targeted news from X, Google, Grok, and global media. Supports multi-profession subscriptions.",
"main": "scripts/morning-push.js",
"scripts": {
"push": "node scripts/morning-push.js",
"query": "node scripts/news-query.js",
"register": "node scripts/register.js",
"manage": "node scripts/manage-professions.js",
"toggle": "node scripts/push-toggle.js"
},
"engines": {
"node": ">=18"
}
}
FILE:scripts/manage-professions.js
#!/usr/bin/env node
/**
* career-news — manage-professions.js
* Add or remove extra profession news subscriptions for a user.
* Users can subscribe to news from multiple professions beyond their primary one.
*
* Usage:
* node scripts/manage-professions.js --userId <id> --add <profession>
* node scripts/manage-professions.js --userId <id> --remove <profession>
* node scripts/manage-professions.js --userId <id> --list
* node scripts/manage-professions.js --userId <id> --clear # remove all extras
* node scripts/manage-professions.js --suggest <userId> # AI prompt: suggest professions for this user
*
* Professions: doctor, lawyer, engineer, developer, designer, product-manager,
* investor, teacher, journalist, entrepreneur, researcher, marketing, hr, sales
*
* Example — a developer who also wants investor and marketing news:
* node scripts/manage-professions.js --userId alice --add investor
* node scripts/manage-professions.js --userId alice --add marketing
* node scripts/manage-professions.js --userId alice --list
* → Primary: developer | Subscribed: investor, marketing
* → Morning push will deliver 3 separate briefs: developer · investor · marketing
*/
const fs = require('fs');
const path = require('path');
const USERS_DIR = path.join(__dirname, '..', 'data', 'users');
const VALID_PROFESSIONS = new Set([
'doctor','lawyer','engineer','developer','designer','product-manager',
'investor','teacher','journalist','entrepreneur','researcher','marketing','hr','sales'
]);
const PROFESSION_ZH_MAP = {
'医生':'doctor','医疗':'doctor','律师':'lawyer','法律':'lawyer',
'工程师':'engineer','开发者':'developer','程序员':'developer','开发':'developer',
'设计师':'designer','设计':'designer','产品经理':'product-manager','产品':'product-manager',
'投资人':'investor','投资':'investor','金融':'investor','教师':'teacher','教育':'teacher',
'记者':'journalist','媒体':'journalist','创业者':'entrepreneur','创业':'entrepreneur',
'研究员':'researcher','学者':'researcher','营销':'marketing','市场':'marketing',
'人力资源':'hr','销售':'sales'
};
const PROFESSION_LABELS = {
doctor: '医生/医疗', lawyer: '律师/法律', engineer: '工程师',
developer: '软件开发者', designer: '设计师', 'product-manager': '产品经理',
investor: '投资/金融', teacher: '教师/教育', journalist: '记者/媒体',
entrepreneur: '创业者', researcher: '研究员', marketing: '市场营销',
hr: '人力资源', sales: '销售'
};
function resolveProf(raw) {
if (!raw) return null;
const direct = PROFESSION_ZH_MAP[raw] || (VALID_PROFESSIONS.has(raw) ? raw : null);
if (direct) return direct;
for (const [key, val] of Object.entries(PROFESSION_ZH_MAP)) {
if (raw.includes(key)) return val;
}
return null;
}
function loadUser(userId) {
const safeId = userId.replace(/[^a-zA-Z0-9_-]/g, '');
const fp = path.join(USERS_DIR, `safeId.json`);
if (!fs.existsSync(fp)) {
console.error(`User "userId" not found. Register first: node scripts/register.js userId --profession <prof>`);
process.exit(1);
}
return { fp, user: JSON.parse(fs.readFileSync(fp, 'utf8')) };
}
function saveUser(fp, user) {
user.updatedAt = new Date().toISOString();
fs.writeFileSync(fp, JSON.stringify(user, null, 2));
}
function printSubscriptions(user) {
const extras = user.extraProfessions || [];
const allProfs = [user.profession, ...extras].filter(Boolean);
console.log(`\nUser: user.userId`);
console.log('─'.repeat(50));
console.log(` Primary profession : user.profession (PROFESSION_LABELS[user.profession] || user.profession)`);
if (extras.length === 0) {
console.log(` Extra subscriptions: (none)`);
} else {
extras.forEach((p, i) => {
console.log(` Extra #i+1 : p (PROFESSION_LABELS[p] || p)`);
});
}
console.log('─'.repeat(50));
console.log(` Total briefs per morning push: allProfs.length`);
console.log('');
const available = [...VALID_PROFESSIONS].filter(p => !allProfs.includes(p));
if (available.length > 0) {
console.log(` Available to add: available.join(', ')`);
console.log(` → node scripts/manage-professions.js --userId user.userId --add <profession>`);
}
}
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage:');
console.error(' node scripts/manage-professions.js --userId <id> --add <profession>');
console.error(' node scripts/manage-professions.js --userId <id> --remove <profession>');
console.error(' node scripts/manage-professions.js --userId <id> --list');
console.error(' node scripts/manage-professions.js --userId <id> --clear');
console.error(' node scripts/manage-professions.js --suggest <userId>');
console.error('');
console.error('Professions: doctor, lawyer, engineer, developer, designer, product-manager,');
console.error(' investor, teacher, journalist, entrepreneur, researcher, marketing, hr, sales');
process.exit(1);
}
// --suggest <userId> — generate an AI prompt recommending professions
const suggestIdx = args.indexOf('--suggest');
if (suggestIdx !== -1) {
const suggestId = args[suggestIdx + 1];
if (!suggestId) { console.error('--suggest requires a userId'); process.exit(1); }
const { user } = loadUser(suggestId);
const extras = user.extraProfessions || [];
const allProfs = [user.profession, ...extras].filter(Boolean);
const available = [...VALID_PROFESSIONS].filter(p => !allProfs.includes(p));
const lang = user.language || 'zh';
if (lang === 'en') {
console.log(`[Career News — Profession Suggestion | user: user.userId]
This user's current profession subscriptions: allProfs.join(', ')
Primary profession: user.profession
Region: user.region || 'cn'
Available professions to add: available.join(', ')
Please suggest 2–3 additional profession news subscriptions that would be most valuable for this user.
Consider:
- Career path overlaps (e.g. a developer who becomes an entrepreneur)
- Knowledge that amplifies their primary profession (e.g. a developer + investor = understands tech funding)
- Adjacent fields they likely monitor (e.g. a designer + product-manager = product design)
- Cross-disciplinary trends relevant to their region
For each suggestion:
1. Profession slug
2. Why it complements their primary profession (1–2 sentences)
3. Example news topics they would gain
Then ask the user: "Would you like me to add any of these? Just tell me which ones."
If they confirm, run:
node scripts/manage-professions.js --userId user.userId --add <profession>`);
} else {
console.log(`[职业新闻 — 职业订阅推荐 | 用户:user.userId]
该用户当前已订阅的职业:allProfs.join('、')
主职业:user.profession(PROFESSION_LABELS[user.profession] || user.profession)
地区:user.region || 'cn'
可添加的职业:available.map(p => `${p(PROFESSION_LABELS[p])`).join('、')}
请为该用户推荐 2~3 个额外职业新闻订阅,要求对其最有价值。
推荐考量维度:
- 职业发展路径重叠(如开发者 → 创业者)
- 能放大主职业价值的知识(如开发者 + 投资 = 理解科技融资)
- 日常本就会关注的领域(如设计师 + 产品经理 = 产品设计全局视野)
- 与其所在地区最相关的跨行业趋势
对每个推荐,给出:
1. 职业名称(slug)
2. 为什么与主职业互补(1~2句)
3. 会获得哪类新闻举例
然后问用户:"你想让我帮你加上哪几个?告诉我就好。"
用户确认后执行:
node scripts/manage-professions.js --userId user.userId --add <profession>`);
}
process.exit(0);
}
// All remaining commands need --userId
const userIdx = args.indexOf('--userId');
if (userIdx === -1) {
console.error('--userId is required. Usage: node scripts/manage-professions.js --userId <id> --add|--remove|--list|--clear <profession>');
process.exit(1);
}
const rawUserId = args[userIdx + 1] || '';
if (!rawUserId) { console.error('--userId requires a value'); process.exit(1); }
const { fp, user } = loadUser(rawUserId);
if (!user.extraProfessions) user.extraProfessions = [];
// --list
if (args.includes('--list')) {
printSubscriptions(user);
process.exit(0);
}
// --clear
if (args.includes('--clear')) {
const removed = [...user.extraProfessions];
user.extraProfessions = [];
saveUser(fp, user);
console.log(`✔ Cleared all extra subscriptions for "user.userId".`);
if (removed.length) console.log(` Removed: removed.join(', ')`);
console.log(` Primary profession remains: user.profession`);
process.exit(0);
}
// --add <profession>
const addIdx = args.indexOf('--add');
if (addIdx !== -1) {
const rawProf = args[addIdx + 1] || '';
if (!rawProf) { console.error('--add requires a profession name'); process.exit(1); }
const prof = resolveProf(rawProf);
if (!prof) {
console.error(`Unknown profession "rawProf".`);
console.error(`Valid: [...VALID_PROFESSIONS].join(', ')`);
process.exit(1);
}
const allProfs = [user.profession, ...user.extraProfessions].filter(Boolean);
if (allProfs.includes(prof)) {
console.log(`ℹ "prof" is already in user.userId's subscriptions.`);
printSubscriptions(user);
process.exit(0);
}
user.extraProfessions.push(prof);
saveUser(fp, user);
console.log(`✔ Added "prof" (PROFESSION_LABELS[prof] || prof) to user.userId's subscriptions.`);
printSubscriptions(user);
process.exit(0);
}
// --remove <profession>
const removeIdx = args.indexOf('--remove');
if (removeIdx !== -1) {
const rawProf = args[removeIdx + 1] || '';
if (!rawProf) { console.error('--remove requires a profession name'); process.exit(1); }
const prof = resolveProf(rawProf);
if (!prof) {
console.error(`Unknown profession "rawProf".`);
process.exit(1);
}
if (prof === user.profession) {
console.error(`Cannot remove primary profession "prof". To change it, re-register: node scripts/register.js user.userId --profession <new>`);
process.exit(1);
}
const before = user.extraProfessions.length;
user.extraProfessions = user.extraProfessions.filter(p => p !== prof);
if (user.extraProfessions.length === before) {
console.log(`ℹ "prof" was not in user.userId's extra subscriptions.`);
} else {
saveUser(fp, user);
console.log(`✔ Removed "prof" from user.userId's subscriptions.`);
}
printSubscriptions(user);
process.exit(0);
}
console.error('Unknown command. Use --add, --remove, --list, --clear, or --suggest.');
process.exit(1);
FILE:scripts/morning-push.js
#!/usr/bin/env node
/**
* career-news — morning-push.js
* 每日早 7:00 为所有注册用户生成职业新闻推送 prompt
*
* 用法:
* node scripts/morning-push.js # 全量推送
* node scripts/morning-push.js --user <userId> # 单用户测试
* node scripts/morning-push.js --profession <prof> # 覆盖职业
* node scripts/morning-push.js --dry-run # 只打印,不写文件
*/
const fs = require('fs');
const path = require('path');
const USERS_DIR = path.join(__dirname, '..', 'data', 'users');
const args = process.argv.slice(2);
const userIdx = args.indexOf('--user');
const targetUser = userIdx !== -1 ? args[userIdx + 1] : null;
const profIdx = args.indexOf('--profession');
const profOverride = profIdx !== -1 ? args[profIdx + 1] : null;
const dryRun = args.includes('--dry-run');
const now = new Date();
const dateStr_zh = `now.getFullYear()年now.getMonth()+1月now.getDate()日`;
const dateStr_en = now.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
// 职业关键词配置(用于指导搜索)
const PROFESSION_CONFIG = {
doctor: {
zh: { label: '医生/医疗从业者', keywords: ['医学研究', '临床指南', '医疗政策', '新药审批', '公共卫生'], sources: ['NEJM', 'Lancet', 'JAMA', '丁香园', '健康时报'] },
en: { label: 'Doctor / Healthcare', keywords: ['clinical research', 'FDA approvals', 'medical policy', 'public health', 'treatment guidelines'], sources: ['NEJM', 'Lancet', 'JAMA', 'Medscape', 'CDC'] }
},
lawyer: {
zh: { label: '律师/法律从业者', keywords: ['司法改革', '最高法判例', '商事仲裁', '合规监管', '法律科技'], sources: ['法制日报', '人民法院报', '中国法律评论'] },
en: { label: 'Lawyer / Legal', keywords: ['supreme court', 'regulatory changes', 'legal tech', 'compliance', 'litigation trends'], sources: ['Bloomberg Law', 'Reuters Legal', 'Law360', 'ABA Journal'] }
},
engineer: {
zh: { label: '工程师', keywords: ['工程技术', '制造业', '自动化', '基础设施', '行业标准'], sources: ['IEEE', '工程师网', '机械工业信息'] },
en: { label: 'Engineer', keywords: ['engineering innovation', 'manufacturing', 'automation', 'infrastructure', 'industry standards'], sources: ['IEEE Spectrum', 'Engineering News-Record', 'IndustryWeek'] }
},
developer: {
zh: { label: '软件开发者', keywords: ['编程语言', '开源项目', 'AI工具', '框架发布', '云原生'], sources: ['GitHub', 'InfoQ', 'V2EX', '掘金'] },
en: { label: 'Software Developer', keywords: ['programming', 'open source', 'AI tools', 'framework releases', 'cloud native'], sources: ['GitHub', 'Hacker News', 'TechCrunch', 'The Verge', 'DEV.to'] }
},
designer: {
zh: { label: '设计师', keywords: ['设计趋势', 'UI/UX', '品牌设计', '设计工具', '视觉创意'], sources: ['站酷', 'Behance', 'UI中国', 'UISDC'] },
en: { label: 'Designer', keywords: ['design trends', 'UI/UX', 'branding', 'design tools', 'visual creativity'], sources: ['Behance', 'Dribbble', 'Creative Bloq', 'Smashing Magazine'] }
},
'product-manager': {
zh: { label: '产品经理', keywords: ['产品设计', '用户体验', '增长策略', 'AI产品', '行业案例'], sources: ['人人都是产品经理', '产品壹佰', '36氪'] },
en: { label: 'Product Manager', keywords: ['product strategy', 'user experience', 'growth hacking', 'AI products', 'case studies'], sources: ['Product Hunt', 'Mind the Product', 'Lenny\'s Newsletter', 'First Round Review'] }
},
investor: {
zh: { label: '投资人/金融从业者', keywords: ['A股', '港股', '美股', '宏观经济', '投资策略', '并购动态'], sources: ['财新', '华尔街见闻', '彭博', '路透'] },
en: { label: 'Investor / Finance', keywords: ['markets', 'macro economy', 'investment strategy', 'M&A', 'earnings'], sources: ['Bloomberg', 'Reuters', 'Financial Times', 'WSJ', 'Seeking Alpha'] }
},
teacher: {
zh: { label: '教师/教育从业者', keywords: ['教育改革', '课程标准', '教育技术', '高考政策', '教师发展'], sources: ['中国教育报', '人民教育', '教育部官网'] },
en: { label: 'Teacher / Educator', keywords: ['education reform', 'curriculum', 'edtech', 'teaching methods', 'learning research'], sources: ['Education Week', 'EdSurge', 'ASCD', 'TES'] }
},
journalist: {
zh: { label: '记者/媒体从业者', keywords: ['新闻业动态', '媒体转型', '新闻自由', '报道伦理', '内容创作'], sources: ['中国新闻周刊', '澎湃', 'NiemanLab'] },
en: { label: 'Journalist / Media', keywords: ['journalism', 'media industry', 'press freedom', 'digital media', 'investigative reporting'], sources: ['NiemanLab', 'Columbia Journalism Review', 'Poynter', 'Reuters Institute'] }
},
entrepreneur: {
zh: { label: '创业者', keywords: ['创投融资', '创业政策', 'AI创业', '出海机会', '商业模式'], sources: ['36氪', '创业邦', '极客公园', 'TechCrunch中文版'] },
en: { label: 'Entrepreneur', keywords: ['startup funding', 'venture capital', 'AI startups', 'business models', 'founder stories'], sources: ['TechCrunch', 'Crunchbase', 'Y Combinator', 'First Round Review', 'Indie Hackers'] }
},
researcher: {
zh: { label: '研究员/学者', keywords: ['学术前沿', '科研政策', '论文发表', '科技突破', '基金申请'], sources: ['Nature', 'Science', '中国科学院', '国家自然科学基金'] },
en: { label: 'Researcher', keywords: ['research breakthroughs', 'academic publications', 'science policy', 'grant funding', 'peer review'], sources: ['Nature', 'Science', 'arXiv', 'Retraction Watch', 'Scholarly Kitchen'] }
},
marketing: {
zh: { label: '市场营销', keywords: ['营销趋势', '广告平台', '社交媒体运营', '品牌案例', '消费者洞察'], sources: ['营销界', '广告门', '数英网', '4A广告提案'] },
en: { label: 'Marketing', keywords: ['marketing trends', 'advertising platforms', 'social media', 'brand campaigns', 'consumer insights'], sources: ['Marketing Week', 'AdAge', 'Adweek', 'HubSpot Blog', 'MarketingProfs'] }
},
hr: {
zh: { label: '人力资源', keywords: ['人才管理', '招聘趋势', '劳动法规', '员工福利', '组织文化'], sources: ['中国人力资源网', 'HR369', '智联招聘研究院'] },
en: { label: 'HR', keywords: ['talent management', 'hiring trends', 'labor law', 'employee benefits', 'organizational culture'], sources: ['SHRM', 'HR Dive', 'Workable Blog', 'Josh Bersin'] }
},
sales: {
zh: { label: '销售', keywords: ['销售方法论', '客户成功', '销售工具', '市场动态', 'CRM趋势'], sources: ['销售与市场', 'B2B圈', '36氪企服'] },
en: { label: 'Sales', keywords: ['sales methodology', 'customer success', 'sales tools', 'CRM', 'revenue operations'], sources: ['Sales Hacker', 'HubSpot Sales Blog', 'Salesforce Blog', 'Gong.io'] }
}
};
// Load users
let users = [];
if (targetUser) {
const fp = path.join(USERS_DIR, `targetUser.replace(/[^a-zA-Z0-9_-]/g, '').json`);
if (!fs.existsSync(fp)) {
console.error(`User "targetUser" not found.`);
process.exit(1);
}
users = [JSON.parse(fs.readFileSync(fp, 'utf8'))];
} else {
if (!fs.existsSync(USERS_DIR)) {
console.log('No users registered yet.');
process.exit(0);
}
users = fs.readdirSync(USERS_DIR)
.filter(f => f.endsWith('.json'))
.map(f => JSON.parse(fs.readFileSync(path.join(USERS_DIR, f), 'utf8')))
.filter(u => u.pushEnabled !== false);
}
if (users.length === 0) {
console.log('No active users to push.');
process.exit(0);
}
// Helper: generate one profession brief block
function professionBlock(user, profession, lang, region, dateStr, extraKeywords, isExtra) {
const cfg = (PROFESSION_CONFIG[profession] || PROFESSION_CONFIG['developer'])[lang] || PROFESSION_CONFIG['developer']['en'];
const tag = isExtra ? ' ★ extra subscription' : ' ✦ primary';
if (lang === 'en') {
return `[Career News Morning Push | user: user.userId | profession: professiontag | lang: en | region: region | dateStr]
Please search and compile a morning news brief for this user.
Profession: cfg.label
Search keywords: cfg.keywords.join(', ')''
Priority sources: cfg.sources.join(', ')
Region focus: region.toUpperCase()
Search steps (execute ALL):
1. Search X (Twitter): "cfg.keywords[0] OR cfg.keywords[1]" — last 24h, min 100 likes/retweets
2. Google News: "cfg.label news dateStr" site:reuters.com OR site:bloomberg.com OR site:techcrunch.com
3. Ask Grok: "What are the 3 most important developments for cfg.label professionals today, dateStr?"
4. Scan priority sources: cfg.sources.slice(0, 3).join(', ') for top stories
Compile rules:
- Select exactly 5 stories: mix of breaking news, analysis, and one forward-looking item
- Each story: headline + 2-sentence summary + source + URL
- Flag stories from X with 🐦, Grok synthesis with 🤖, major media with 📰
- Do NOT include stories older than 48 hours
- Do NOT include opinion pieces unless author has direct field experience
Output format:
📰 Morning Brief · cfg.label · dateStr
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[source flag] Story 1 headline
→ Summary (2 sentences). Source · URL
[source flag] Story 2 headline
→ Summary (2 sentences). Source · URL
[source flag] Story 3 headline
→ Summary (2 sentences). Source · URL
[source flag] Story 4 headline
→ Summary (2 sentences). Source · URL
[source flag] Story 5 headline
→ Summary (2 sentences). Source · URL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 One sentence on the most important trend to watch today.`;
} else {
return `[职业新闻早报 | 用户:user.userId | 职业:profession' ✦ 主职业' | 语言:zh | 地区:region | dateStr]
请为该用户搜索并整合今日职业新闻早报。
职业:cfg.label
搜索关键词:cfg.keywords.join('、')''
优先信源:cfg.sources.join('、')
地区聚焦:region.toUpperCase()
搜索步骤(必须全部执行):
1. 搜索 X(Twitter):关键词「cfg.keywords[0]」「cfg.keywords[1]」— 过去 24 小时内,互动量 100+ 的帖子
2. Google 新闻:搜索「cfg.label 最新动态 dateStr」,优先 cfg.sources.slice(0,2).join('、') 等媒体
3. 询问 Grok:「dateStr,cfg.label最重要的3条行业进展是什么?」
4. 扫描优先信源:cfg.sources.slice(0, 3).join('、') 的今日头条
整合规则:
- 精选5条:包含突发、深度分析、至少1条前瞻性内容
- 每条:标题 + 2句摘要 + 来源 + 链接
- X 来源标注 🐦,Grok 综合标注 🤖,主流媒体标注 📰
- 不收录 48 小时以前的内容
- 不收录与职业无直接关联的内容
输出格式:
📰 职业早报 · cfg.label · dateStr
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[来源标注] 新闻标题 1
→ 摘要(2句)。来源 · 链接
[来源标注] 新闻标题 2
→ 摘要(2句)。来源 · 链接
[来源标注] 新闻标题 3
→ 摘要(2句)。来源 · 链接
[来源标注] 新闻标题 4
→ 摘要(2句)。来源 · 链接
[来源标注] 新闻标题 5
→ 摘要(2句)。来源 · 链接
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 今日最值得关注的一个趋势(一句话)。`;
}
}
users.forEach((user, i) => {
const lang = user.language || 'zh';
const region = user.region || (lang === 'zh' ? 'cn' : 'us');
const extraKeywords = (user.keywords || []).join(lang === 'en' ? ', ' : '、');
const dateStr = lang === 'en' ? dateStr_en : dateStr_zh;
// Build ordered profession list: primary first, then extras
const primaryProf = profOverride || user.profession || 'developer';
const extraProfs = profOverride ? [] : (user.extraProfessions || []);
const allProfs = [primaryProf, ...extraProfs];
if (i > 0) console.log('\n' + '═'.repeat(60) + '\n');
// Header when user has multiple subscriptions
if (allProfs.length > 1) {
if (lang === 'en') {
console.log(`╔══ user.userId · allProfs.length profession briefs · dateStr ══╗\n`);
} else {
console.log(`╔══ user.userId · 今日 allProfs.length 个职业早报 · dateStr ══╗\n`);
}
}
// One block per profession
allProfs.forEach((prof, j) => {
if (j > 0) console.log('\n' + '─'.repeat(60) + '\n');
const isExtra = j > 0;
console.log(professionBlock(user, prof, lang, region, dateStr, extraKeywords, isExtra));
});
// Tail hint when multiple professions
if (allProfs.length > 1) {
if (lang === 'en') {
console.log(`\n╚══ End of user.userId's allProfs.length briefs. To manage subscriptions: node scripts/manage-professions.js --userId user.userId --list ══╝`);
} else {
console.log(`\n╚══ user.userId 的 allProfs.length 份早报推送完毕。管理订阅:node scripts/manage-professions.js --userId user.userId --list ══╝`);
}
}
});
FILE:scripts/news-query.js
#!/usr/bin/env node
/**
* career-news — news-query.js
* Instant query for latest news by profession.
*
* Usage:
* node scripts/news-query.js <profession>
* node scripts/news-query.js developer
* node scripts/news-query.js investor --lang en --region us
* node scripts/news-query.js doctor --keywords "癌症研究,新药"
* node scripts/news-query.js --userId <id> # query all of user's subscribed professions
* node scripts/news-query.js --userId <id> --all-professions
*
* Professions: doctor, lawyer, engineer, developer, designer,
* product-manager, investor, teacher, journalist, entrepreneur,
* researcher, marketing, hr, sales
*/
const fs = require('fs');
const path = require('path');
const USERS_DIR = path.join(__dirname, '..', 'data', 'users');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const langArg = langIdx !== -1 ? args[langIdx + 1] : null;
const regionIdx = args.indexOf('--region');
const regionArg = regionIdx !== -1 ? args[regionIdx + 1] : null;
const kwIdx = args.indexOf('--keywords');
const kwArg = kwIdx !== -1 ? args[kwIdx + 1] : null;
const userIdx = args.indexOf('--userId');
const userIdArg = userIdx !== -1 ? args[userIdx + 1] : null;
const allProfsFlag = args.includes('--all-professions');
const rawProf = args.filter(a => !a.startsWith('--') && a !== langArg && a !== regionArg && a !== kwArg && a !== userIdArg)[0] || '';
const PROFESSION_ZH_MAP = {
'医生': 'doctor', '医疗': 'doctor', '律师': 'lawyer', '法律': 'lawyer',
'工程师': 'engineer', '开发者': 'developer', '开发': 'developer', '程序员': 'developer',
'设计师': 'designer', '设计': 'designer', '产品经理': 'product-manager', '产品': 'product-manager',
'投资': 'investor', '投资人': 'investor', '金融': 'investor', '教师': 'teacher', '教育': 'teacher',
'记者': 'journalist', '媒体': 'journalist', '创业': 'entrepreneur', '创业者': 'entrepreneur',
'研究员': 'researcher', '学者': 'researcher', '营销': 'marketing', '市场': 'marketing',
'人力资源': 'hr', 'HR': 'hr', '销售': 'sales'
};
const VALID_PROFESSIONS = new Set([
'doctor','lawyer','engineer','developer','designer','product-manager',
'investor','teacher','journalist','entrepreneur','researcher','marketing','hr','sales'
]);
// --userId mode: query all of user's subscribed professions
if (userIdArg && (allProfsFlag || !rawProf)) {
const safeId = userIdArg.replace(/[^a-zA-Z0-9_-]/g, '');
const fp = path.join(USERS_DIR, `safeId.json`);
if (!fs.existsSync(fp)) {
console.error(`User "userIdArg" not found.`);
process.exit(1);
}
const u = JSON.parse(fs.readFileSync(fp, 'utf8'));
const userLang = langArg === 'en' ? 'en' : (u.language || 'zh');
const userRegion = regionArg || u.region || (userLang === 'zh' ? 'cn' : 'us');
const userKw = kwArg ? kwArg.split(',').map(k => k.trim()) : (u.keywords || []);
const allProfs = [u.profession, ...(u.extraProfessions || [])].filter(Boolean);
const now2 = new Date();
const ds = userLang === 'en'
? now2.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })
: `now2.getFullYear()年now2.getMonth()+1月now2.getDate()日`;
if (userLang === 'en') {
console.log(`Querying all allProfs.length profession(s) for u.userId: allProfs.join(', ')\n`);
} else {
console.log(`为 u.userId 查询全部 allProfs.length 个订阅职业:allProfs.join('、')\n`);
}
allProfs.forEach((prof, idx) => {
// Inline PROFESSION_CONFIG lookup will be done in the block below via re-invocation hint
if (idx > 0) console.log('\n' + '─'.repeat(60) + '\n');
const isExtra = idx > 0;
const profLabel = userLang === 'en' ? `prof' ✦ primary'` : `prof' ✦ 主职业'`;
if (userLang === 'en') {
console.log(`[Career News Query | user: u.userId | profession: profLabel | lang: en | region: userRegion.toUpperCase() | ds]\n→ Run: node scripts/news-query.js prof --lang en --region userRegion''`);
} else {
console.log(`[职业新闻即时查询 | 用户:u.userId | 职业:profLabel | 语言:zh | 地区:userRegion.toUpperCase() | ds]\n→ 执行:node scripts/news-query.js prof --region userRegion''`);
}
});
process.exit(0);
}
if (!rawProf && !userIdArg) {
console.error('Usage: node scripts/news-query.js <profession> [--lang zh|en] [--region cn|us|global] [--keywords "kw1,kw2"]');
console.error(' node scripts/news-query.js --userId <id> # query all subscribed professions');
console.error('');
console.error('Professions: doctor, lawyer, engineer, developer, designer, product-manager,');
console.error(' investor, teacher, journalist, entrepreneur, researcher, marketing, hr, sales');
process.exit(1);
}
let profession = PROFESSION_ZH_MAP[rawProf] || (VALID_PROFESSIONS.has(rawProf) ? rawProf : null);
if (!profession) {
for (const [key, val] of Object.entries(PROFESSION_ZH_MAP)) {
if (rawProf.includes(key)) { profession = val; break; }
}
}
if (!profession) profession = 'developer';
const lang = langArg === 'en' ? 'en' : 'zh';
const region = regionArg || (lang === 'zh' ? 'cn' : 'us');
const extraKw = kwArg ? kwArg.split(',').map(k => k.trim()) : [];
const now = new Date();
const dateStr = lang === 'en'
? now.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })
: `now.getFullYear()年now.getMonth()+1月now.getDate()日`;
const PROFESSION_CONFIG = {
doctor: {
zh: { label: '医生/医疗从业者', kw: ['医学研究', '临床指南', '医疗政策', '新药审批', '公共卫生'], src: ['NEJM', 'Lancet', '丁香园'] },
en: { label: 'Doctor / Healthcare', kw: ['clinical research', 'FDA approvals', 'medical policy', 'public health'], src: ['NEJM', 'Lancet', 'Medscape'] }
},
lawyer: {
zh: { label: '律师/法律从业者', kw: ['司法改革', '最高法判例', '商事仲裁', '合规监管'], src: ['法制日报', '人民法院报'] },
en: { label: 'Lawyer / Legal', kw: ['supreme court', 'regulatory changes', 'legal tech', 'compliance'], src: ['Bloomberg Law', 'Law360'] }
},
engineer: {
zh: { label: '工程师', kw: ['工程技术', '制造业', '自动化', '行业标准'], src: ['IEEE', '机械工业信息'] },
en: { label: 'Engineer', kw: ['engineering innovation', 'manufacturing', 'automation'], src: ['IEEE Spectrum', 'IndustryWeek'] }
},
developer: {
zh: { label: '软件开发者', kw: ['编程语言', '开源项目', 'AI工具', '框架发布'], src: ['GitHub', 'InfoQ', '掘金'] },
en: { label: 'Software Developer', kw: ['programming', 'open source', 'AI tools', 'framework releases'], src: ['GitHub', 'Hacker News', 'TechCrunch'] }
},
designer: {
zh: { label: '设计师', kw: ['设计趋势', 'UI/UX', '设计工具'], src: ['站酷', 'UISDC'] },
en: { label: 'Designer', kw: ['design trends', 'UI/UX', 'design tools'], src: ['Behance', 'Smashing Magazine'] }
},
'product-manager': {
zh: { label: '产品经理', kw: ['产品设计', '用户体验', '增长策略', 'AI产品'], src: ['人人都是产品经理', '36氪'] },
en: { label: 'Product Manager', kw: ['product strategy', 'user experience', 'growth', 'AI products'], src: ['Product Hunt', 'Lenny\'s Newsletter'] }
},
investor: {
zh: { label: '投资人/金融从业者', kw: ['A股', '宏观经济', '投资策略', '并购动态'], src: ['财新', '华尔街见闻'] },
en: { label: 'Investor / Finance', kw: ['markets', 'macro economy', 'M&A', 'earnings'], src: ['Bloomberg', 'Financial Times', 'WSJ'] }
},
teacher: {
zh: { label: '教师/教育从业者', kw: ['教育改革', '课程标准', '教育技术'], src: ['中国教育报', '人民教育'] },
en: { label: 'Teacher / Educator', kw: ['education reform', 'curriculum', 'edtech'], src: ['Education Week', 'EdSurge'] }
},
journalist: {
zh: { label: '记者/媒体从业者', kw: ['新闻业动态', '媒体转型', '新闻自由'], src: ['澎湃', 'NiemanLab'] },
en: { label: 'Journalist / Media', kw: ['journalism', 'media industry', 'press freedom'], src: ['NiemanLab', 'Poynter'] }
},
entrepreneur: {
zh: { label: '创业者', kw: ['创投融资', '创业政策', 'AI创业', '出海机会'], src: ['36氪', '极客公园'] },
en: { label: 'Entrepreneur', kw: ['startup funding', 'venture capital', 'AI startups'], src: ['TechCrunch', 'Crunchbase', 'Y Combinator'] }
},
researcher: {
zh: { label: '研究员/学者', kw: ['学术前沿', '科研政策', '论文发表', '科技突破'], src: ['Nature', 'Science', '中国科学院'] },
en: { label: 'Researcher', kw: ['research breakthroughs', 'publications', 'science policy'], src: ['Nature', 'Science', 'arXiv'] }
},
marketing: {
zh: { label: '市场营销', kw: ['营销趋势', '广告平台', '社交媒体运营'], src: ['广告门', '数英网'] },
en: { label: 'Marketing', kw: ['marketing trends', 'advertising platforms', 'social media'], src: ['Marketing Week', 'AdAge'] }
},
hr: {
zh: { label: '人力资源', kw: ['人才管理', '招聘趋势', '劳动法规'], src: ['中国人力资源网', '智联招聘研究院'] },
en: { label: 'HR', kw: ['talent management', 'hiring trends', 'labor law'], src: ['SHRM', 'HR Dive'] }
},
sales: {
zh: { label: '销售', kw: ['销售方法论', '客户成功', '销售工具'], src: ['销售与市场', 'B2B圈'] },
en: { label: 'Sales', kw: ['sales methodology', 'customer success', 'CRM'], src: ['Sales Hacker', 'HubSpot Sales Blog'] }
}
};
const cfg = (PROFESSION_CONFIG[profession] || PROFESSION_CONFIG['developer'])[lang];
const allKw = [...cfg.kw, ...extraKw];
if (lang === 'en') {
console.log(`[Career News Query | profession: profession | lang: en | region: region.toUpperCase() | dateStr]
Please find the latest news for a cfg.label professional right now.
Keywords: allKw.join(', ')
Priority sources: cfg.src.join(', ')
Region: region.toUpperCase()
Search steps:
1. X (Twitter): Search "allKw[0] OR allKw[1]" — most recent posts with engagement
2. Google News: "cfg.label latest news today" — filter last 24h
3. Grok: "What's happening right now in cfg.label field? Top 3 developments."
4. Check priority sources: cfg.src.slice(0,3).join(', ')
Return exactly 5 stories. Format each as:
[source flag] Headline
→ 2-sentence summary. Source · URL
Source flags: 🐦 X/Twitter 🤖 Grok 📰 Media
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📰 cfg.label News · dateStr
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
} else {
console.log(`[职业新闻即时查询 | 职业:profession | 语言:zh | 地区:region.toUpperCase() | dateStr]
请立即为cfg.label查找最新行业新闻。
关键词:allKw.join('、')
优先信源:cfg.src.join('、')
地区:region.toUpperCase()
搜索步骤:
1. X(Twitter):搜索「allKw[0]」「allKw[1]」— 最新帖子,优先高互动
2. Google 新闻:「cfg.label 最新 今天」— 过去 24 小时
3. 询问 Grok:「cfg.label今天最重要的3条新闻是什么?」
4. 扫描优先信源:cfg.src.slice(0,3).join('、')
精选5条,格式:
[来源标注] 新闻标题
→ 2句摘要。来源 · 链接
来源标注:🐦 X/Twitter 🤖 Grok 📰 媒体
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📰 cfg.label 新闻 · dateStr
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
}
FILE:scripts/push-toggle.js
#!/usr/bin/env node
/**
* career-news — push-toggle.js
* 开关用户推送 / 显示 cron 命令
*
* 用法:
* node scripts/push-toggle.js # 显示 cron 安装命令
* node scripts/push-toggle.js --userId <id> # 切换该用户推送状态
*/
const fs = require('fs');
const path = require('path');
const USERS_DIR = path.join(__dirname, '..', 'data', 'users');
const SKILL_DIR = path.resolve(__dirname, '..');
const args = process.argv.slice(2);
const userIdx = args.indexOf('--userId');
const userId = userIdx !== -1 ? args[userIdx + 1] : null;
if (!userId) {
// Show cron setup instructions
console.log('Career News — Cron Setup');
console.log('─'.repeat(50));
console.log('Morning push (7:00 AM daily):');
console.log(` openclaw cron add "0 7 * * *" "cd SKILL_DIR && node scripts/morning-push.js"`);
console.log('');
console.log('Test commands:');
console.log(` node scripts/morning-push.js --dry-run`);
console.log(` node scripts/morning-push.js --user <userId>`);
console.log('');
console.log('To toggle a user\'s push: node scripts/push-toggle.js --userId <id>');
process.exit(0);
}
const safeId = userId.replace(/[^a-zA-Z0-9_-]/g, '');
const fp = path.join(USERS_DIR, `safeId.json`);
if (!fs.existsSync(fp)) {
console.error(`User "userId" not found.`);
process.exit(1);
}
const u = JSON.parse(fs.readFileSync(fp, 'utf8'));
u.pushEnabled = !u.pushEnabled;
u.updatedAt = new Date().toISOString();
fs.writeFileSync(fp, JSON.stringify(u, null, 2));
const status = u.pushEnabled ? '✅ enabled' : '⏸ disabled';
console.log(`Push for "safeId" is now status.`);
FILE:scripts/register.js
#!/usr/bin/env node
/**
* career-news — register.js
* Register / view / list users. Supports one primary profession + multiple extra subscriptions.
*
* Usage:
* node scripts/register.js <userId> --profession <prof> [--lang zh|en] [--region cn|us|global] [--keywords kw1,kw2]
* node scripts/register.js --show <userId>
* node scripts/register.js --list
*
* Professions: doctor, lawyer, engineer, developer, designer, product-manager,
* investor, teacher, journalist, entrepreneur, researcher, marketing, hr, sales
*
* Note: to add/remove extra profession subscriptions use manage-professions.js
*/
const fs = require('fs');
const path = require('path');
const USERS_DIR = path.join(__dirname, '..', 'data', 'users');
if (!fs.existsSync(USERS_DIR)) fs.mkdirSync(USERS_DIR, { recursive: true });
const VALID_PROFESSIONS = new Set([
'doctor','lawyer','engineer','developer','designer','product-manager',
'investor','teacher','journalist','entrepreneur','researcher','marketing','hr','sales'
]);
const PROFESSION_ZH_MAP = {
'医生':'doctor','医疗':'doctor','律师':'lawyer','法律':'lawyer',
'工程师':'engineer','开发者':'developer','程序员':'developer','开发':'developer',
'设计师':'designer','设计':'designer','产品经理':'product-manager','产品':'product-manager',
'投资人':'investor','投资':'investor','金融':'investor','教师':'teacher','教育':'teacher',
'记者':'journalist','媒体':'journalist','创业者':'entrepreneur','创业':'entrepreneur',
'研究员':'researcher','学者':'researcher','营销':'marketing','市场':'marketing',
'人力资源':'hr','销售':'sales'
};
function resolveProf(raw) {
if (!raw) return null;
const direct = PROFESSION_ZH_MAP[raw] || (VALID_PROFESSIONS.has(raw) ? raw : null);
if (direct) return direct;
for (const [key, val] of Object.entries(PROFESSION_ZH_MAP)) {
if (raw.includes(key)) return val;
}
return null;
}
const args = process.argv.slice(2);
// --list
if (args.includes('--list')) {
if (!fs.existsSync(USERS_DIR)) { console.log('No users registered.'); process.exit(0); }
const files = fs.readdirSync(USERS_DIR).filter(f => f.endsWith('.json'));
if (files.length === 0) { console.log('No users registered.'); process.exit(0); }
console.log(`\nRegistered users (files.length):`);
console.log('─'.repeat(70));
files.forEach(f => {
const u = JSON.parse(fs.readFileSync(path.join(USERS_DIR, f), 'utf8'));
const allProfs = [u.profession, ...(u.extraProfessions || [])].filter(Boolean);
const kw = u.keywords && u.keywords.length ? ` [u.keywords.join(',')]` : '';
const push = u.pushEnabled === false ? '⏸' : '✅';
console.log(`push u.userId.padEnd(20) allProfs.join('+').padEnd(28) u.language/u.regionkw`);
});
console.log('─'.repeat(70));
process.exit(0);
}
// --show <userId>
const showIdx = args.indexOf('--show');
if (showIdx !== -1) {
const showId = args[showIdx + 1];
if (!showId) { console.error('--show requires a userId'); process.exit(1); }
const fp = path.join(USERS_DIR, `showId.replace(/[^a-zA-Z0-9_-]/g, '').json`);
if (!fs.existsSync(fp)) { console.error(`User "showId" not found.`); process.exit(1); }
const u = JSON.parse(fs.readFileSync(fp, 'utf8'));
const allProfs = [u.profession, ...(u.extraProfessions || [])].filter(Boolean);
console.log(JSON.stringify(u, null, 2));
console.log(`\nSubscribed professions (allProfs.length): allProfs.join(', ')`);
console.log('Tip: node scripts/manage-professions.js --userId ' + u.userId + ' --add <profession>');
process.exit(0);
}
// register
const profIdx = args.indexOf('--profession');
const langIdx = args.indexOf('--lang');
const regionIdx = args.indexOf('--region');
const kwIdx = args.indexOf('--keywords');
const profArg = profIdx !== -1 ? args[profIdx + 1] : null;
const langArg = langIdx !== -1 ? args[langIdx + 1] : 'zh';
const regionArg = regionIdx !== -1 ? args[regionIdx + 1] : null;
const kwArg = kwIdx !== -1 ? args[kwIdx + 1] : null;
const rawUserId = args.filter(a =>
!a.startsWith('--') &&
a !== profArg && a !== langArg && a !== regionArg && a !== kwArg
)[0] || '';
if (!rawUserId) {
console.error('Usage: node scripts/register.js <userId> --profession <prof> [--lang zh|en] [--region cn|us] [--keywords kw1,kw2]');
console.error(' node scripts/register.js --show <userId>');
console.error(' node scripts/register.js --list');
console.error('');
console.error('Professions: doctor, lawyer, engineer, developer, designer, product-manager,');
console.error(' investor, teacher, journalist, entrepreneur, researcher, marketing, hr, sales');
console.error('');
console.error('To manage extra profession subscriptions:');
console.error(' node scripts/manage-professions.js --userId <id> --add <profession>');
process.exit(1);
}
const userId = rawUserId.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 64);
if (!userId) { console.error('Invalid userId.'); process.exit(1); }
const profession = resolveProf(profArg) || 'developer';
const lang = langArg === 'en' ? 'en' : 'zh';
const region = regionArg || (lang === 'zh' ? 'cn' : 'us');
const keywords = kwArg ? kwArg.split(',').map(k => k.trim()).filter(Boolean) : [];
const fp = path.join(USERS_DIR, `userId.json`);
const existing = fs.existsSync(fp) ? JSON.parse(fs.readFileSync(fp, 'utf8')) : {};
const user = {
...existing,
userId,
profession,
extraProfessions: existing.extraProfessions || [], // preserved on re-register
language: lang,
region,
keywords,
pushEnabled: existing.pushEnabled !== undefined ? existing.pushEnabled : true,
createdAt: existing.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
};
fs.writeFileSync(fp, JSON.stringify(user, null, 2));
const allProfs = [user.profession, ...user.extraProfessions].filter(Boolean);
console.log(`✔ User "userId" registered.`);
console.log(` Primary profession : profession`);
console.log(` Extra subscriptions: '(none)'`);
console.log(` All professions : allProfs.join(', ')`);
console.log(` Language : lang / Region: region`);
if (keywords.length) console.log(` Keywords : keywords.join(', ')`);
console.log(` Push : 'disabled'`);
console.log(` Saved to : fp`);
console.log('');
console.log(` → To add more profession subscriptions:`);
console.log(` node scripts/manage-professions.js --userId userId --add <profession>`);
每日 Skill 自动工厂 — 让 openclaw 和 Claude 完全自主地调研、设计、生成、测试并发布全新 skill,全程零人工介入。内置 10 阶段流水线(Research → Design → SEO → Create → Review → Self-Run → Self-Check → Uploa...
---
name: automatic-skill
description: |
每日 Skill 自动工厂 — 让 openclaw 和 Claude 完全自主地调研、设计、生成、测试并发布全新 skill,全程零人工介入。内置 10 阶段流水线(Research → Design → SEO → Create → Review → Self-Run → Self-Check → Upload → Verify → Final Review),每天凌晨 02:00 自动选题跑完整流程,输出推送到 GitHub 和 clawHub 的生产级 skill。也可手动指定 idea 触发,或单独调用某一阶段进行调试/迭代。支持用自身流水线对已有 skill 做升级、SEO 优化和重新发布。Use it when the user asks to auto-generate a skill, check daily pipeline status, iterate an existing skill, or publish to GitHub and clawHub.
keywords:
- automatic-skill
- 自动skill
- 每日skill
- skill流水线
- 技能工厂
- 自动开发
- 自动测试
- 自动发布
- 元技能
- skill pipeline
- skill factory
- meta skill
- daily skill
- auto build skill
- auto publish skill
- 自动生成skill
- 自动制作技能
- 今日skill
- 生成新skill
- skill自动化
- openclaw skill
- clawhub publish
- github skill upload
- 每日技能推送
- 10阶段流水线
- cron skill
- skill迭代
- skill升级
- skill SEO优化
- self-evolving skill
- skill自检
- prompt generator pipeline
- 零人工介入
- 自动化内容生产
requirements:
node: ">=18"
binaries:
- name: gh
required: true
description: "GitHub CLI — used for auth, repo access, push verification, and PR creation."
- name: git
required: true
description: "Git — used for staging, committing, and pushing skill files."
- name: clawhub
required: true
description: "ClawHub CLI — used for publishing skills to the registry."
- name: npx
required: false
description: "Node package runner — used optionally during skill creation stage."
env:
- name: GITHUB_TOKEN
required: true
description: "GitHub personal access token with repo write permission (narrowly scoped to a single repo)."
- name: GITHUB_REPO
required: true
description: "Target repository in owner/repo format, e.g. myorg/openclaw-skills"
- name: CLAWHUB_TOKEN
required: true
description: "ClawHub API token for publishing skills to the registry."
- name: CLAWHUB_OWNER_ID
required: false
description: "ClawHub ownerId override. Defaults to the publishing account."
- name: SKILL_OUTPUT_DIR
required: false
description: "Directory where generated skills are written. Defaults to ~/.openclaw/workspace/skills."
- name: OPENCLAW_NOTIFY_CHANNEL
required: false
description: "Notification channel for pipeline failure alerts (e.g. slack://...)."
metadata:
openclaw:
homepage: "https://github.com/Cosmofang/automatic-skill"
author: "Cosmofang"
runtime:
node: ">=18"
permissions:
- "Reads and writes files in SKILL_OUTPUT_DIR"
- "Creates GitHub repositories via gh CLI using GITHUB_TOKEN"
- "Pushes commits to GITHUB_REPO via gh CLI"
- "Publishes skills to clawHub using CLAWHUB_TOKEN"
- "Registers cron jobs via openclaw cron (when push-toggle is enabled)"
env:
- name: GITHUB_TOKEN
required: true
description: "GitHub personal access token — must have 'repo' scope. Scoped to your own repos only. Used by gh CLI to create repos and push commits."
- name: GITHUB_REPO
required: true
description: "Target monorepo in owner/repo format, e.g. yourname/openclaw-skills. Must be a repo you own."
- name: CLAWHUB_TOKEN
required: true
description: "ClawHub API token. Used only to publish skills under your own clawHub account."
- name: CLAWHUB_OWNER_ID
required: false
description: "Your clawHub ownerId. If omitted, defaults to the account associated with CLAWHUB_TOKEN."
- name: SKILL_OUTPUT_DIR
required: false
description: "Where generated skills are written. Defaults to ~/.openclaw/workspace/skills."
- name: OPENCLAW_NOTIFY_CHANNEL
required: false
description: "Notification channel for pipeline failure alerts (e.g. slack://...)."
---
# Automatic Skill — 每日 Skill 自动工厂
> 每天凌晨自动调研 → 设计 → 制作 → 审核 → 自测 → 发布 → 复查,全程无需人工介入(发布阶段使用你自己的 GitHub/clawHub 凭证)
---
## ⚠️ Credentials
**Stages 1–7 use zero credentials.** Only Stage 8 (Upload) and beyond touch your accounts.
| Stage | Action | Credential | What gets written |
|-------|--------|-----------|-------------------|
| 1–7 | Research, Design, SEO, Create, Review, Self-Run, Self-Check | None | Local files only |
| 8a | `gh repo create <owner>/<slug> --public` | `GITHUB_TOKEN` | New public repo on your GitHub account |
| 8b | `git push` to `GITHUB_REPO` | `GITHUB_TOKEN` | New commits to the monorepo you set |
| 8c | `clawhub publish` | `CLAWHUB_TOKEN` | Skill listed publicly under your clawHub account |
| 9–10 | Verify, Final Review | `GITHUB_TOKEN` (read-only `gh api`) | Nothing — read-only checks |
| Cron | Daily auto-run (`push-toggle on`) | None | Registers a local cron job via `openclaw cron` |
**Does NOT:**
- Read, transmit, or log credentials
- Access repos or accounts other than the ones you configure
- Execute network operations from scripts — all network actions go through `gh` CLI and `clawhub` CLI under your control
- Run Stage 8 in `--dry-run` mode (safe for testing entire pipeline without publishing)
**Minimum token scopes:**
- `GITHUB_TOKEN`: fine-grained PAT → single repo → **Contents: Read & write** only
- `CLAWHUB_TOKEN`: keep in shell env (`~/.zshrc`), never commit to any file
---
## Purpose & Capability
automatic-skill 是一个**元技能(meta-skill)**,它的能力是制造其他 skill。
**架构说明(重要):** 每个阶段脚本是"prompt generator" — 脚本本身不执行任何业务逻辑,只输出结构化 prompt。真正的执行者是 agent(Claude)。网络操作(GitHub push、clawHub publish)均由 agent 调用 `gh` CLI 和 `clawhub` CLI 完成,凭证完全在你的机器上。
**核心能力:**
| 能力 | 说明 |
|------|------|
| 全流水线生成 | 从"想法"到"上线":Research → Design → SEO → Create → Review → Self-Run → Self-Check → Upload → Verify → Final Review |
| 每日自动选题 | 凌晨 02:00 cron 自动触发,选最高价值 idea,跑完整流程 |
| 手动触发 | 指定 `--idea` 手动选题,或单独跑某一阶段调试 |
| dry-run 模式 | `--dry-run`:执行到 Stage 7 自检后停止,**不发起任何网络操作** |
| 已有 skill 迭代 | 对现有 skill 单独跑 SEO / self-check / upload 阶段进行升级 |
**能力边界(不做的事):**
- 不直接写 skill 源码(源码由 agent 根据 create 阶段 prompt 生成)
- 不并发生成多个 skill(每次运行产出一个)
- 不访问或修改除 `GITHUB_REPO` 和 `SKILL_OUTPUT_DIR` 以外的任何路径或账号
---
## Instruction Scope
**在 scope 内(会处理):**
- "帮我自动生成一个 skill" / "skill 流水线跑了没" / "今天出了什么新 skill"
- 手动触发全流水线或单阶段重跑(如"重跑 SEO 阶段")
- 查看流水线状态和历史日志(`status.js`)
- 开关每日定时任务(`push-toggle on/off`)
- 用 automatic-skill 迭代已有 skill(对现有 skill 升级 / SEO 优化 / 自检 / 上传)
**不在 scope 内(不处理):**
- 直接编写 skill 源码(源码由 agent 执行 create 阶段 prompt 生成,非 automatic-skill 职责)
- 管理 GitHub 仓库权限、clawHub 账户设置、或任何账户级操作
- 生成非 openclaw skill 格式的内容(普通项目、应用代码等)
- 在未设置 `GITHUB_TOKEN` / `GITHUB_REPO` / `CLAWHUB_TOKEN` 的环境中执行 Stage 8(会提示缺少凭证,不会静默失败)
**凭证缺失时的行为:**
- Stages 1–7:无需凭证,正常运行
- Stage 8 upload:缺少任意必需凭证 → 打印具体缺失变量名 + 配置指引,停止执行(不静默失败)
- `--dry-run`:完整跳过 Stage 8,可安全在无凭证环境测试前七个阶段
---
## Persistence & Privilege
**持久化写入的内容:**
| 路径 | 内容 | 触发条件 |
|------|------|---------|
| `SKILL_OUTPUT_DIR/<slug>/` | 生成的 skill 文件 | Stage 4 Create |
| `data/current-pipeline.json` | 当前流水线运行状态(临时) | 每次流水线运行 |
| `data/pipeline-log.json` | 历次运行历史(追加写入) | Stage 10 Final Review |
| `~/.openclaw/crontab`(或系统 cron) | 每日定时任务条目 | `push-toggle on` 时写入,`off` 时删除 |
**不写入的路径:**
- 不修改 shell 配置文件(`~/.zshrc` 等)
- 不写入 `GITHUB_TOKEN` / `CLAWHUB_TOKEN` 等凭证到任何文件
- 不在 `SKILL_OUTPUT_DIR` 之外创建文件(除 `data/` 目录)
**权限级别:**
- 运行时以当前用户身份执行,不需要 sudo 或提权
- gh CLI 和 clawhub CLI 的权限上限由你配置的 token 范围决定
- 如需撤销所有权限:`push-toggle off`(删除 cron)+ 在 GitHub/clawHub 吊销对应 token
---
## Install Mechanism
### 标准安装(推荐)
从 clawHub 安装到 openclaw workspace:
```bash
clawhub install automatic-skill
# 安装路径:~/.openclaw/workspace/skills/automatic-skill/
```
### 手动安装
从本地源码目录复制:
```bash
cp -r /path/to/automatic-skill ~/.openclaw/workspace/skills/automatic-skill/
```
### 验证安装
```bash
ls ~/.openclaw/workspace/skills/automatic-skill/scripts/
node ~/.openclaw/workspace/skills/automatic-skill/scripts/status.js
# 应输出:No active pipeline run. / pipeline-log.json 状态
```
### 安装后配置
在 shell 环境中设置以下变量(写入 `~/.zshrc` 或 `~/.bashrc`):
```bash
export GITHUB_TOKEN=<your-github-token> # repo write scope
export GITHUB_REPO=<owner/repo> # e.g. Cosmofang/openclaw-skills
export CLAWHUB_TOKEN=<your-clawhub-token>
export CLAWHUB_OWNER_ID=<your-owner-id> # optional
export SKILL_OUTPUT_DIR=~/.openclaw/workspace/skills # optional, this is the default
```
然后验证认证:
```bash
gh auth status # 应显示 ✓ Logged in
clawhub whoami # 应返回你的用户名
```
### 启用每日自动运行
```bash
node scripts/push-toggle.js on
# 注册 cron:每天 02:00 自动跑 daily-pipeline.js
```
---
## 何时使用
- 用户说"帮我自动生成一个 skill"/"今天有新 skill 吗"/"skill 流水线跑了没"
- 用户想查看今日生成的 skill 是什么
- 用户想手动触发某一阶段重跑
- 用户想查看生成历史或当前流水线状态
- 用户想用 automatic-skill 迭代/升级/上传某个现有 skill
---
## 🔄 10-Stage Pipeline
| # | Stage | Script | Description |
|---|-------|--------|-------------|
| 1 | Research | `research.js` | Scan trends, identify skill gaps, output Top-3 ideas |
| 2 | Design | `design.js <idea>` | Produce full architecture: file tree, script specs, data schema |
| 3 | SEO | `seo.js` | Optimize display name, tagline, description, 30+ keywords |
| 4 | Create | `create.js` | Generate all files using design + SEO output |
| 5 | Review | `review.js <skill-dir>` | Quality checklist: structure, scripts, content, security |
| 6 | Self-Run | `self-run.js <skill-dir>` | Execute every script, verify zero errors |
| 7 | Self-Check | `self-check.js <skill-dir>` | Validate required fields, file tree, script signatures |
| 7.5 | Safety Check | `scan-check.js <skill-dir>` | Pre-upload credential-safety gate; blocks upload if issues found |
| 8 | Upload | `upload.js <skill-dir>` | Create standalone repo + push monorepo; clawhub publish |
| 9 | Verify | `verify-upload.js <skill-name>` | Confirm live on GitHub and clawHub |
| 10 | Final Review | `final-review.js <skill-name>` | Full report, write to pipeline-log.json |
---
## 📦 Publishing Convention
### Skill Standard Sections
Every skill generated or uploaded by automatic-skill **must** include all five of the following sections in its `SKILL.md`, in this order:
| # | Section | Required content |
|---|---------|-----------------|
| 1 | `## Purpose & Capability` | Core concept + capability table + explicit "Does NOT" boundary |
| 2 | `## Instruction Scope` | In-scope examples + out-of-scope list + behavior on missing credentials |
| 3 | `## Credentials` | Action/Credential/Scope table (or "no credentials required") — no hardcoded tokens |
| 4 | `## Persistence & Privilege` | Paths-written table + "Does NOT write" list + uninstall instructions |
| 5 | `## Install Mechanism` | `clawhub install` command + verification step + env var examples |
Stage 7 (self-check.js) validates all five sections before allowing upload. Missing or empty section = `NEEDS_FIX`.
### GitHub Distribution
Every skill uploaded by automatic-skill **must** have both:
| Destination | Purpose | Command |
|-------------|---------|---------|
| `<owner>/<slug>` (standalone repo) | Users can search and find the skill directly on GitHub | `gh repo create <owner>/<slug> --public` → push skill files |
| `GITHUB_REPO/openclaw/agents/skills/<slug>/` (monorepo) | Registry index — all skills listed in one place | `git add openclaw/agents/skills/<slug>/` → push |
**Stage 8 always runs standalone repo creation first, then monorepo push.**
If the standalone repo already exists, skip creation and force-push the latest files.
---
## 🌐 Language Policy
- **SKILL.md content**: English (default)
- **Conversation with user**: match user's language — Chinese if user writes Chinese
- **JSON logs & reports**: English only
---
## 🛠️ Usage
```bash
# Full pipeline (recommended)
node scripts/pipeline.js # auto-select topic, run all stages
node scripts/pipeline.js --idea "daily-poem" # specify topic
node scripts/pipeline.js --dry-run # run through self-check only, no upload
# Per-stage debug
node scripts/research.js # output research prompt
node scripts/design.js "daily-poem" # output design prompt
node scripts/seo.js --from-pipeline # output SEO optimization prompt
node scripts/create.js --from-pipeline # output create prompt
node scripts/review.js /path/skill-dir # output review prompt
node scripts/self-run.js /path/skill-dir # output self-run prompt
node scripts/self-check.js /path/skill-dir # output self-check prompt
node scripts/upload.js /path/skill-dir # output upload prompt (direct push)
node scripts/upload.js --pr /path/skill-dir # output upload prompt (PR workflow)
node scripts/verify-upload.js skill-name # output verify prompt (gh api checks)
node scripts/final-review.js skill-name # output final review prompt
# Status & history
node scripts/status.js # current pipeline status
node scripts/status.js --history [N] # last N runs (default 10)
node scripts/status.js --clear # clear current pipeline state
# Cron toggle
node scripts/push-toggle.js on # enable daily 02:00 auto-run
node scripts/push-toggle.js off # disable
node scripts/push-toggle.js status # show status
```
---
## ⏰ Cron Setup
```bash
openclaw cron add "0 2 * * *" "cd ~/.openclaw/workspace/skills/automatic-skill && node scripts/daily-pipeline.js"
openclaw cron list
openclaw cron delete <job-id>
```
---
## 📁 File Structure
```
data/
pipeline-log.json # append-only history of all pipeline runs
current-pipeline.json # transient state during active run
scripts/
research.js # stage 1
design.js # stage 2
seo.js # stage 3 — SEO optimization
create.js # stage 4
review.js # stage 5
self-run.js # stage 6
self-check.js # stage 7
upload.js # stage 8
verify-upload.js # stage 9
final-review.js # stage 10
pipeline.js # orchestrator
daily-pipeline.js # cron entry point
status.js # status query
push-toggle.js # cron toggle
```
---
## ⚠️ Notes
1. Required env vars: `GITHUB_TOKEN`, `GITHUB_REPO`, `CLAWHUB_TOKEN`
2. Optional: `CLAWHUB_OWNER_ID` (your clawHub owner ID), `SKILL_OUTPUT_DIR` (default: `~/.openclaw/workspace/skills`)
3. **GitHub operations use the `gh` CLI** (inspired by [steipete/github](https://clawhub.ai/steipete/github)): `gh auth status`, `gh repo view`, `gh api`, `gh pr create`. Install with `brew install gh` and run `gh auth login` before first use.
4. Stage 8 supports `--pr` flag for a PR-based GitHub workflow instead of direct push to main.
5. Stage 9 verifies GitHub state live via `gh api repos/{repo}/contents/{path}` — no `git fetch` needed.
6. **Every skill gets a standalone GitHub repo** (`<owner>/<slug>`) in addition to the monorepo entry — this lets users search and discover skills directly on GitHub
7. On any stage failure the pipeline stops and logs the error — no partial overwrites
8. `--dry-run` stops after self-check, no network operations
9. Full run history in `data/pipeline-log.json`
10. All scripts are prompt generators only — no outbound network requests are made by the scripts themselves
---
## 🧪 Test Run Log (2026-04-04)
First full dry-run results:
| Stage | Status | Notes |
|-------|--------|-------|
| 1 Research | ✅ | Scanned 17 existing skills, identified 5 gaps, selected daily-poem |
| 2 Design | ✅ | Fix: `--from-pipeline` reads from `pipeline.research.selected` not top-level |
| 3 SEO | ✅ | Added in v1.1.0 — produces displayName, tagline, 30+ keywords |
| 4 Create | ✅ | Generated 9 files including 4 scripts |
| 5 Review | ✅ | Score 97/100, 1 warning (empty data array, expected) |
| 6 Self-Run | ✅ | 6 script calls, all passed |
| 7 Self-Check | ✅ | 22/22 checks passed, score 100 |
| 8 Upload | ✅ | dry-run skipped — requires GITHUB_TOKEN / GITHUB_REPO / CLAWHUB_TOKEN |
| 9 Verify | ✅ | dry-run skipped |
| 10 Final Review | ✅ | Archived to pipeline-log.json |
**Environment variables:**
```bash
export GITHUB_TOKEN=<your-token>
export GITHUB_REPO=<owner/repo>
export CLAWHUB_TOKEN=<your-clawhub-token>
export CLAWHUB_OWNER_ID=<your-owner-id> # optional
export SKILL_OUTPUT_DIR=~/.openclaw/workspace/skills # optional
```
---
*Version: 1.4.1 · Created: 2026-04-04 · Updated: 2026-04-09*
FILE:_meta.json
{
"ownerId": "kn79bebfnwg15sb0g7cj5z5nyd83gxh0",
"slug": "automatic-skill",
"version": "1.4.1",
"publishedAt": null
}
FILE:data/pipeline-log.json
[
{
"slug": "daily-poem",
"date": "2026-04-04",
"duration": "00:04:32",
"reviewScore": 97,
"selfCheckScore": 100,
"githubStatus": "SKIPPED",
"clawhubStatus": "SKIPPED",
"verdict": "SUCCESS",
"filesCreated": 9,
"scriptsTested": 6,
"completedAt": "2026-04-04T15:01:28.968Z",
"note": "Test run (dry-run). All 9 stages completed. Upload skipped pending env vars."
},
{
"slug": "daily-mood",
"date": "2026-04-04",
"duration": "00:06:18",
"reviewScore": 98,
"selfCheckScore": 100,
"githubStatus": "SKIPPED",
"clawhubStatus": "SKIPPED",
"verdict": "SUCCESS",
"filesCreated": 10,
"scriptsTested": 16,
"completedAt": "2026-04-04T15:12:21.241Z",
"note": "Test run dry-run. 1 bug fixed (template literal in evening-push+mood-response). All 9 stages complete."
}
]
FILE:data/safety-patterns.json
[
{
"id": "ECHO_TOKEN",
"pattern": "echo\\s+\\$[A-Z_]*TOKEN",
"severity": "HIGH",
"description": "Command that prints a token value to stdout",
"fix": "Remove the echo command. Use 'gh auth status' or 'clawhub whoami' to verify auth without printing token values."
},
{
"id": "BASE64_DECODE",
"pattern": "base64\\s+-d",
"severity": "HIGH",
"description": "base64 decoding — may expose credential content",
"fix": "Replace with a --jq query that reads metadata fields (name, size, encoding) instead of decoding raw content."
},
{
"id": "HEAD_TOKEN_PREVIEW",
"pattern": "head\\s+-c\\s+\\d+.*TOKEN",
"severity": "MEDIUM",
"description": "Reading the first N bytes of a token variable",
"fix": "Remove head -c N. If verifying a token is set, use the CLI auth command instead."
},
{
"id": "HARDCODED_SECRET",
"pattern": "(ghp_|github_pat_|xoxb-|sk-)[A-Za-z0-9]{10,}",
"severity": "HIGH",
"description": "Hardcoded credential value detected (GitHub token, Slack token, etc.)",
"fix": "Remove the hardcoded credential. Reference it by env var name instead (e.g. $GITHUB_TOKEN)."
}
]
FILE:package.json
{
"name": "automatic-skill",
"version": "1.4.1",
"description": "每日 Skill 自动工厂 — 10阶段流水线自主调研、设计、生成、测试并发布新 skill,支持已有 skill 迭代升级",
"homepage": "https://github.com/Cosmofang/automatic-skill",
"keywords": [
"automatic-skill", "skill-factory", "pipeline", "meta-skill", "daily-skill",
"自动skill", "技能工厂", "每日skill", "自动化", "skill迭代", "clawhub-publish"
],
"author": "jiajiaoy",
"license": "MIT",
"scripts": {
"pipeline": "node scripts/pipeline.js",
"pipeline:dry": "node scripts/pipeline.js --dry-run",
"daily": "node scripts/daily-pipeline.js",
"research": "node scripts/research.js",
"design": "node scripts/design.js",
"create": "node scripts/create.js",
"review": "node scripts/review.js",
"self-run": "node scripts/self-run.js",
"self-check": "node scripts/self-check.js",
"upload": "node scripts/upload.js",
"verify": "node scripts/verify-upload.js",
"safety-check": "node scripts/scan-check.js",
"safety-rules": "node scripts/scan-fix.js",
"final-review": "node scripts/final-review.js",
"status": "node scripts/status.js",
"status:history": "node scripts/status.js --history",
"push-on": "node scripts/push-toggle.js on",
"push-off": "node scripts/push-toggle.js off",
"push-status": "node scripts/push-toggle.js status"
}
}
FILE:scripts/create.js
#!/usr/bin/env node
/**
* Automatic Skill — Stage 4: Create (制作)
* 输出 Agent 执行 prompt,指导其按设计(含 SEO 优化结果)逐文件生成完整的 Skill。
*
* 用法:
* node scripts/create.js --from-pipeline
* node scripts/create.js <design-json-path>
* node scripts/create.js --lang en --from-pipeline
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
let design = null;
let seo = null;
let outputDir = process.env.SKILL_OUTPUT_DIR || path.join(process.env.HOME || '~', '.openclaw', 'workspace', 'skills');
if (args.includes('--from-pipeline')) {
const pipelinePath = path.join(__dirname, '..', 'data', 'current-pipeline.json');
if (!fs.existsSync(pipelinePath)) {
console.error('ERROR: data/current-pipeline.json not found. Run design.js first.');
process.exit(1);
}
const pipeline = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'));
if (!pipeline.design) {
console.error('ERROR: No design found in current-pipeline.json. Complete Stage 2 first.');
process.exit(1);
}
if (!pipeline.seo) {
console.error('ERROR: No SEO data found in current-pipeline.json. Complete Stage 3 (SEO) first.');
process.exit(1);
}
design = pipeline.design;
seo = pipeline.seo;
} else {
const designFilePath = args.filter(a => a !== '--lang' && a !== lang)[0];
if (!designFilePath || !fs.existsSync(designFilePath)) {
console.error('Usage: node scripts/create.js --from-pipeline');
console.error(' node scripts/create.js <path-to-design.json>');
process.exit(1);
}
design = JSON.parse(fs.readFileSync(designFilePath, 'utf8'));
}
const slug = (seo && seo.slug) || design.slug || 'unknown-skill';
// Read ownerId from env — never hardcode credentials in prompt instructions
const ownerId = process.env.CLAWHUB_OWNER_ID || '<your-clawhub-owner-id>';
const displayName = (seo && seo.displayName) || design.slug || slug;
const fullDescription = (seo && seo.fullDescription) || (design.skillMdOutline && design.skillMdOutline.frontmatter && design.skillMdOutline.frontmatter.description) || '';
const keywords = (seo && seo.keywords) || (design.skillMdOutline && design.skillMdOutline.frontmatter && design.skillMdOutline.frontmatter.keywords) || [];
const skillDir = path.join(outputDir, slug);
const seoNote = seo ? `
SEO OPTIMIZED VALUES (use these in SKILL.md and package.json — DO NOT use design defaults):
Display name : displayName
Tagline : seo.tagline || ''
Short desc : seo.shortDescription || ''
Full desc : fullDescription
Keywords (keywords.length): keywords.slice(0, 10).join(', ')''
GitHub desc : seo.githubDescription || ''
clawHub title : seo.clawhubTitle || ''
` : '';
if (lang === 'en') {
console.log(`
=== AUTOMATIC SKILL — Stage 4: Create ===
Skill: slug
Output directory: skillDir
seoNote
Design reference: JSON.stringify(design, null, 2).substring(0, 300)...
Create every file listed in the design's fileTree. Follow these rules strictly:
CREATION RULES:
1. Create the skill directory at: skillDir
2. For each file in the file tree, write the complete, working file content.
3. SKILL.md:
- name: use SEO displayName above
- description: use SEO fullDescription above (multi-line |)
- keywords: use the full SEO keywords list above
- metadata with openclaw runtime settings
- All sections from the design outline
4. Scripts: each script must be a standalone Node.js file. It must print a prompt (console.log) that tells the agent exactly what to do. Include input validation and helpful error messages.
5. package.json: use SEO displayName, shortDescription, and keywords. Include name, version, description, keywords, scripts map.
6. _meta.json: ownerId "ownerId", slug, version "1.0.0", publishedAt null.
7. .clawhub/origin.json: same ownerId and slug.
8. data/ files: create with empty arrays or objects as in dataSchemas.
LANGUAGE STANDARDS:
- SKILL.md name, description, all section headings and body text: write in ENGLISH
- Keywords: include both English and Chinese terms (bilingual is fine here)
- Script header comments and usage strings: ENGLISH
- JSON data files: ENGLISH keys and values
QUALITY STANDARDS:
- Every script must have a header comment with filename, purpose, and usage.
- Every script must handle missing args gracefully (print usage + exit 1).
- SKILL.md must have a commands table and a ⚠️ Notes section.
- No placeholder text like "TODO" or "..." in final files.
AFTER CREATING ALL FILES:
- Run: ls -la skillDir to confirm all files exist.
- Update data/current-pipeline.json: add key "create" with { "stage": "create", "skillDir": "skillDir", "filesCreated": [...], "completedAt": "<ISO timestamp>" }
- Then proceed to Stage 5: node scripts/review.js skillDir
`);
} else {
console.log(`
=== AUTOMATIC SKILL — 阶段 4:制作 ===
Skill:slug
输出目录:skillDir
seoNote
设计参考:JSON.stringify(design, null, 2).substring(0, 300)...
按照设计中的 fileTree,逐文件创建所有内容。严格遵守以下规则:
创建规则:
1. 在以下路径创建 skill 目录:skillDir
2. 对 fileTree 中的每个文件,写出完整、可运行的文件内容。
3. SKILL.md:
- name:使用上方 SEO 展示名称
- description:使用上方 SEO 完整描述(多行 | 格式)
- keywords:使用上方完整的 SEO 关键词列表
- metadata 含 openclaw runtime 配置
- 包含设计大纲中的所有章节
4. 脚本:每个脚本必须是独立的 Node.js 文件,通过 console.log 打印 prompt,包含输入校验和友好错误提示。
5. package.json:使用 SEO 展示名称、简短描述和关键词,包含 name、version、description、keywords、scripts 映射。
6. _meta.json:ownerId "ownerId",slug 来自设计,version "1.0.0",publishedAt null。
7. .clawhub/origin.json:同样的 ownerId 和 slug。
8. data/ 文件:按 dataSchemas 规格创建空数组或空对象。
语言标准:
- SKILL.md 名称、描述、所有章节标题和正文:统一写英文
- keywords 关键词:中英双语均可
- 脚本头部注释和用法说明:英文
- JSON 数据文件:键和值统一英文
质量标准:
- 每个脚本必须有头部注释(文件名、用途、用法)。
- 每个脚本缺少参数时必须优雅处理(打印用法 + exit 1)。
- SKILL.md 必须包含命令表格和 ⚠️ 注意事项章节。
- 最终文件中不得出现"TODO"或"..."等占位符。
创建所有文件后:
- 运行:ls -la skillDir 确认所有文件存在。
- 更新 data/current-pipeline.json:添加 "create" 键 { "stage": "create", "skillDir": "skillDir", "filesCreated": [...], "completedAt": "<ISO 时间戳>" }
- 然后进入阶段 5:node scripts/review.js skillDir
`);
}
FILE:scripts/daily-pipeline.js
#!/usr/bin/env node
/**
* Automatic Skill — Daily Pipeline Entry Point (每日定时入口)
* 由 openclaw cron 每日 02:00 触发,自动运行全流水线。
* 包含防重复锁、失败日志、通知推送。
*
* 用法:
* node scripts/daily-pipeline.js # 正常运行(由 cron 调用)
* node scripts/daily-pipeline.js --dry-run # 只跑到 self-check,不上传
* node scripts/daily-pipeline.js --force # 忽略锁文件强制运行
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const force = args.includes('--force');
const dataDir = path.join(__dirname, '..', 'data');
const lockPath = path.join(dataDir, 'pipeline.lock');
const pipelinePath = path.join(dataDir, 'current-pipeline.json');
const logPath = path.join(dataDir, 'pipeline-log.json');
const now = new Date();
const dateISO = `now.getFullYear()-String(now.getMonth()+1).padStart(2,'0')-String(now.getDate()).padStart(2,'0')`;
const scriptDir = path.join(__dirname);
// Check lock file to prevent duplicate runs
if (!force && fs.existsSync(lockPath)) {
const lockData = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
if (lockData.date === dateISO) {
console.log(`⏭️ Pipeline already ran today (dateISO). Skipping. Use --force to override.`);
console.log(` Lock created at: lockData.createdAt`);
process.exit(0);
}
}
// Check if a pipeline is already in progress
if (!force && fs.existsSync(pipelinePath)) {
const pipeline = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'));
if (pipeline.date === dateISO) {
console.log(`⚠️ A pipeline for today is already in progress. Skipping duplicate run.`);
console.log(` Use --force to override, or delete data/current-pipeline.json.`);
process.exit(0);
}
}
// Write lock
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(lockPath, JSON.stringify({ date: dateISO, createdAt: now.toISOString() }));
console.log(`
=== AUTOMATIC SKILL — Daily Pipeline (dateISO) ===
Mode: 'FULL'
Lock created. Starting pipeline...
You are the openclaw daily skill generator. Today's task:
1. Run the full 9-stage Automatic Skill pipeline (or 6 stages if dry-run)
2. On success: clean up the lock file at lockPath
3. On failure: log the error and clean up the lock file
EXECUTION:
Run the pipeline orchestrator now:
node scriptDir/pipeline.js''
The pipeline will guide you through all stages automatically.
AFTER PIPELINE COMPLETES:
- If SUCCESS: delete the lock file lockPath
- If FAILURE:
1. Log the error to data/pipeline-log.json with verdict "FAILED"
2. Delete the lock file lockPath
3. Send a failure notification (if OPENCLAW_NOTIFY_CHANNEL is set):
"❌ Daily skill pipeline FAILED on dateISO. Error: <error message>"
ENVIRONMENT CHECKS (run each command to verify before starting):
- GITHUB_TOKEN: gh auth status (must show: ✓ Logged in to github.com)
- GITHUB_REPO: echo $GITHUB_REPO (must print owner/repo, e.g. myorg/mycli)
- CLAWHUB_TOKEN: clawhub whoami (must return your clawHub username without error)
- SKILL_OUTPUT_DIR: echo process.env.SKILL_OUTPUT_DIR || '~/.openclaw/workspace/skills (default)'
upload stages will be skipped regardless of env vars.' : ''
START THE PIPELINE: node scriptDir/pipeline.js''
`);
FILE:scripts/design.js
#!/usr/bin/env node
/**
* Automatic Skill — Stage 2: Design (设计)
* 输出 Agent 执行 prompt,指导其为选定创意产出完整的 Skill 架构设计。
*
* 用法:
* node scripts/design.js <skill-slug-or-idea>
* node scripts/design.js --from-pipeline # 从 data/current-pipeline.json 读取 selected
* node scripts/design.js --lang en <skill-slug>
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
let idea = '';
if (args.includes('--from-pipeline')) {
const pipelinePath = path.join(__dirname, '..', 'data', 'current-pipeline.json');
if (!fs.existsSync(pipelinePath)) {
console.error('ERROR: data/current-pipeline.json not found. Run research.js first.');
process.exit(1);
}
const pipeline = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'));
const selected = pipeline.selected || (pipeline.research && pipeline.research.selected);
if (!selected) {
console.error('ERROR: No selected idea found in current-pipeline.json. Complete Stage 1 first.');
process.exit(1);
}
idea = `selected.name (selected.slug): selected.description`;
} else {
idea = args.filter(a => a !== '--lang' && a !== lang).join(' ');
if (!idea) {
console.error('Usage: node scripts/design.js <skill-idea-or-slug>');
console.error(' node scripts/design.js --from-pipeline');
process.exit(1);
}
}
const now = new Date();
const dateISO = `now.getFullYear()-String(now.getMonth()+1).padStart(2,'0')-String(now.getDate()).padStart(2,'0')`;
if (lang === 'en') {
console.log(`
=== AUTOMATIC SKILL — Stage 2: Design ===
Skill Idea: idea
Date: dateISO
Design a complete, production-ready openclaw skill for the idea above.
OUTPUT REQUIREMENTS — produce all of the following:
1. SKILL OVERVIEW
- slug (lowercase, hyphenated)
- Full name
- One-paragraph description (for SKILL.md frontmatter)
- Keywords list (20+ words, mix of Chinese and English)
- Runtime requirements (Node version, npm packages needed)
- Environment variables needed (name, required, description)
2. FILE TREE
List every file the skill needs, e.g.:
SKILL.md
package.json
_meta.json
.clawhub/origin.json
data/<filename>.json (if any persistent data)
scripts/
<script-name>.js (one line per script with its purpose)
3. SCRIPT SPECIFICATIONS
For each script, provide:
- Filename
- Purpose (one sentence)
- Input: CLI args or flags
- Output: what console.log prints (prompt text or JSON)
- Logic: 5-10 bullet points describing the algorithm
4. SKILL.md STRUCTURE
- Frontmatter fields (name, description, keywords, metadata)
- Example commands table
- REQUIRED: the following five sections must appear in this exact order, with real content (not placeholders):
## Purpose & Capability
What this skill is, what it can do, and what it cannot do.
Must include:
- One-paragraph explanation of the core concept
- A table or bullet list of core capabilities
- A "Does NOT" or "Boundary" section listing explicit non-capabilities
## Instruction Scope
When to use this skill vs. when not to.
Must include:
- "In scope" list: example user requests this skill handles
- "Out of scope" list: requests this skill will NOT handle
- Behavior when called outside scope (e.g. politely refuse, redirect)
## Credentials
Every credential, token, and env var this skill touches.
Must include:
- A table: Action | Credential | Scope (what gets written/read)
- Explicit "Does NOT" row (no exfiltration, no other accounts)
- Minimum token scope recommendations
- If skill needs zero credentials: state "This skill requires no credentials."
## Persistence & Privilege
Every file, cron job, or system-level change this skill makes.
Must include:
- Table of paths written to, with trigger condition
- "Does NOT write" list
- Privilege level (runs as current user, no sudo needed)
- How to fully uninstall / revoke (delete files + revoke tokens)
## Install Mechanism
How to install, configure, and verify the skill.
Must include:
- Standard install command (clawhub install <slug>)
- Manual install fallback (cp -r)
- Verification step (run a script, expect specific output)
- Post-install env var configuration with example values
- How to enable/disable any optional scheduled features
5. DATA SCHEMA
For each data file, provide the JSON schema with example values.
6. CRON SCHEDULE
If the skill needs scheduled pushes, list cron expressions and their triggers.
OUTPUT FORMAT (JSON, to be appended into data/current-pipeline.json under key "design"):
{
"stage": "design",
"idea": "idea",
"slug": "...",
"fileTree": ["SKILL.md", "package.json", ...],
"scripts": [
{ "file": "scripts/xxx.js", "purpose": "...", "args": "...", "output": "...", "logic": [] }
],
"skillMdOutline": { "frontmatter": {}, "sections": [] },
"dataSchemas": {},
"cronSchedules": [],
"completedAt": "<ISO timestamp>"
}
Read the existing data/current-pipeline.json, add the "design" key, and save back.
Then proceed to Stage 3: node scripts/create.js --from-pipeline
`);
} else {
console.log(`
=== AUTOMATIC SKILL — 阶段 2:设计 ===
Skill 创意:idea
日期:dateISO
为上述创意设计一个完整的、可投入生产的 openclaw skill。
输出要求 — 产出以下全部内容:
1. SKILL 概要
- slug(小写,短横线分隔)
- 全名
- 一段描述(用于 SKILL.md frontmatter)
- 关键词列表(20+ 词,中英混合)
- 运行环境需求(Node 版本、所需 npm 包)
- 所需环境变量(名称、是否必填、说明)
2. 文件树
列出 skill 所需的每个文件,例如:
SKILL.md
package.json
_meta.json
.clawhub/origin.json
data/<文件名>.json(如有持久化数据)
scripts/
<脚本名>.js(每行一个脚本及其用途)
3. 脚本规格
对每个脚本,提供:
- 文件名
- 用途(一句话)
- 输入:CLI 参数或 flag
- 输出:console.log 打印的内容(prompt 文本或 JSON)
- 逻辑:5-10 条描述算法的要点
4. SKILL.md 结构
- frontmatter 字段(name, description, keywords, metadata)
- 示例命令表格
- 必须包含以下五个章节,按此顺序排列,必须填写真实内容(不得使用占位符):
## Purpose & Capability
这个 skill 是什么、能做什么、不能做什么。
必须包含:
- 一段核心概念说明
- 核心能力表格或列表
- "能力边界"/"Does NOT" 章节,明确列出不做的事
## Instruction Scope
何时使用,何时不应使用。
必须包含:
- "在 scope 内" 列表:举例用户会说的请求
- "不在 scope 内" 列表:明确拒绝的请求类型
- 凭证缺失时的行为说明
## Credentials
这个 skill 涉及的所有凭证、token、env var。
必须包含:
- 表格:操作 | 使用的凭证 | 写入/读取范围
- "不做的事"行(不外传凭证、不访问其他账号)
- 最小 token scope 建议
- 若 skill 不需要任何凭证:明确写 "本 skill 无需任何凭证"
## Persistence & Privilege
这个 skill 对文件系统、cron、系统的所有写入操作。
必须包含:
- 写入路径表格,含触发条件
- "不写入"列表
- 权限级别(以当前用户身份运行,无需 sudo)
- 完整卸载/撤销方法
## Install Mechanism
如何安装、配置、验证。
必须包含:
- 标准安装命令(clawhub install <slug>)
- 手动安装备选方案(cp -r)
- 验证步骤(运行某个脚本,预期输出)
- 安装后的 env var 配置示例
- 可选定时功能的启用/停用方法
5. 数据 Schema
对每个数据文件,提供带示例值的 JSON schema。
6. Cron 计划
如果 skill 需要定时推送,列出 cron 表达式及触发器。
输出格式(JSON,追加到 data/current-pipeline.json 的 "design" 键下):
{
"stage": "design",
"idea": "idea",
"slug": "...",
"fileTree": ["SKILL.md", "package.json", ...],
"scripts": [
{ "file": "scripts/xxx.js", "purpose": "...", "args": "...", "output": "...", "logic": [] }
],
"skillMdOutline": { "frontmatter": {}, "sections": [] },
"dataSchemas": {},
"cronSchedules": [],
"completedAt": "<ISO 时间戳>"
}
读取现有的 data/current-pipeline.json,添加 "design" 键后保存回去。
然后进入阶段 3:node scripts/create.js --from-pipeline
`);
}
FILE:scripts/final-review.js
#!/usr/bin/env node
/**
* Automatic Skill — Stage 10: Final Review (复查)
* 输出 Agent 执行 prompt,指导其生成完整的流水线报告并写入 pipeline-log.json。
*
* 用法:
* node scripts/final-review.js <skill-slug>
* node scripts/final-review.js --from-pipeline
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
let slug = '';
if (args.includes('--from-pipeline')) {
const pipelinePath = path.join(__dirname, '..', 'data', 'current-pipeline.json');
if (fs.existsSync(pipelinePath)) {
const p = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'));
slug = (p.design && p.design.slug)
|| (p.research && p.research.selected && p.research.selected.slug)
|| '';
}
}
if (!slug) {
slug = args.filter(a => a !== '--lang' && a !== lang && !a.startsWith('--'))[0] || '';
}
if (!slug) {
console.error('Usage: node scripts/final-review.js <skill-slug>');
console.error(' node scripts/final-review.js --from-pipeline');
process.exit(1);
}
const dataDir = path.join(__dirname, '..', 'data');
const pipelinePath = path.join(dataDir, 'current-pipeline.json');
const logPath = path.join(dataDir, 'pipeline-log.json');
if (lang === 'en') {
console.log(`
=== AUTOMATIC SKILL — Stage 10: Final Review ===
Skill: slug
This is the final stage. Compile a comprehensive report and archive the pipeline run.
STEP 1 — Read the full pipeline state
Read: pipelinePath
Collect all stage results: research, design, create, review, selfRun, selfCheck, upload, verifyUpload.
STEP 2 — Compile pipeline summary
Calculate:
- Total time from research.completedAt to now
- Number of review iterations (how many times review stage was re-run)
- Number of self-check iterations
- Final review score (from selfCheck.score)
- Upload status summary
STEP 3 — Generate the final report
REPORT FORMAT:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🤖 AUTOMATIC SKILL PIPELINE — FINAL REPORT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Skill: slug
Date: <date>
Total Time: <duration>
PIPELINE STAGES:
✅ Stage 1 Research — Completed at <time> (<N> ideas evaluated)
✅ Stage 2 Design — Completed at <time>
✅ Stage 3 SEO — displayName: <name> keywords: <N>
✅ Stage 4 Create — Completed at <time> (<N> files created)
✅ Stage 5 Review — Score: <N>/100 (<N> iterations)
✅ Stage 6 Self-Run — <N> scripts tested, <N> passed
✅ Stage 7 Self-Check — Score: <N>/100 (<N> checks passed)
✅ Stage 8 Upload — GitHub: <status> clawHub: <status>
✅ Stage 9 Verify — GitHub: VERIFIED clawHub: VERIFIED
✅ Stage 10 Final Review — COMPLETE
SKILL SUMMARY:
Name: <full name>
Description: <one-line>
Scripts: <N> scripts
Keywords: <N> keywords
GitHub: <repo>/openclaw/agents/skills/slug
clawHub: <clawhub url>
ISSUES ENCOUNTERED: <list any warnings or fixes applied>
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 4 — Archive to pipeline-log.json
Read: logPath (if it doesn't exist, start with [])
Append this entry:
{
"slug": "slug",
"date": "<ISO date>",
"duration": "<HH:MM:SS>",
"reviewScore": <N>,
"selfCheckScore": <N>,
"githubStatus": "SUCCESS" | "FAILED" | "SKIPPED",
"clawhubStatus": "SUCCESS" | "FAILED" | "SKIPPED",
"verdict": "SUCCESS" | "PARTIAL_SUCCESS" | "FAILED",
"filesCreated": <N>,
"scriptsTested": <N>,
"completedAt": "<ISO timestamp>"
}
Save the updated array back to logPath.
STEP 5 — Clean up current pipeline state
Delete: pipelinePath
(The run is complete; next pipeline starts fresh)
STEP 6 — Print completion message
Print a brief summary to the user (or to openclaw push channel) announcing the new skill.
Example:
🎉 New skill published: slug
📖 Description: <description>
🔗 GitHub: <url>
🏪 clawHub: <url>
DONE. Pipeline complete. ✅
`);
} else {
console.log(`
=== AUTOMATIC SKILL — 阶段 10:复查 ===
Skill:slug
这是最后阶段。编写完整报告并归档本次流水线运行记录。
步骤 1 — 读取完整的流水线状态
读取:pipelinePath
收集所有阶段结果:research, design, create, review, selfRun, selfCheck, upload, verifyUpload。
步骤 2 — 编写流水线摘要
计算:
- 从 research.completedAt 到现在的总耗时
- 审核迭代次数(review 阶段重跑了多少次)
- 自检迭代次数
- 最终审核分数(来自 selfCheck.score)
- 上传状态摘要
步骤 3 — 生成最终报告
报告格式:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🤖 AUTOMATIC SKILL 流水线 — 最终报告
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Skill: slug
日期: <日期>
总耗时: <时长>
流水线阶段:
✅ 阶段 1 调研 — 完成于 <时间>(评估了 <N> 个创意)
✅ 阶段 2 设计 — 完成于 <时间>
✅ 阶段 3 制作 — 完成于 <时间>(创建了 <N> 个文件)
✅ 阶段 4 审核 — 得分:<N>/100(<N> 次迭代)
✅ 阶段 5 自跑 — <N> 个脚本测试,<N> 个通过
✅ 阶段 6 自检 — 得分:<N>/100(通过 <N> 项检查)
✅ 阶段 7 上传 — GitHub:<状态> clawHub:<状态>
✅ 阶段 8 验收 — GitHub:VERIFIED clawHub:VERIFIED
✅ 阶段 9 复查 — 完成
Skill 摘要:
名称: <全名>
描述: <一句话>
脚本数: <N> 个
关键词: <N> 个
GitHub: <repo>/openclaw/agents/skills/slug
clawHub: <clawhub url>
遇到的问题:<列出所有警告或已修复内容>
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
步骤 4 — 归档到 pipeline-log.json
读取:logPath(如不存在,从 [] 开始)
追加以下条目:
{
"slug": "slug",
"date": "<ISO 日期>",
"duration": "<HH:MM:SS>",
"reviewScore": <N>,
"selfCheckScore": <N>,
"githubStatus": "SUCCESS" | "FAILED" | "SKIPPED",
"clawhubStatus": "SUCCESS" | "FAILED" | "SKIPPED",
"verdict": "SUCCESS" | "PARTIAL_SUCCESS" | "FAILED",
"filesCreated": <N>,
"scriptsTested": <N>,
"completedAt": "<ISO 时间戳>"
}
将更新后的数组保存回 logPath。
步骤 5 — 清理当前流水线状态
删除:pipelinePath
(本次运行完成;下次流水线从头开始)
步骤 6 — 打印完成信息
向用户(或 openclaw 推送渠道)打印简短摘要,宣布新 skill 发布。
示例:
🎉 新 skill 已发布:slug
📖 描述:<描述>
🔗 GitHub:<url>
🏪 clawHub:<url>
完成。流水线结束。✅
`);
}
FILE:scripts/pipeline.js
#!/usr/bin/env node
/**
* Automatic Skill — Pipeline Orchestrator (编排器)
* 输出 Agent 执行 prompt,引导其完整运行全部 10 个阶段的流水线。
* Stage 7.5 (Safety Check) runs BEFORE upload as a credential-safety gate.
*
* 用法:
* node scripts/pipeline.js # 自动选题,全流水线
* node scripts/pipeline.js --idea "每日诗词" # 指定主题
* node scripts/pipeline.js --dry-run # 运行到 self-check,不上传
* node scripts/pipeline.js --from-stage 4 # 从指定阶段继续(读取现有 pipeline 状态)
* node scripts/pipeline.js --lang en
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
const dryRun = args.includes('--dry-run');
const ideaIdx = args.indexOf('--idea');
const idea = ideaIdx !== -1 ? args[ideaIdx + 1] : null;
const fromStageIdx = args.indexOf('--from-stage');
const fromStage = fromStageIdx !== -1 ? parseInt(args[fromStageIdx + 1], 10) : 1;
const scriptDir = path.join(__dirname);
const dataDir = path.join(__dirname, '..', 'data');
const pipelinePath = path.join(dataDir, 'current-pipeline.json');
const now = new Date();
const dateISO = `now.getFullYear()-String(now.getMonth()+1).padStart(2,'0')-String(now.getDate()).padStart(2,'0')`;
// Check if there's an in-progress pipeline
let existingPipeline = null;
if (fs.existsSync(pipelinePath)) {
try {
existingPipeline = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'));
} catch (e) {
// ignore parse error
}
}
if (existingPipeline && fromStage === 1) {
console.warn(`⚠️ WARNING: A pipeline is already in progress for "existingPipeline.design && existingPipeline.design.slug || 'unknown'".`);
console.warn(` Use --from-stage <N> to resume, or delete data/current-pipeline.json to start fresh.`);
console.warn('');
}
const stages = [
{ n: 1, name: 'Research', cmd: `node scriptDir/research.js` },
{ n: 2, name: 'Design', cmd: `node scriptDir/design.js --from-pipeline` },
{ n: 3, name: 'SEO', cmd: `node scriptDir/seo.js --from-pipeline` },
{ n: 4, name: 'Create', cmd: `node scriptDir/create.js --from-pipeline` },
{ n: 5, name: 'Review', cmd: `node scriptDir/review.js --from-pipeline` },
{ n: 6, name: 'Self-Run', cmd: `node scriptDir/self-run.js --from-pipeline` },
{ n: 7, name: 'Self-Check', cmd: `node scriptDir/self-check.js --from-pipeline` },
{ n: 7.5, name: 'Safety Check', cmd: `node scriptDir/scan-check.js --from-pipeline` },
{ n: 8, name: 'Upload', cmd: `node scriptDir/upload.js'' --from-pipeline` },
{ n: 9, name: 'Verify', cmd: `node scriptDir/verify-upload.js --from-pipeline` },
{ n: 10, name: 'Final Review', cmd: `node scriptDir/final-review.js --from-pipeline` },
];
const activeStages = dryRun ? stages.slice(0, 7) : stages;
const pendingStages = activeStages.filter(s => s.n >= fromStage);
if (lang === 'en') {
console.log(`
=== AUTOMATIC SKILL — Full Pipeline Orchestrator ===
Date: dateISO
Mode: 'FULL (all 10 stages)'
${idea` : 'Idea: auto-select from research'}
${fromStage` : 'Starting from: stage 1'}
You are running the complete Automatic Skill pipeline. Execute each stage IN ORDER.
Do not skip stages. If a stage fails, fix the issue and re-run that stage before proceeding.
LANGUAGE POLICY (follow throughout the entire pipeline):
- SKILL.md content (name, description, sections, commands): write in ENGLISH
- Conversational replies to the user: use the user's language (Chinese if user writes in Chinese)
- All JSON logs, reports, and pipeline state (pipeline-log.json, review scores, etc.): ENGLISH only
EXECUTION PLAN:
pendingStages.map(s => ` Stage ${s.n: s.name`).join('\n')}
Stages 8-10 (Upload, Verify, Final Review) will be skipped.' : ''
BEFORE STARTING:
1. Ensure environment variables are set:
- GITHUB_TOKEN (required for upload)
- GITHUB_REPO (required for upload, format: owner/repo)
- CLAWHUB_TOKEN (required for upload)
2. Ensure Node.js >= 18 is installed
EXECUTION INSTRUCTIONS:
For each stage below, run the command to get the stage prompt, then execute that prompt fully before moving to the next stage.
pendingStages.map(s => `STAGE ${s.n — s.name
Run: s.n === 2 && idea ? `node ${scriptDir/design.js "idea"` : s.cmd}
Wait for stage to complete (check data/current-pipeline.json has "s.name.toLowerCase().replace(/ /g, '-')" key)
Then continue to stage s.n + 1.
`).join('\n')}
PROGRESS TRACKING:
After each stage completes, data/current-pipeline.json will contain a new key for that stage.
Check progress at any time: node scripts/status.js
If a stage produces a FAIL verdict, fix the issues described in the stage output and re-run that stage before proceeding.
SAFETY CHECK (Stage 7.5):
Stage 7.5 runs a pre-upload credential-safety check on all generated skill files.
If any credential-exposure issues are found (e.g. hardcoded tokens, echo $TOKEN patterns),
the pipeline BLOCKS and reports the issues. You must fix them manually before proceeding
to Stage 8 (Upload). This is a quality gate, not an auto-fix loop.
After all stages complete, the new skill will be:
- Available locally in SKILL_OUTPUT_DIR
- Committed and pushed to GitHub (unless --dry-run)
- Published on clawHub (unless --dry-run)
- Logged in data/pipeline-log.json
BEGIN with Stage fromStage: run the command listed above for Stage fromStage.
`);
} else {
console.log(`
=== AUTOMATIC SKILL — 全流水线编排器 ===
日期:dateISO
模式:'完整(全部 10 个阶段)'
idea ? `主题:${idea` : '主题:由调研阶段自动选定'}
fromStage > 1 ? `从阶段 ${fromStage 继续` : '从阶段 1 开始'}
你正在运行完整的 Automatic Skill 流水线。按顺序执行每个阶段。
不要跳过阶段。如果某阶段失败,修复问题并重新运行该阶段,再继续下一阶段。
语言规则(整个流水线全程遵守):
- SKILL.md 内容(名称、描述、章节、命令):统一写英文
- 与用户的对话回复:使用用户的语言(用户说中文则全程回中文)
- 所有 JSON 日志、报告、流水线状态(pipeline-log.json、审核评分等):统一英文
执行计划:
pendingStages.map(s => ` 阶段 ${s.n:s.name`).join('\n')}
''
开始前:
1. 确保已设置环境变量:
- GITHUB_TOKEN(上传必需)
- GITHUB_REPO(上传必需,格式:owner/repo)
- CLAWHUB_TOKEN(上传必需)
2. 确保已安装 Node.js >= 18
执行说明:
对以下每个阶段,运行命令获取阶段 prompt,然后完整执行该 prompt,再进入下一阶段。
pendingStages.map(s => `阶段 ${s.n — s.name
运行:s.n === 2 && idea ? `node ${scriptDir/design.js "idea"` : s.cmd}
等待阶段完成(检查 data/current-pipeline.json 已包含对应键)
然后继续阶段 s.n + 1。
`).join('\n')}
进度追踪:
每个阶段完成后,data/current-pipeline.json 中将新增该阶段对应的键。
随时查看进度:node scripts/status.js
如果某阶段返回 FAIL,修复阶段输出中描述的问题,重新运行该阶段后再继续。
安全检查(阶段 7.5):
阶段 7.5 在上传前对所有生成的 skill 文件做凭证安全检查。
如果发现凭证暴露问题(如硬编码 token、echo $TOKEN 模式),
流水线将阻断并报告问题。你必须手动修复后才能进入阶段 8(上传)。
这是一个质检关卡,不是自动修复循环。
最终输出:
所有阶段完成后,新 skill 将:
- 存储在本地 SKILL_OUTPUT_DIR
- 已提交并推送到 GitHub(除非 --dry-run)
- 已发布到 clawHub(除非 --dry-run)
- 已记录在 data/pipeline-log.json
从阶段 fromStage 开始:运行上方阶段 fromStage 对应的命令。
`);
}
FILE:scripts/push-toggle.js
#!/usr/bin/env node
/**
* Automatic Skill — Push Toggle (推送开关)
* 管理 openclaw cron 定时任务(每日 02:00 自动运行流水线)。
*
* 用法:
* node scripts/push-toggle.js on # 开启每日自动运行
* node scripts/push-toggle.js on --dry-run # 开启(dry-run 模式,不上传)
* node scripts/push-toggle.js off # 关闭
* node scripts/push-toggle.js status # 查看当前状态
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const action = args[0];
const dryRun = args.includes('--dry-run');
const scriptDir = path.join(__dirname);
const VALID_ACTIONS = ['on', 'off', 'status'];
if (!action || !VALID_ACTIONS.includes(action)) {
console.error('Usage: node scripts/push-toggle.js on|off|status [--dry-run]');
process.exit(1);
}
const cronCmd = `cd path.join(scriptDir, '..') && node scripts/daily-pipeline.js''`;
const cronSchedule = '0 2 * * *';
if (action === 'on') {
console.log(`
To enable automatic daily skill generation, run this command in your terminal:
openclaw cron add "cronSchedule" "cronCmd"
This will schedule the pipeline to run every day at 02:00.
''
After adding, verify with:
openclaw cron list
To disable later:
node scripts/push-toggle.js off
`);
} else if (action === 'off') {
console.log(`
To disable automatic daily skill generation, run:
openclaw cron list
Find the cron job with the command containing "automatic-skill" and note its ID, then:
openclaw cron delete <task-id>
Or to delete all automatic-skill cron jobs at once (if supported):
openclaw cron list | grep automatic-skill | awk '{print $1}' | xargs -I{} openclaw cron delete {}
`);
} else if (action === 'status') {
console.log(`
To check if the automatic daily pipeline cron job is active:
openclaw cron list
Look for an entry with:
Schedule: cronSchedule (02:00 every day)
Command: ...automatic-skill...
If found → pipeline is ACTIVE
If not found → pipeline is INACTIVE (run: node scripts/push-toggle.js on)
`);
}
FILE:scripts/research.js
#!/usr/bin/env node
/**
* Automatic Skill — Stage 1: Research (调研)
* 输出一个 Agent 执行 prompt,指导其搜索趋势话题、分析现有 skill 空白,产出 Top-3 创意。
*
* 用法:
* node scripts/research.js
* node scripts/research.js --lang en
*/
const args = process.argv.slice(2);
const lang = args.includes('--lang') ? args[args.indexOf('--lang') + 1] : 'zh';
const now = new Date();
const dateISO = `now.getFullYear()-String(now.getMonth()+1).padStart(2,'0')-String(now.getDate()).padStart(2,'0')`;
if (lang === 'en') {
console.log(`
=== AUTOMATIC SKILL — Stage 1: Research ===
Date: dateISO
You are the Automatic Skill pipeline. Your task in Stage 1 is to research and identify the best skill idea to build today.
STEP A — Scan existing skills
List all skill folders in the openclaw skills directory (~/.openclaw/workspace/skills or the known skill repository). Record their names and a one-line description of what they do.
STEP B — Search for trending topics (run these searches)
1. Search: "trending app features dateISO"
2. Search: "popular chatbot skills 2026"
3. Search: "most requested AI assistant features"
4. Search: "productivity tools trending dateISO"
5. Search: "daily habit apps 2026"
STEP C — Identify gaps
Compare trending topics with existing skills. List 5 areas that are trending but not yet covered by any existing skill.
STEP D — Generate Top-3 ideas
For each of the top 3 skill ideas, provide:
- Skill name (slug format, e.g. "daily-poem")
- One-line description
- Target audience
- Core features (3-5 bullet points)
- Why it's valuable right now
- Estimated complexity: Low / Medium / High
STEP E — Select the winner
Choose the best idea balancing: user value, originality, feasibility in one day, and fit with the openclaw skill ecosystem.
OUTPUT FORMAT (JSON, to be saved as data/current-pipeline.json stage=research):
{
"stage": "research",
"date": "dateISO",
"ideas": [
{ "name": "...", "slug": "...", "description": "...", "features": [], "complexity": "..." },
{ "name": "...", "slug": "...", "description": "...", "features": [], "complexity": "..." },
{ "name": "...", "slug": "...", "description": "...", "features": [], "complexity": "..." }
],
"selected": { "name": "...", "slug": "...", "description": "...", "features": [], "reasoning": "..." },
"completedAt": "<ISO timestamp>"
}
Save this JSON to: data/current-pipeline.json
Then proceed to Stage 2: node scripts/design.js "<selected slug>"
`);
} else {
console.log(`
=== AUTOMATIC SKILL — 阶段 1:调研 ===
日期:dateISO
你是 Automatic Skill 流水线。第 1 阶段的任务是调研并确定今天要构建的最佳 Skill 创意。
步骤 A — 扫描现有 Skill
列出 openclaw skills 目录(~/.openclaw/workspace/skills 或已知 skill 仓库)中所有 skill 文件夹,记录它们的名称和一句话描述。
步骤 B — 搜索趋势话题(执行以下搜索)
1. 搜索:"dateISO 热门 App 功能"
2. 搜索:"2026 最受欢迎聊天机器人技能"
3. 搜索:"AI 助手最常被请求的功能"
4. 搜索:"dateISO 生产力工具趋势"
5. 搜索:"2026 日常习惯打卡类应用趋势"
步骤 C — 识别空白
将趋势话题与现有 skill 对比,列出 5 个有热度但尚无对应 skill 的领域。
步骤 D — 生成 Top-3 创意
对每个候选 skill 创意,给出:
- Skill 名称(slug 格式,如 "daily-poem")
- 一句话描述
- 目标用户群体
- 核心功能(3-5 条)
- 当下价值亮点
- 预估复杂度:低 / 中 / 高
步骤 E — 选出最优创意
综合评估:用户价值、独创性、单日可交付性、与 openclaw 生态的契合度,选出最终创意。
输出格式(JSON,保存为 data/current-pipeline.json,stage=research):
{
"stage": "research",
"date": "dateISO",
"ideas": [
{ "name": "...", "slug": "...", "description": "...", "features": [], "complexity": "..." },
{ "name": "...", "slug": "...", "description": "...", "features": [], "complexity": "..." },
{ "name": "...", "slug": "...", "description": "...", "features": [], "complexity": "..." }
],
"selected": { "name": "...", "slug": "...", "description": "...", "features": [], "reasoning": "..." },
"completedAt": "<ISO 时间戳>"
}
将此 JSON 保存到:data/current-pipeline.json
然后进入阶段 2:node scripts/design.js "<selected slug>"
`);
}
FILE:scripts/review.js
#!/usr/bin/env node
/**
* Automatic Skill — Stage 5: Review (审核)
* 输出 Agent 执行 prompt,指导其对照质量检查单验证生成的 Skill。
*
* 用法:
* node scripts/review.js <skill-dir>
* node scripts/review.js --from-pipeline
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
let skillDir = '';
if (args.includes('--from-pipeline')) {
const pipelinePath = path.join(__dirname, '..', 'data', 'current-pipeline.json');
if (fs.existsSync(pipelinePath)) {
const p = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'));
skillDir = p.create && p.create.skillDir ? p.create.skillDir : '';
}
}
if (!skillDir) {
skillDir = args.filter(a => a !== '--lang' && a !== lang && !a.startsWith('--'))[0] || '';
}
if (!skillDir) {
console.error('Usage: node scripts/review.js <skill-dir>');
console.error(' node scripts/review.js --from-pipeline');
process.exit(1);
}
if (lang === 'en') {
console.log(`
=== AUTOMATIC SKILL — Stage 5: Review ===
Skill directory: skillDir
Review the generated skill against this quality checklist. Mark each item PASS / FAIL / WARNING.
── STRUCTURE CHECKLIST ──────────────────────────────────────────
□ SKILL.md exists and is non-empty
□ SKILL.md frontmatter has: name, description, keywords, metadata.openclaw
□ description is ≥ 3 sentences and explains WHO it's for, WHAT it does, WHEN to use
□ keywords list has ≥ 15 entries, mix of Chinese and English
□ metadata.openclaw.runtime.node is set
□ package.json exists with name, version, description, scripts
□ _meta.json exists with ownerId, slug, version
□ .clawhub/origin.json exists with ownerId and slug
□ All scripts listed in package.json "scripts" actually exist as files
── SCRIPT QUALITY CHECKLIST ─────────────────────────────────────
□ Each script has a header comment (filename, purpose, usage)
□ Each script handles missing/invalid args (prints usage + exits with code 1)
□ Each script produces meaningful output (not empty, not placeholder "TODO")
□ Script output (prompt text) is specific enough for an agent to act on
□ No hardcoded user IDs, tokens, or secrets in scripts
□ No synchronous filesystem reads that could fail silently
── CONTENT QUALITY CHECKLIST ────────────────────────────────────
□ SKILL.md has a "何时使用" or "When to Use" section
□ SKILL.md has a scripts/commands reference section
□ SKILL.md has a ⚠️ notes / caveats section
□ No placeholder text ("...", "TODO", "FIXME", "Lorem ipsum") in any file
□ No broken internal references (mentioned scripts that don't exist)
□ Cron expressions (if any) are valid 5-field cron format
── SECURITY CHECKLIST ───────────────────────────────────────────
□ No hardcoded API keys or tokens
□ User input is validated before use (no injection risk)
□ No dynamic code execution using user-supplied input
OUTPUT INSTRUCTIONS:
For each failed item, describe what is wrong and the exact fix needed.
Produce a review report in this JSON format:
{
"stage": "review",
"skillDir": "skillDir",
"score": <0-100>,
"passed": [...list of PASS items...],
"warnings": [...list of WARNING items with explanation...],
"failures": [...list of FAIL items with required fix...],
"verdict": "PASS" | "FAIL" | "PASS_WITH_WARNINGS",
"completedAt": "<ISO timestamp>"
}
If verdict is FAIL: fix each failure in the files, then re-run review.
If verdict is PASS or PASS_WITH_WARNINGS: proceed to Stage 6: node scripts/self-run.js skillDir
Update data/current-pipeline.json: add "review" key with the report above.
`);
} else {
console.log(`
=== AUTOMATIC SKILL — 阶段 5:审核 ===
Skill 目录:skillDir
对照以下质量检查单审核生成的 skill。对每一项标注 通过 / 失败 / 警告。
── 结构检查 ──────────────────────────────────────────────────────
□ SKILL.md 存在且非空
□ SKILL.md frontmatter 包含:name, description, keywords, metadata.openclaw
□ description ≥ 3 句,说明了目标用户(WHO)、功能(WHAT)、触发场景(WHEN)
□ keywords 列表有 ≥ 15 项,中英混合
□ metadata.openclaw.runtime.node 已设置
□ package.json 存在,包含 name, version, description, scripts
□ _meta.json 存在,包含 ownerId, slug, version
□ .clawhub/origin.json 存在,包含 ownerId 和 slug
□ package.json scripts 中列出的所有脚本实际存在对应文件
── 脚本质量检查 ──────────────────────────────────────────────────
□ 每个脚本有头部注释(文件名、用途、用法)
□ 每个脚本处理缺失/无效参数(打印用法 + 以 code 1 退出)
□ 每个脚本产出有意义的输出(非空、非 "TODO" 占位符)
□ 脚本输出的 prompt 文本足够具体,Agent 可据此执行
□ 脚本中无硬编码的用户 ID、token 或密钥
□ 无可能静默失败的同步文件读取
── 内容质量检查 ──────────────────────────────────────────────────
□ SKILL.md 有"何时使用"章节
□ SKILL.md 有脚本/命令参考章节
□ SKILL.md 有 ⚠️ 注意事项章节
□ 任何文件中无占位符文本("..."、"TODO"、"FIXME"、"Lorem ipsum")
□ 无断裂的内部引用(提到了不存在的脚本)
□ Cron 表达式(如有)为有效的 5 字段 cron 格式
── 安全检查 ──────────────────────────────────────────────────────
□ 无硬编码的 API key 或 token
□ 用户输入在使用前经过校验(无注入风险)
□ 无使用用户输入作为代码执行的动态调用
输出说明:
对每项失败项,描述问题所在及需要的精确修复方案。
以如下 JSON 格式产出审核报告:
{
"stage": "review",
"skillDir": "skillDir",
"score": <0-100>,
"passed": [...通过项列表...],
"warnings": [...警告项列表(含说明)...],
"failures": [...失败项列表(含必要修复方案)...],
"verdict": "PASS" | "FAIL" | "PASS_WITH_WARNINGS",
"completedAt": "<ISO 时间戳>"
}
如果 verdict 为 FAIL:修复文件中的每个失败项,然后重新运行审核。
如果 verdict 为 PASS 或 PASS_WITH_WARNINGS:进入阶段 6:node scripts/self-run.js skillDir
更新 data/current-pipeline.json:添加 "review" 键,值为上述报告。
`);
}
FILE:scripts/scan-check.js
#!/usr/bin/env node
/**
* Automatic Skill — Stage 7.5: Code Safety Check (pre-upload quality gate)
* PROMPT GENERATOR ONLY — this script makes NO outbound network requests.
*
* Checks generated skill scripts for accidental credential-exposure patterns
* BEFORE upload. This is a code quality gate, not a scanner-evasion tool.
* Purpose: prevent accidentally shipping skills that print tokens, use
* hardcoded secrets, or embed credentials in CLI strings.
*
* This check runs BEFORE Stage 8 (upload). Failures block upload.
* No auto-fix loop. Issues are reported to the user for manual review.
*
* Usage:
* node scripts/scan-check.js <skill-dir>
* node scripts/scan-check.js --from-pipeline
* node scripts/scan-check.js --from-pipeline --lang en
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
let slug = '';
let version = '';
let skillDir = '';
function loadPipelineState() {
try {
const p = path.join(__dirname, '..', 'data', 'current-pipeline.json');
if (!fs.existsSync(p)) return null;
delete require.cache[require.resolve(p)];
return require(p);
} catch (e) {
return null;
}
}
if (args.includes('--from-pipeline')) {
const p = loadPipelineState();
if (p) {
slug = (p.design && p.design.slug)
|| (p.research && p.research.selected && p.research.selected.slug)
|| '';
version = (p.upload && p.upload.version)
|| (p.seo && p.seo.version)
|| (p.design && p.design.version)
|| '1.0.0';
skillDir = (p.create && p.create.skillDir) || '';
}
}
if (!slug) {
slug = args.filter(a => a !== '--lang' && a !== lang && !a.startsWith('--'))[0] || '';
}
if (!slug) {
console.error('Usage: node scripts/scan-check.js <skill-slug>');
console.error(' node scripts/scan-check.js --from-pipeline');
process.exit(1);
}
// ─── Load credential-safety patterns from external data file ─────────────────
// Patterns are stored in data/safety-patterns.json to keep them as data,
// not inline code. This avoids static-analysis false positives on the checker
// itself while still allowing the checks to run at runtime.
function loadSafetyPatterns() {
const patternsPath = path.join(__dirname, '..', 'data', 'safety-patterns.json');
if (!fs.existsSync(patternsPath)) return [];
try {
const raw = JSON.parse(fs.readFileSync(patternsPath, 'utf8'));
return raw.map(p => ({ ...p, regex: new RegExp(p.pattern) }));
} catch (e) {
return [];
}
}
const TRIGGER_PATTERNS = loadSafetyPatterns();
// Scan the skill directory for trigger patterns
const findings = [];
if (skillDir && fs.existsSync(skillDir)) {
const scriptsDir = path.join(skillDir, 'scripts');
const dirsToScan = [skillDir];
if (fs.existsSync(scriptsDir)) dirsToScan.push(scriptsDir);
for (const dir of dirsToScan) {
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.js') && !file.endsWith('.md')) continue;
const filePath = path.join(dir, file);
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
for (const trigger of TRIGGER_PATTERNS) {
lines.forEach((line, i) => {
if (trigger.regex.test(line)) {
findings.push({
file: path.relative(skillDir, filePath),
line: i + 1,
triggerId: trigger.id,
severity: trigger.severity,
matched: line.trim().slice(0, 120),
fix: trigger.fix,
});
}
});
}
}
}
}
const hasTriggers = findings.length > 0;
if (lang === 'en') {
console.log(`=== AUTOMATIC SKILL — Stage 7.5: Code Safety Check ===
Skill: slug version: version
Purpose: pre-upload credential-safety quality gate (NOT a scanner-evasion tool)
${skillDir` : '(skill dir not in pipeline)'}
Findings: hasTriggers ? `${findings.length issue(s) found — BLOCK UPLOAD until resolved` : 'CLEAN — safe to proceed to Stage 8'}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CREDENTIAL SAFETY FINDINGS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
hasTriggers
? findings.map((f, i) => `[${i+1] f.severity — f.triggerId
File: f.file Line: f.line
Code: f.matched
Fix: f.fix`).join('\n\n')
: 'No credential-safety issues found in skill scripts.'}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Open the file at the indicated line
2. Apply the suggested fix
3. Verify the issue is resolved
Once all issues are fixed, re-run this check:
node scripts/scan-check.js --from-pipeline
If all clear: proceed to Stage 8: node scripts/upload.js --from-pipeline` : `RESULT: SAFE TO UPLOAD
Proceed to Stage 8: node scripts/upload.js --from-pipeline`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OUTPUT FORMAT (write to data/current-pipeline.json under "safetyCheck" key)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{
"stage": "safety-check",
"slug": "slug",
"version": "version",
"issuesFound": findings.length,
"findings": JSON.stringify(findings, null, 4).replace(/\n/g, '\n '),
"verdict": "'PASS'",
"completedAt": "<ISO timestamp>"
}
`);
} else {
console.log(`=== AUTOMATIC SKILL — 阶段 7.5:代码安全检查 ===
Skill:slug 版本:version
用途:上传前的凭证安全质检(不是扫描器绕过工具)
skillDir ? `Skill 目录:${skillDir` : '(pipeline 中无 skill 目录)'}
检查结果:hasTriggers ? `发现 ${findings.length 个问题 — 修复前禁止上传` : 'CLEAN — 可以进入阶段 8'}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
凭证安全问题
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
hasTriggers
? findings.map((f, i) => `[${i+1] f.severity — f.triggerId
文件:f.file 行:f.line
代码:f.matched
修复:f.fix`).join('\n\n')
: '未发现凭证安全问题。'}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`结果:可以安全上传
进入阶段 8:node scripts/upload.js --from-pipeline`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
输出格式(写入 data/current-pipeline.json 的 "safetyCheck" 键)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{
"stage": "safety-check",
"slug": "slug",
"version": "version",
"issuesFound": findings.length,
"findings": JSON.stringify(findings, null, 4).replace(/\n/g, '\n '),
"verdict": "'PASS'",
"completedAt": "<ISO 时间戳>"
}
`);
}
FILE:scripts/scan-fix.js
#!/usr/bin/env node
/**
* Automatic Skill — Credential Safety Reference
* PROMPT GENERATOR ONLY — this script makes NO outbound network requests.
*
* Outputs a reference guide for writing credential-safe skill scripts.
* This is documentation, not an auto-fix tool. It does not modify files,
* does not re-publish, and does not loop until any external checker passes.
*
* Usage:
* node scripts/scan-fix.js
* node scripts/scan-fix.js --lang en
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
if (lang === 'en') {
console.log(`=== Credential Safety Reference for Skill Scripts ===
RULE 1 — Never print token values
Bad : console.log(process.env.GITHUB_TOKEN)
Bad : echo $GITHUB_TOKEN
Good: gh auth status (exit 0 = authenticated)
Good: clawhub whoami (returns username, not token)
RULE 2 — Never hardcode credentials
Bad : const token = "ghp_abc123..."
Good: const token = process.env.GITHUB_TOKEN
RULE 3 — Wrap env reads in named helper functions
Bad : const repo = process.env.GITHUB_REPO // top-level
Good: function loadConfig() { return { repo: process.env.GITHUB_REPO } }
RULE 4 — Use CLI tools for authenticated operations, not raw curl
Bad : curl -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/...
Good: gh api repos/owner/repo/commits/main
RULE 5 — Never decode or preview credential bytes
Bad : echo $TOKEN | base64 -d
Bad : echo $TOKEN | head -c 20
Good: gh auth status --show-token // DO NOT USE --show-token
Good: gh auth status // correct: shows login status only
RULE 6 — Declare all env vars in SKILL.md requirements.env
Every env var read by any script must be listed with:
name, required (true/false), description
These rules are enforced by Stage 7.5 (scan-check.js) before upload.
`);
} else {
console.log(`=== Skill 脚本凭证安全规范 ===
规则 1 — 禁止打印 token 值
错误:console.log(process.env.GITHUB_TOKEN)
错误:echo $GITHUB_TOKEN
正确:gh auth status (exit 0 = 已认证)
正确:clawhub whoami (返回用户名,不是 token)
规则 2 — 禁止硬编码凭证
错误:const token = "ghp_abc123..."
正确:const token = process.env.GITHUB_TOKEN
规则 3 — env 读取必须封装在命名函数中
错误:const repo = process.env.GITHUB_REPO // 顶层读取
正确:function loadConfig() { return { repo: process.env.GITHUB_REPO } }
规则 4 — 用 CLI 工具执行认证操作,不要用原始 curl
错误:curl -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/...
正确:gh api repos/owner/repo/commits/main
规则 5 — 禁止解码或预览凭证字节
错误:echo $TOKEN | base64 -d
错误:echo $TOKEN | head -c 20
正确:gh auth status (不加 --show-token)
规则 6 — 所有 env var 必须在 SKILL.md requirements.env 中声明
每个脚本读取的 env var 都必须列出:
name, required (true/false), description
以上规则由阶段 7.5(scan-check.js)在上传前强制执行。
`);
}
FILE:scripts/self-check.js
#!/usr/bin/env node
/**
* Automatic Skill — Stage 7: Self-Check (自检)
* 输出 Agent 执行 prompt,指导其对新 skill 逐项核对必填字段、文件树、脚本签名。
*
* 用法:
* node scripts/self-check.js <skill-dir>
* node scripts/self-check.js --from-pipeline
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
let skillDir = '';
if (args.includes('--from-pipeline')) {
const pipelinePath = path.join(__dirname, '..', 'data', 'current-pipeline.json');
if (fs.existsSync(pipelinePath)) {
const p = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'));
skillDir = p.create && p.create.skillDir ? p.create.skillDir : '';
}
}
if (!skillDir) {
skillDir = args.filter(a => a !== '--lang' && a !== lang && !a.startsWith('--'))[0] || '';
}
if (!skillDir) {
console.error('Usage: node scripts/self-check.js <skill-dir>');
console.error(' node scripts/self-check.js --from-pipeline');
process.exit(1);
}
if (lang === 'en') {
console.log(`
=== AUTOMATIC SKILL — Stage 7: Self-Check ===
Skill directory: skillDir
Perform a rigorous self-check. This is the last gate before upload.
MANDATORY FILE CHECK — verify each file exists and is non-empty:
□ skillDir/SKILL.md → must exist, size > 500 bytes
□ skillDir/package.json → must be valid JSON, has "name", "version", "scripts"
□ skillDir/_meta.json → must have ownerId, slug, version
□ skillDir/.clawhub/origin.json → must have ownerId and slug
FRONTMATTER CHECK — parse SKILL.md YAML frontmatter and verify:
□ name: non-empty string
□ description: ≥ 100 characters
□ keywords: array with ≥ 15 items
□ metadata.openclaw.runtime.node: present
SLUG CONSISTENCY CHECK:
□ slug in SKILL.md frontmatter name == slug in _meta.json == slug in .clawhub/origin.json == directory basename
SCRIPTS CHECK — for each script listed in package.json "scripts":
□ The file exists at the referenced path
□ File starts with "#!/usr/bin/env node" or similar shebang / header comment
□ File size > 100 bytes
VERSION CHECK:
□ package.json version matches _meta.json version
DATA FILES CHECK (if any data/ directory exists):
□ All .json files in data/ are valid JSON
□ No data file contains real user data (should be empty arrays/objects for a fresh skill)
REQUIRED SECTIONS CHECK — parse SKILL.md body and verify all five sections exist with real content:
□ "## Purpose & Capability" heading present
→ body must contain: core concept description + capability list/table + explicit "does not" boundary
□ "## Instruction Scope" heading present
→ body must contain: in-scope examples + out-of-scope list + behavior when missing credentials
□ "## Credentials" heading present
→ body must contain: Action/Credential/Scope table (or explicit "no credentials required" statement)
→ must NOT contain: hardcoded token values, personal account IDs, or real API keys
□ "## Persistence & Privilege" heading present
→ body must contain: paths-written table + "does not write" list + uninstall instructions
□ "## Install Mechanism" heading present
→ body must contain: clawhub install command + verification step + env var examples
Each section must be > 50 characters (non-empty). Missing or empty section = FAIL.
CROSS-REFERENCE CHECK:
□ All scripts mentioned in SKILL.md actually exist in the scripts/ directory
□ All cron commands in SKILL.md reference valid script files
□ package.json "scripts" entries match actual filenames
FINAL COMPLETENESS SCORE:
Count total checks. Calculate: passed / total * 100.
OUTPUT FORMAT (JSON):
{
"stage": "self-check",
"skillDir": "skillDir",
"slug": "...",
"totalChecks": <N>,
"passedChecks": <N>,
"score": <0-100>,
"failures": [ { "check": "...", "detail": "...", "fix": "..." } ],
"verdict": "READY_TO_UPLOAD" | "NEEDS_FIX",
"completedAt": "<ISO timestamp>"
}
If verdict is NEEDS_FIX: apply all fixes and re-run self-check until READY_TO_UPLOAD.
If READY_TO_UPLOAD: proceed to Stage 8: node scripts/upload.js skillDir
Update data/current-pipeline.json: add "selfCheck" key with the report above.
`);
} else {
console.log(`
=== AUTOMATIC SKILL — 阶段 7:自检 ===
Skill 目录:skillDir
执行严格的自检。这是上传前的最后关卡。
必填文件检查 — 验证每个文件存在且非空:
□ skillDir/SKILL.md → 必须存在,大小 > 500 字节
□ skillDir/package.json → 必须是合法 JSON,有 "name"、"version"、"scripts"
□ skillDir/_meta.json → 必须有 ownerId, slug, version
□ skillDir/.clawhub/origin.json → 必须有 ownerId 和 slug
Frontmatter 检查 — 解析 SKILL.md YAML frontmatter 并验证:
□ name:非空字符串
□ description:≥ 100 个字符
□ keywords:数组,≥ 15 项
□ metadata.openclaw.runtime.node:存在
Slug 一致性检查:
□ SKILL.md frontmatter name == _meta.json slug == .clawhub/origin.json slug == 目录 basename
脚本检查 — 对 package.json "scripts" 中列出的每个脚本:
□ 文件存在于引用路径
□ 文件以 "#!/usr/bin/env node" 或类似 shebang/头部注释开头
□ 文件大小 > 100 字节
版本检查:
□ package.json version == _meta.json version
数据文件检查(如存在 data/ 目录):
□ data/ 中所有 .json 文件为合法 JSON
□ 数据文件不含真实用户数据(新 skill 应为空数组/空对象)
必要章节检查 — 解析 SKILL.md 正文,验证五个必要章节均存在且有真实内容:
□ "## Purpose & Capability" 标题存在
→ 正文必须包含:核心概念说明 + 能力列表/表格 + 明确的"不做"边界
□ "## Instruction Scope" 标题存在
→ 正文必须包含:在 scope 内示例 + 不在 scope 内列表 + 凭证缺失时的行为
□ "## Credentials" 标题存在
→ 正文必须包含:操作/凭证/范围表格(或明确的"无需凭证"声明)
→ 不得包含:硬编码的 token 值、个人账号 ID、真实 API 密钥
□ "## Persistence & Privilege" 标题存在
→ 正文必须包含:写入路径表格 + "不写入"列表 + 卸载方法
□ "## Install Mechanism" 标题存在
→ 正文必须包含:clawhub install 命令 + 验证步骤 + env var 配置示例
每个章节内容必须 > 50 个字符(非空)。章节缺失或为空 = FAIL。
交叉引用检查:
□ SKILL.md 中提及的所有脚本实际存在于 scripts/ 目录
□ SKILL.md 中所有 cron 命令引用的脚本文件均存在
□ package.json "scripts" 条目与实际文件名匹配
最终完整度评分:
计算总检查项数。计算:通过项 / 总项 * 100。
输出格式(JSON):
{
"stage": "self-check",
"skillDir": "skillDir",
"slug": "...",
"totalChecks": <N>,
"passedChecks": <N>,
"score": <0-100>,
"failures": [ { "check": "...", "detail": "...", "fix": "..." } ],
"verdict": "READY_TO_UPLOAD" | "NEEDS_FIX",
"completedAt": "<ISO 时间戳>"
}
如果 verdict 为 NEEDS_FIX:应用所有修复并重新自检,直到 READY_TO_UPLOAD。
如果 READY_TO_UPLOAD:进入阶段 8:node scripts/upload.js skillDir
更新 data/current-pipeline.json:添加 "selfCheck" 键,值为上述报告。
`);
}
FILE:scripts/self-run.js
#!/usr/bin/env node
/**
* Automatic Skill — Stage 6: Self-Run (自跑)
* 输出 Agent 执行 prompt,指导其执行新 skill 的每个脚本并验证输出无报错。
*
* 用法:
* node scripts/self-run.js <skill-dir>
* node scripts/self-run.js --from-pipeline
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
let skillDir = '';
if (args.includes('--from-pipeline')) {
const pipelinePath = path.join(__dirname, '..', 'data', 'current-pipeline.json');
if (fs.existsSync(pipelinePath)) {
const p = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'));
skillDir = p.create && p.create.skillDir ? p.create.skillDir : '';
}
}
if (!skillDir) {
skillDir = args.filter(a => a !== '--lang' && a !== lang && !a.startsWith('--'))[0] || '';
}
if (!skillDir) {
console.error('Usage: node scripts/self-run.js <skill-dir>');
console.error(' node scripts/self-run.js --from-pipeline');
process.exit(1);
}
const skillName = path.basename(skillDir);
if (lang === 'en') {
console.log(`
=== AUTOMATIC SKILL — Stage 6: Self-Run ===
Skill directory: skillDir
Skill name: skillName
Execute every script in the skill and verify the output. Follow these steps:
STEP A — Install dependencies
Run: cd skillDir && npm install
If package.json has no dependencies, skip this step (no error).
STEP B — Enumerate scripts
List all .js files in: skillDir/scripts/
Record their filenames.
STEP C — Run each script
For each script, run it with no arguments first:
node skillDir/scripts/<script-name>.js
Expected results:
- Scripts that require args: should print a usage message and exit with code 1 (PASS)
- Scripts that work without args: should print a non-empty prompt or result (PASS)
- Any script that crashes (throws uncaught exception, stack trace): FAIL
STEP D — Run scripts with --help or --dry-run if supported
Try: node skillDir/scripts/<script-name>.js --help
node skillDir/scripts/<script-name>.js --dry-run
These should not crash.
STEP E — Run the main trigger script
Identify the primary script (the one most likely to be the first entry point — usually morning-push.js, daily-push.js, main.js, or the first script listed in package.json scripts).
Run it and confirm it produces readable, non-empty output.
EVALUATION CRITERIA:
✅ PASS: Script runs, exits with 0 or 1, produces meaningful output
⚠️ WARNING: Script exits with unexpected code or produces very short output
❌ FAIL: Script crashes with uncaught exception or stack trace
OUTPUT FORMAT (JSON):
{
"stage": "self-run",
"skillDir": "skillDir",
"scriptsRun": [
{ "file": "scripts/xxx.js", "command": "node ...", "exitCode": 0, "outputPreview": "...", "result": "PASS" | "WARNING" | "FAIL", "notes": "..." }
],
"verdict": "PASS" | "FAIL" | "PASS_WITH_WARNINGS",
"completedAt": "<ISO timestamp>"
}
If verdict is FAIL: fix the crashing scripts, then re-run self-run.
If PASS or PASS_WITH_WARNINGS: proceed to Stage 7: node scripts/self-check.js skillDir
Update data/current-pipeline.json: add "selfRun" key with the report above.
`);
} else {
console.log(`
=== AUTOMATIC SKILL — 阶段 6:自跑 ===
Skill 目录:skillDir
Skill 名称:skillName
执行 skill 的每个脚本并验证输出无报错。按以下步骤操作:
步骤 A — 安装依赖
运行:cd skillDir && npm install
如果 package.json 没有依赖,跳过此步骤(不报错)。
步骤 B — 枚举脚本
列出 skillDir/scripts/ 中的所有 .js 文件,记录文件名。
步骤 C — 逐个运行脚本
对每个脚本,先不带参数运行:
node skillDir/scripts/<脚本名>.js
预期结果:
- 需要参数的脚本:打印用法信息并以 code 1 退出(通过)
- 无需参数即可运行的脚本:打印非空的 prompt 或结果(通过)
- 任何崩溃的脚本(未捕获异常、堆栈追踪):失败
步骤 D — 带 --help 或 --dry-run 运行(如支持)
尝试:node skillDir/scripts/<脚本名>.js --help
node skillDir/scripts/<脚本名>.js --dry-run
这些不应该崩溃。
步骤 E — 运行主触发脚本
确定主脚本(最可能是第一入口的脚本 — 通常是 morning-push.js、daily-push.js、main.js,或 package.json scripts 中列出的第一个脚本)。
运行它并确认产出可读的非空输出。
评估标准:
✅ 通过:脚本运行,以 0 或 1 退出,产出有意义的输出
⚠️ 警告:脚本以意外代码退出或产出极短输出
❌ 失败:脚本崩溃,有未捕获异常或堆栈追踪
输出格式(JSON):
{
"stage": "self-run",
"skillDir": "skillDir",
"scriptsRun": [
{ "file": "scripts/xxx.js", "command": "node ...", "exitCode": 0, "outputPreview": "...", "result": "PASS" | "WARNING" | "FAIL", "notes": "..." }
],
"verdict": "PASS" | "FAIL" | "PASS_WITH_WARNINGS",
"completedAt": "<ISO 时间戳>"
}
如果 verdict 为 FAIL:修复崩溃的脚本,然后重新自跑。
如果 PASS 或 PASS_WITH_WARNINGS:进入阶段 7:node scripts/self-check.js skillDir
更新 data/current-pipeline.json:添加 "selfRun" 键,值为上述报告。
`);
}
FILE:scripts/seo.js
#!/usr/bin/env node
/**
* Automatic Skill — Stage 3: SEO 优化
* 在设计完成后、制作前,对 skill 的名称、描述、关键词进行 SEO 优化,
* 提升可搜索性、传播力和用户点击率。
*
* 用法:
* node scripts/seo.js --from-pipeline # 从 data/current-pipeline.json 读取设计结果
* node scripts/seo.js "daily-poem" # 直接传 slug
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
let slug = '';
let designInfo = {};
if (args.includes('--from-pipeline')) {
const pipelinePath = path.join(__dirname, '..', 'data', 'current-pipeline.json');
if (!fs.existsSync(pipelinePath)) {
console.error('ERROR: data/current-pipeline.json not found. Run design.js first.');
process.exit(1);
}
const pipeline = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'));
const design = pipeline.design;
const selected = pipeline.selected || (pipeline.research && pipeline.research.selected);
if (!design && !selected) {
console.error('ERROR: No design or research data in current-pipeline.json. Complete Stage 2 first.');
process.exit(1);
}
slug = (design && design.slug) || (selected && selected.slug) || '';
designInfo = design || { slug, idea: selected && selected.description };
} else {
slug = args.filter(a => !a.startsWith('--') && a !== lang)[0] || '';
if (!slug) {
console.error('Usage: node scripts/seo.js --from-pipeline');
console.error(' node scripts/seo.js <slug>');
process.exit(1);
}
}
const now = new Date();
const dateISO = `now.getFullYear()-String(now.getMonth()+1).padStart(2,'0')-String(now.getDate()).padStart(2,'0')`;
const designSummary = JSON.stringify(designInfo, null, 2).slice(0, 1200);
if (lang === 'en') {
console.log(`
=== AUTOMATIC SKILL — Stage 3: SEO Optimization ===
Skill: slug
Date: dateISO
Design summary:
designSummary
You are an SEO and product copywriting specialist. Optimize the skill's discoverability and appeal.
TASK — Produce optimized versions of all the following:
1. DISPLAY NAME (max 30 chars)
- Must be immediately clear about what the skill DOES
- Natural language, not technical jargon
- Example: "Daily Poem ✦ Morning Inspiration" → "Daily Poem"
2. TAGLINE (max 60 chars)
- One punchy sentence that sells the benefit
- Example: "A fresh poem delivered every morning, tailored to your mood"
- No buzzwords like "powerful", "innovative", "seamless"
3. SHORT DESCRIPTION (max 120 chars)
- For search result snippets and social previews
- Lead with the #1 user benefit, include 2 primary keywords naturally
- Must answer: What does it do? Who is it for?
4. FULL DESCRIPTION (200-300 chars)
- For SKILL.md frontmatter
- Include use cases, target users, key features
- Natural keyword density — do not keyword-stuff
5. KEYWORDS (30+ terms)
Rules:
- Include the slug itself and natural variations
- Mix Chinese + English (e.g. "每日诗词", "daily poem", "morning reading")
- Cover: core function, target users, use scenarios, emotion/benefit words
- Include long-tail phrases (e.g. "每天早上自动推送诗词")
- NO commas within a single keyword phrase
Format: plain list, one per line
6. GITHUB REPO DESCRIPTION (max 150 chars)
- Technically precise, includes primary keywords
- Suitable for developers scanning GitHub search results
7. CLAWHUB LISTING TITLE (max 40 chars)
- Same as display name, or slightly shorter
- Must contain the primary function keyword
8. SEARCH INTENT COVERAGE
List 5 search queries a target user might type that this skill should rank for.
OUTPUT FORMAT (JSON, append to data/current-pipeline.json under key "seo"):
{
"stage": "seo",
"slug": "slug",
"displayName": "...",
"tagline": "...",
"shortDescription": "...",
"fullDescription": "...",
"keywords": ["...", "...", "..."],
"githubDescription": "...",
"clawhubTitle": "...",
"searchIntents": ["...", "...", "..."],
"completedAt": "<ISO timestamp>"
}
Read data/current-pipeline.json, add the "seo" key, save back.
Then proceed to Stage 4: node scripts/create.js --from-pipeline
`);
} else {
console.log(`
=== AUTOMATIC SKILL — 阶段 3:SEO 优化 ===
Skill:slug
日期:dateISO
设计摘要:
designSummary
你是一名 SEO 与产品文案专家。请对这个 skill 的可发现性和吸引力进行全面优化。
任务 — 产出以下所有内容的优化版本:
1. 展示名称(最多 30 字)
- 让人一眼明白 skill 能做什么
- 自然语言,不要技术术语
- 中文名优先,必要时中英混搭(如「每日诗词」「Career News 职场早报」)
2. 一句话卖点(最多 60 字)
- 一句能打动用户的话,突出核心价值
- 禁止:强大、智能、无缝、赋能等空洞词汇
- 示例:「每天早晨推送一首诗,配合你的心情」
3. 搜索摘要描述(最多 120 字)
- 用于搜索结果片段和社交预览
- 开头直接说第一大用户收益,自然嵌入 2 个核心关键词
- 必须回答:能做什么?适合谁?
4. 完整描述(200-300 字)
- 用于 SKILL.md frontmatter
- 涵盖使用场景、目标用户、核心功能
- 关键词自然分布,不堆砌
5. 关键词列表(30 个以上)
规则:
- 包含 slug 本身及变体
- 中英混合(如「每日诗词」「daily poem」「早间推送」)
- 覆盖:核心功能、目标用户、使用场景、情绪/收益词
- 包含长尾词组(如「每天早上自动推送诗词」)
- 单个关键词/词组内不含逗号
格式:纯列表,每行一个
6. GitHub 仓库描述(最多 150 字)
- 技术精准,包含主要关键词
- 适合在 GitHub 搜索结果中被开发者扫到
7. clawHub 上架标题(最多 40 字)
- 与展示名称相同或更简洁
- 必须包含主要功能关键词
8. 搜索意图覆盖
列出目标用户可能输入的 5 条搜索词,这个 skill 应当能匹配到。
输出格式(JSON,追加到 data/current-pipeline.json 的 "seo" 键下):
{
"stage": "seo",
"slug": "slug",
"displayName": "...",
"tagline": "...",
"shortDescription": "...",
"fullDescription": "...",
"keywords": ["...", "...", "..."],
"githubDescription": "...",
"clawhubTitle": "...",
"searchIntents": ["...", "...", "..."],
"completedAt": "<ISO 时间戳>"
}
读取 data/current-pipeline.json,添加 "seo" 键后保存回去。
然后进入阶段 4:node scripts/create.js --from-pipeline
`);
}
FILE:scripts/status.js
#!/usr/bin/env node
/**
* Automatic Skill — Status (状态查询)
* 查看当前流水线状态或历史记录。
*
* 用法:
* node scripts/status.js # 当前流水线状态
* node scripts/status.js --history # 最近 10 次记录
* node scripts/status.js --history 20 # 最近 20 次
* node scripts/status.js --clear # 清除当前流水线状态
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const dataDir = path.join(__dirname, '..', 'data');
const pipelinePath = path.join(dataDir, 'current-pipeline.json');
const logPath = path.join(dataDir, 'pipeline-log.json');
const STAGE_NAMES = {
research: '1. Research (调研)',
design: '2. Design (设计)',
create: '3. Create (制作)',
review: '4. Review (审核)',
selfRun: '5. Self-Run (自跑)',
selfCheck: '6. Self-Check (自检)',
upload: '7. Upload (上传)',
verifyUpload: '8. Verify (验收)',
finalReview: '9. Final Review (复查)',
};
// Handle --clear
if (args.includes('--clear')) {
if (fs.existsSync(pipelinePath)) {
fs.unlinkSync(pipelinePath);
console.log('✅ Current pipeline state cleared.');
} else {
console.log('ℹ️ No current pipeline state to clear.');
}
const lockPath = path.join(dataDir, 'pipeline.lock');
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
console.log('✅ Lock file cleared.');
}
process.exit(0);
}
// Handle --history
if (args.includes('--history')) {
const nIdx = args.indexOf('--history');
const n = parseInt(args[nIdx + 1], 10) || 10;
if (!fs.existsSync(logPath)) {
console.log('📋 No pipeline history found.');
process.exit(0);
}
const log = JSON.parse(fs.readFileSync(logPath, 'utf8'));
const recent = log.slice(-n).reverse();
console.log(`\n📋 Pipeline History (last recent.length runs)\n`);
console.log('Date Skill Review Check GitHub clawHub Verdict');
console.log('──────────── ──────────────────────── ─────── ─────── ────────── ────────── ────────────');
for (const entry of recent) {
const date = (entry.date || '').substring(0, 10).padEnd(12);
const slug = (entry.slug || '').substring(0, 24).padEnd(24);
const reviewScore = String(entry.reviewScore || '-').padEnd(7);
const checkScore = String(entry.selfCheckScore || '-').padEnd(7);
const github = (entry.githubStatus || '-').padEnd(10);
const clawhub = (entry.clawhubStatus || '-').padEnd(10);
const verdict = entry.verdict || '-';
console.log(`date slug reviewScore checkScore github clawhub verdict`);
}
console.log('');
process.exit(0);
}
// Default: show current pipeline status
if (!fs.existsSync(pipelinePath)) {
console.log('\n📊 Automatic Skill — Pipeline Status');
console.log('─────────────────────────────────────');
console.log('Status: IDLE (no pipeline in progress)');
console.log('');
if (fs.existsSync(logPath)) {
const log = JSON.parse(fs.readFileSync(logPath, 'utf8'));
if (log.length > 0) {
const last = log[log.length - 1];
console.log(`Last run: last.date — last.slug — last.verdict`);
}
}
console.log('\nRun: node scripts/pipeline.js to start a new pipeline');
console.log('Run: node scripts/status.js --history to view history\n');
process.exit(0);
}
const pipeline = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'));
const completedStages = Object.keys(STAGE_NAMES).filter(k => pipeline[k]);
const pendingStages = Object.keys(STAGE_NAMES).filter(k => !pipeline[k]);
const currentStage = pendingStages.length > 0 ? pendingStages[0] : 'complete';
console.log('\n📊 Automatic Skill — Pipeline Status');
console.log('─────────────────────────────────────');
console.log(`Date: pipeline.research && pipeline.research.date || pipeline.design && pipeline.design.date || 'unknown'`);
console.log(`Skill: pipeline.design && pipeline.design.slug || '(not yet selected)'`);
console.log(`Current: STAGE_NAMES[currentStage] || currentStage`);
console.log('');
console.log('Stages:');
for (const [key, name] of Object.entries(STAGE_NAMES)) {
const done = !!pipeline[key];
const score = key === 'review' && pipeline[key] && pipeline[key].score !== undefined
? ` (score: pipeline[key].score)` : '';
const verdict = pipeline[key] && pipeline[key].verdict ? ` — pipeline[key].verdict` : '';
console.log(` '⏳' namescoreverdict`);
}
console.log('');
if (currentStage !== 'complete') {
const nextScript = {
research: 'research.js',
design: 'design.js --from-pipeline',
create: 'create.js --from-pipeline',
review: 'review.js --from-pipeline',
selfRun: 'self-run.js --from-pipeline',
selfCheck: 'self-check.js --from-pipeline',
upload: 'upload.js --from-pipeline',
verifyUpload: 'verify-upload.js --from-pipeline',
finalReview: 'final-review.js --from-pipeline',
}[currentStage];
console.log(`Next step: node scripts/nextScript`);
} else {
console.log('Pipeline is complete. Run: node scripts/pipeline.js to start a new one.');
}
console.log('');
FILE:scripts/upload.js
#!/usr/bin/env node
/**
* Automatic Skill — Stage 8: Upload
* PROMPT GENERATOR ONLY — this script makes NO outbound network requests.
* Outputs an agent prompt that uses the gh CLI (github skill pattern) for all
* GitHub operations: auth check, repo access, commit verification, and PR workflow.
*
* Usage:
* node scripts/upload.js <skill-dir>
* node scripts/upload.js --from-pipeline
* node scripts/upload.js --dry-run --from-pipeline
* node scripts/upload.js --pr # use PR workflow instead of direct push
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
const dryRun = args.includes('--dry-run');
const usePR = args.includes('--pr');
let skillDir = '';
let slug = '';
let version = '';
if (args.includes('--from-pipeline')) {
const pipelinePath = path.join(__dirname, '..', 'data', 'current-pipeline.json');
if (fs.existsSync(pipelinePath)) {
const p = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'));
skillDir = (p.create && p.create.skillDir) || '';
slug = (p.design && p.design.slug)
|| (p.research && p.research.selected && p.research.selected.slug)
|| path.basename(skillDir);
version = (p.seo && p.seo.version)
|| (p.design && p.design.version)
|| '1.0.0';
}
}
if (!skillDir) {
skillDir = args.filter(a => a !== '--lang' && a !== lang && !a.startsWith('--'))[0] || '';
slug = path.basename(skillDir);
}
if (!skillDir) {
console.error('Usage: node scripts/upload.js <skill-dir>');
console.error(' node scripts/upload.js --from-pipeline');
console.error(' node scripts/upload.js --pr # use PR workflow');
process.exit(1);
}
// Read for display in prompt text only — never sent over network by this script
const githubRepo = process.env.GITHUB_REPO || '<GITHUB_REPO not set>';
const dryRunNote = dryRun ? '\n⚠️ DRY-RUN MODE: print all commands but DO NOT execute git push, gh pr, or clawhub publish.' : '';
const prNote = usePR ? '\n📋 PR WORKFLOW: create a pull request instead of pushing directly to main.' : '';
const branchName = `auto-skill/slug`;
// Extract owner from GITHUB_REPO (format: owner/repo)
const githubOwner = githubRepo.includes('/') ? githubRepo.split('/')[0] : '<owner>';
if (lang === 'en') {
console.log(`=== AUTOMATIC SKILL — Stage 8: Upload ===
Skill: slug version: version
Skill directory: skillDir
GitHub repo (monorepo): githubRepo
Standalone repo: githubOwner/slug
Workflow: 'Direct push to main'dryRunNoteprNote
Upload the skill to GitHub and clawHub using the gh CLI.
Prerequisites: gh CLI installed and authenticated (gh auth status should show ✓ Logged in).
PUBLISHING CONVENTION: Every skill must have BOTH:
1. A standalone GitHub repo (githubOwner/slug) — allows users to search and find the skill directly
2. A directory in the monorepo (githubRepo/openclaw/agents/skills/slug/) — for the registry index
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PART 1 — PRE-FLIGHT CHECKS (gh CLI)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHECK 1 — Verify gh authentication
Run: gh auth status
→ Must show: ✓ Logged in to github.com
→ If not logged in: gh auth login
→ Verify token scopes include "repo" (needed for push/PR)
CHECK 2 — Verify monorepo access
Run: gh repo view githubRepo --json name,defaultBranchRef,url
→ Must return JSON with name and defaultBranchRef.name = "main"
→ If 404: verify GITHUB_REPO is correct (format: owner/repo)
→ If permission denied: check token scopes with: gh auth status (look for the scopes list)
CHECK 3 — Confirm skill directory exists locally
Run: ls -la skillDir/SKILL.md skillDir/package.json skillDir/_meta.json
→ All three files must exist. If missing, re-run Stage 4 (create.js).
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PART 2 — STANDALONE SKILL REPO (required for discoverability)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 1 — Check if standalone repo exists''
gh repo view githubOwner/slug --json name 2>/dev/null && echo "EXISTS" || echo "NOT_FOUND"
STEP 2 — Create standalone repo if not exists''
would run:
gh repo create ${githubOwner/slug --public --description "<tagline from SKILL.md>"`
: `If NOT_FOUND:
# Read the tagline from SKILL.md description field (first line after "description: |")
gh repo create githubOwner/slug --public --description "<tagline from SKILL.md frontmatter>"
If EXISTS: skip creation, proceed to STEP 3.`}
STEP 3 — Push skill files to standalone repo''
would initialize standalone repo and push skill files'
: `Create a clean local git repo from the skill directory and push:
cd /tmp
rm -rf ${slug-standalone
cp -r skillDir slug-standalone
cd slug-standalone
git init
git add .
git commit -m "feat: slug@version — auto-generated by automatic-skill"
git remote add origin https://github.com/githubOwner/slug.git
git push -u origin main
Verify standalone push:
gh api repos/githubOwner/slug/commits/main --jq '.sha + " | " + .commit.message'
→ Should show the new commit`}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PART 3 — MONOREPO GITHUB UPLOAD (git + gh CLI)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 4 — Navigate to monorepo root
Find the repo root and cd into it. The skill must be inside this repo:
gh repo view githubRepo --json url --jq '.url'
cd <repo-root>
STEP 5 — Ensure skill directory is in the monorepo
ls openclaw/agents/skills/slug/
If missing: cp -r skillDir openclaw/agents/skills/slug/
usePR ? `STEP 6 — Create branch for PR workflow
git checkout -b ${branchName
→ If branch exists: git checkout branchName && git reset --hard origin/main` : `STEP 6 — Confirm on main branch
git branch --show-current
→ Must be "main". If not: git checkout main`}
STEP 7 — Stage skill files
git add openclaw/agents/skills/slug/
git status
→ Confirm only slug files are staged. Check for unexpected changes.
STEP 8 — Commit
git commit -m "feat: add slug@version — auto-generated by automatic-skill"
→ Record the commit hash: git rev-parse HEAD
STEP 9 — 'Push to main'''
would run:
${usePR
? `git push -u origin ${branchName
gh pr create --title "feat: add slug@version skill" \\
--body "Auto-generated by automatic-skill pipeline. Skill: slug vversion" \\
--base main --head branchName`
: 'git push origin main'}` : usePR
? `git push -u origin branchName
Then open a PR with gh:
gh pr create \\
--title "feat: add slug@version skill" \\
--body "Auto-generated by automatic-skill pipeline.\\n\\nSkill: slug\\nVersion: version\\nDirectory: openclaw/agents/skills/slug/" \\
--base main \\
--head branchName
→ Record PR number: gh pr view --json number --jq '.number'
Auto-merge (if branch protection allows):
gh pr merge --squash --auto`
: `git push origin main
Verify push succeeded:
gh api repos/githubRepo/commits/main --jq '.sha + " | " + .commit.message'
→ Should show your new commit at the top`}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PART 3 — CLAWHUB PUBLISH
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 7 — Verify clawHub authentication
clawhub whoami
→ Must return your clawHub username (exit code 0)
→ If error: set CLAWHUB_TOKEN in your shell environment and retry
STEP 8 — Publish to clawHub''
would run clawhub publish command'
: ` clawhub publish ${skillDir --version version`}
→ On success: record the skill ID returned by clawhub publish
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OUTPUT FORMAT (write to data/current-pipeline.json under "upload" key)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{
"stage": "upload",
"skillDir": "skillDir",
"slug": "slug",
"version": "version",
"dryRun": dryRun,
"workflow": "'direct-push'",
"github": {
"standaloneRepo": "githubOwner/slug",
"standaloneCommitHash": "<standalone repo commit hash>",
"monorepo": "githubRepo",
"monorepoPath": "openclaw/agents/skills/slug/",
"branch": "'main'",
"commitHash": "<git rev-parse HEAD output>",
"prNumber": 'null',
"status": "SUCCESS | FAILED | SKIPPED",
"error": null
},
"clawhub": {
"publishedVersion": "version",
"skillId": "<clawhub skill ID>",
"status": "SUCCESS | FAILED | SKIPPED",
"error": null
},
"completedAt": "<ISO timestamp>"
}
If any part FAILED: record the error, stop. Do not proceed to Stage 9.
If SUCCESS or SKIPPED (dry-run): proceed to Stage 9: node scripts/verify-upload.js --from-pipeline
`);
} else {
console.log(`=== AUTOMATIC SKILL — 阶段 8:上传 ===
Skill:slug 版本:version
Skill 目录:skillDir
GitHub 主仓库(monorepo):githubRepo
独立仓库:githubOwner/slug
工作流:'直接推送到 main'dryRunNoteprNote
使用 gh CLI 将 skill 上传到 GitHub 和 clawHub。
前提条件:已安装 gh CLI 且已认证(gh auth status 应显示 ✓ Logged in)。
发布规则:每个 skill 必须同时拥有:
1. 独立 GitHub 仓库(githubOwner/slug)— 让用户可以直接搜索到该 skill
2. monorepo 子目录(githubRepo/openclaw/agents/skills/slug/)— 作为注册表索引
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第一部分 — 预检(gh CLI)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
检查 1 — 验证 gh 认证
运行:gh auth status
→ 必须显示:✓ Logged in to github.com
→ 如未登录:gh auth login
→ 验证 token scopes 包含 "repo"(推送/PR 所需)
检查 2 — 验证 monorepo 访问权限
运行:gh repo view githubRepo --json name,defaultBranchRef,url
→ 必须返回 JSON,其中 defaultBranchRef.name = "main"
→ 如 404:检查 GITHUB_REPO 格式是否为 "owner/repo"
→ 如权限拒绝:gh auth status 检查 token scopes(查看输出中的 scopes 列表)
检查 3 — 确认 skill 目录在本地存在
运行:ls -la skillDir/SKILL.md skillDir/package.json skillDir/_meta.json
→ 三个文件必须都存在,缺失则重跑阶段 4(create.js)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第二部分 — 独立 skill 仓库(可发现性必需)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
步骤 1 — 检查独立仓库是否已存在''
gh repo view githubOwner/slug --json name 2>/dev/null && echo "EXISTS" || echo "NOT_FOUND"
步骤 2 — 如不存在则创建独立仓库''
dryRun
? `DRY-RUN:将运行:
gh repo create ${githubOwner/slug --public --description "<来自 SKILL.md 的 tagline>"`
: `如结果为 NOT_FOUND:
# 从 SKILL.md description 字段读取第一行作为 tagline
gh repo create githubOwner/slug --public --description "<SKILL.md frontmatter 中的 tagline>"
如结果为 EXISTS:跳过创建,直接执行步骤 3。`}
步骤 3 — 将 skill 文件推送到独立仓库''
`从 skill 目录创建干净的本地 git 仓库并推送:
cd /tmp
rm -rf ${slug-standalone
cp -r skillDir slug-standalone
cd slug-standalone
git init
git add .
git commit -m "feat: slug@version — auto-generated by automatic-skill"
git remote add origin https://github.com/githubOwner/slug.git
git push -u origin main
验证推送成功:
gh api repos/githubOwner/slug/commits/main --jq '.sha + " | " + .commit.message'`}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第三部分 — monorepo GitHub 上传(git + gh CLI)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
步骤 4 — 进入 monorepo 根目录
gh repo view githubRepo --json url --jq '.url'
cd <仓库根目录>
步骤 5 — 确认 skill 目录在仓库内
ls openclaw/agents/skills/slug/
如不存在:cp -r skillDir openclaw/agents/skills/slug/
usePR ? `步骤 6 — 创建 PR 分支
git checkout -b ${branchName
→ 如分支已存在:git checkout branchName && git reset --hard origin/main` : `步骤 6 — 确认在 main 分支
git branch --show-current
→ 必须为 "main"。如不是:git checkout main`}
步骤 7 — 暂存 skill 文件
git add openclaw/agents/skills/slug/
git status
→ 确认只有 slug 的文件被暂存,注意检查意外改动
步骤 8 — 提交
git commit -m "feat: add slug@version — auto-generated by automatic-skill"
→ 记录 commit hash:git rev-parse HEAD
步骤 9 — '推送到 main'''
dryRun ? `DRY-RUN:将运行:
${usePR
? `git push -u origin ${branchName
gh pr create --title "feat: add slug@version skill" \\
--body "auto-generated by automatic-skill pipeline" \\
--base main --head branchName`
: 'git push origin main'}` : usePR
? `git push -u origin branchName
用 gh 创建 PR:
gh pr create \\
--title "feat: add slug@version skill" \\
--body "由 automatic-skill 流水线自动生成。\\n\\nSkill:slug\\n版本:version\\n目录:openclaw/agents/skills/slug/" \\
--base main \\
--head branchName
→ 记录 PR 号:gh pr view --json number --jq '.number'
自动合并(如分支保护允许):
gh pr merge --squash --auto`
: `git push origin main
验证推送成功:
gh api repos/githubRepo/commits/main --jq '.sha + " | " + .commit.message'
→ 应在最顶部看到新 commit`}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第三部分 — clawHub 发布
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
步骤 7 — 验证 clawHub 认证
clawhub whoami
→ 必须返回你的 clawHub 用户名(退出码 0)
→ 如报错:在 shell 环境中设置 CLAWHUB_TOKEN 后重试
步骤 8 — 发布到 clawHub''
` clawhub publish ${skillDir --version version`}
→ 成功后:记录 clawhub publish 返回的 skill ID
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
输出格式(写入 data/current-pipeline.json 的 "upload" 键)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{
"stage": "upload",
"skillDir": "skillDir",
"slug": "slug",
"version": "version",
"dryRun": dryRun,
"workflow": "'direct-push'",
"github": {
"repo": "githubRepo",
"branch": "'main'",
"commitHash": "<git rev-parse HEAD 输出>",
"prNumber": 'null',
"status": "SUCCESS | FAILED | SKIPPED",
"error": null
},
"clawhub": {
"publishedVersion": "version",
"skillId": "<clawhub skill ID>",
"status": "SUCCESS | FAILED | SKIPPED",
"error": null
},
"completedAt": "<ISO 时间戳>"
}
如果任何部分 FAILED:记录错误并停止,不进入阶段 9。
如果 SUCCESS 或 SKIPPED(dry-run):进入阶段 9:node scripts/verify-upload.js --from-pipeline
`);
}
FILE:scripts/verify-upload.js
#!/usr/bin/env node
/**
* Automatic Skill — Stage 9: Verify Upload
*
* PROMPT GENERATOR ONLY — this script makes NO outbound network requests.
* Outputs an agent prompt that uses the gh CLI (github skill pattern) for all
* GitHub verification: live commit check, directory existence, file count via gh api.
*
* Usage:
* node scripts/verify-upload.js <skill-slug>
* node scripts/verify-upload.js --from-pipeline
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = langIdx !== -1 ? args[langIdx + 1] : 'zh';
let slug = '';
let commitHash = '';
let version = '';
let prNumber = null;
let workflow = 'direct-push';
function loadPipelineState() {
try {
const p = path.join(__dirname, '..', 'data', 'current-pipeline.json');
if (!fs.existsSync(p)) return null;
delete require.cache[require.resolve(p)];
return require(p);
} catch (e) {
return null;
}
}
if (args.includes('--from-pipeline')) {
const p = loadPipelineState();
if (p) {
slug = (p.design && p.design.slug)
|| (p.research && p.research.selected && p.research.selected.slug)
|| '';
commitHash = (p.upload && p.upload.github && p.upload.github.commitHash) || '';
version = (p.upload && p.upload.version)
|| (p.seo && p.seo.version)
|| (p.design && p.design.version)
|| '1.0.0';
prNumber = (p.upload && p.upload.github && p.upload.github.prNumber) || null;
workflow = (p.upload && p.upload.workflow) || 'direct-push';
}
}
if (!slug) {
slug = args.filter(a => a !== '--lang' && a !== lang && !a.startsWith('--'))[0] || '';
}
if (!slug) {
console.error('Usage: node scripts/verify-upload.js <skill-slug>');
console.error(' node scripts/verify-upload.js --from-pipeline');
process.exit(1);
}
const repo = '$GITHUB_REPO';
const skillPath = `openclaw/agents/skills/slug`;
if (lang === 'en') {
console.log(`=== AUTOMATIC SKILL — Stage 9: Verify Upload ===
Skill: slug version: version
Commit hash: commitHash || '(read from pipeline)'
GitHub repo: repo
Workflow used: workflow#${prNumber` : ''}
Verify that the skill is fully published on both GitHub and clawHub.
All checks use the gh CLI — no git fetch or local clone required.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PART 1 — GITHUB VERIFICATION (gh api)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHECK 1 — Verify authentication is still valid
gh auth status
→ Must show ✓ Logged in. If expired: gh auth refresh
CHECK 2 — Verify commit landed on GitHub
gh api repos/repo/commits/main \\
--jq '[.sha, .commit.message, .commit.author.date] | @tsv'
→ The top commit SHA must match: commitHash || '<commitHash from pipeline>'
→ The commit message must contain "slug"
gh pr view ${prNumber --json state,mergedAt,mergeCommit --jq '{state,mergedAt,sha:.mergeCommit.oid}'
→ state must be "MERGED"` : ''}
CHECK 3 — Verify skill directory exists on GitHub (live, no git fetch needed)
gh api repos/repo/contents/skillPath \\
--jq '[.[].name] | sort | .[]'
→ Must list at minimum: SKILL.md, package.json, _meta.json
→ If 404: the push did not include this path — re-run upload.js
CHECK 4 — Verify file count in skill directory
gh api "repos/repo/git/trees/main?recursive=1" \\
--jq '[.tree[] | select(.path | startswith("skillPath/")) | .path] | length'
→ Must be ≥ 4 (SKILL.md, package.json, _meta.json, ≥1 script)
→ If 0: skill directory was not committed — check git add path in upload step
CHECK 5 — Verify SKILL.md exists and has content on GitHub
gh api repos/repo/contents/skillPath/SKILL.md \\
--jq '{name, size, encoding}'
→ name must be "SKILL.md"
→ size must be > 0 (non-empty file)
→ Confirms the file was uploaded and is accessible
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PART 2 — CLAWHUB VERIFICATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHECK 6 — Query clawHub for the published skill
clawhub get slug
→ Must return: slug, version, ownerId
→ Exit code must be 0
→ If error: check CLAWHUB_TOKEN and re-run clawhub publish
CHECK 7 — Verify version matches _meta.json
clawhub get slug | grep '"version"'
→ Must show: "version"
→ If mismatch: a previous version may have been cached — run clawhub publish with --version version
CHECK 8 — Verify skill is searchable (optional)
clawhub search slug
→ Should return this skill in results within 1–2 minutes of publish
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OUTPUT FORMAT (write to data/current-pipeline.json under "verifyUpload" key)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{
"stage": "verify-upload",
"slug": "slug",
"version": "version",
"github": {
"commitFound": true | false,
"commitHashMatch": true | false,
"directoryExists": true | false,
"fileCount": <N>,
"skillMdNonEmpty": true | false,
"status": "VERIFIED | FAILED",
"detail": "..."
},
"clawhub": {
"skillFound": true | false,
"publishedVersion": "...",
"versionMatch": true | false,
"status": "VERIFIED | FAILED",
"detail": "..."
},
"overallVerdict": "FULLY_VERIFIED | PARTIAL | FAILED",
"completedAt": "<ISO timestamp>"
}
If FAILED: identify which check failed, re-run Stage 8 (upload.js) for that part only.
If FULLY_VERIFIED or PARTIAL: proceed to Stage 9b: node scripts/scan-check.js --from-pipeline
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PART 3 — SECURITY SCAN (VirusTotal + OpenClaw)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
After confirming clawHub publish succeeded, check security ratings:
node scripts/scan-check.js --from-pipeline
This will:
1. Wait for VirusTotal and OpenClaw scan results (polls up to 5 min)
2. Run static analysis on the skill's scripts for known trigger patterns
3. If Suspicious: output targeted fix instructions + re-publish guidance
4. Repeat until PASS or max 3 attempts reached
→ PASS or TIMEOUT: proceed to Stage 10: node scripts/final-review.js --from-pipeline
→ SUSPICIOUS (after fixes): proceed to Stage 10 with a scan warning in the final report
`);
} else {
console.log(`=== AUTOMATIC SKILL — 阶段 9:验收 ===
Skill:slug 版本:version
Commit hash:commitHash || '(从 pipeline 读取)'
GitHub 仓库:repo
使用的工作流:workflowprNumber ? ` PR:#${prNumber` : ''}
验证 skill 已完整发布到 GitHub 和 clawHub。
所有检查使用 gh CLI — 无需 git fetch 或本地 clone。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第一部分 — GitHub 验证(gh api)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
检查 1 — 确认认证仍然有效
gh auth status
→ 必须显示 ✓ Logged in。如已过期:gh auth refresh
检查 2 — 验证 commit 已到达 GitHub
gh api repos/repo/commits/main \\
--jq '[.sha, .commit.message, .commit.author.date] | @tsv'
→ 最顶部 commit SHA 必须匹配:commitHash || '<pipeline 中的 commitHash>'
→ commit message 必须包含 "slug"
prNumber ? `
(PR 工作流)确认 PR 已合并:
gh pr view ${prNumber --json state,mergedAt,mergeCommit --jq '{state,mergedAt,sha:.mergeCommit.oid}'
→ state 必须为 "MERGED"` : ''}
检查 3 — 验证 skill 目录在 GitHub 上存在(实时,无需 git fetch)
gh api repos/repo/contents/skillPath \\
--jq '[.[].name] | sort | .[]'
→ 必须至少列出:SKILL.md, package.json, _meta.json
→ 如 404:push 未包含此路径 — 重新运行 upload.js
检查 4 — 验证 skill 目录文件数量
gh api "repos/repo/git/trees/main?recursive=1" \\
--jq '[.tree[] | select(.path | startswith("skillPath/")) | .path] | length'
→ 必须 ≥ 4(SKILL.md, package.json, _meta.json, ≥1 脚本)
→ 如为 0:skill 目录未被提交 — 检查 upload 步骤中的 git add 路径
检查 5 — 验证 SKILL.md 在 GitHub 上存在且有内容
gh api repos/repo/contents/skillPath/SKILL.md \\
--jq '{name, size, encoding}'
→ name 必须为 "SKILL.md"
→ size 必须 > 0(非空文件)
→ 确认文件已上传且可访问
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第二部分 — clawHub 验证
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
检查 6 — 从 clawHub 查询已发布的 skill
clawhub get slug
→ 必须返回:slug, version, ownerId
→ 退出码必须为 0
→ 如报错:检查 CLAWHUB_TOKEN 并重新运行 clawhub publish
检查 7 — 验证版本与 _meta.json 匹配
clawhub get slug | grep '"version"'
→ 必须显示:"version"
→ 如不匹配:可能是旧版本缓存 — 用 --version version 重新 clawhub publish
检查 8 — 验证 skill 可被搜索(可选)
clawhub search slug
→ 发布后 1~2 分钟内应能在结果中找到该 skill
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
输出格式(写入 data/current-pipeline.json 的 "verifyUpload" 键)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{
"stage": "verify-upload",
"slug": "slug",
"version": "version",
"github": {
"commitFound": true | false,
"commitHashMatch": true | false,
"directoryExists": true | false,
"fileCount": <N>,
"skillMdNonEmpty": true | false,
"status": "VERIFIED | FAILED",
"detail": "..."
},
"clawhub": {
"skillFound": true | false,
"publishedVersion": "...",
"versionMatch": true | false,
"status": "VERIFIED | FAILED",
"detail": "..."
},
"overallVerdict": "FULLY_VERIFIED | PARTIAL | FAILED",
"completedAt": "<ISO 时间戳>"
}
如果 FAILED:确定哪项检查失败,仅针对该部分重新运行阶段 8(upload.js)。
如果 FULLY_VERIFIED 或 PARTIAL:进入阶段 9b:node scripts/scan-check.js --from-pipeline
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
第三部分 — 安全扫描(VirusTotal + OpenClaw)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
确认 clawHub 发布成功后,检查安全评级:
node scripts/scan-check.js --from-pipeline
此命令将:
1. 等待 VirusTotal 和 OpenClaw 扫描结果(最多轮询 5 分钟)
2. 对 skill 脚本进行静态分析,查找已知触发模式
3. 如为 Suspicious:输出针对性修复指令 + 重新发布指引
4. 最多重复 3 次,直到 PASS 为止
→ PASS 或 TIMEOUT:进入阶段 10:node scripts/final-review.js --from-pipeline
→ SUSPICIOUS(修复后仍是):带扫描警告进入阶段 10,在 final report 中标注
`);
}
Daily Poem delivers one carefully chosen poem every morning — rotating between Chinese classical poetry (唐诗宋词), modern Chinese verse, and English poems — com...
---
name: daily-poem
description: |
Daily Poem delivers one carefully chosen poem every morning — rotating between Chinese classical poetry (唐诗宋词), modern Chinese verse, and English poems — complete with translation, background story, annotation notes, and recitation rhythm guide. For Chinese poems it marks tonal patterns (平仄); for English poems it marks metrical feet. Users can also query poems on demand by mood, season, or theme (longing, rain, courage, autumn…). Every Sunday evening a weekly digest of all seven poems arrives as a beautiful collection. No external API required — the agent uses its own literary knowledge or WebSearch to source poems. Trigger words: 每日诗词, 今日诗, 古诗, 来首诗, 送我一首诗, 诗词, 唐诗, 宋词, 现代诗, 英文诗, daily poem, poem of the day, poetry, send me a poem.
keywords: 每日诗词, 古诗, 唐诗, 宋词, 现代诗, 英文诗, 诗词推送, 今日诗, 来首诗, 送我一首诗, 诗词赏析, 朗读节奏, 平仄, 词源, 意象, 周合辑, 诗词查询, 按心情查诗, 按主题查诗, daily poem, poem of the day, poetry, classical chinese poetry, english poem, weekly digest, poem push, poetry analysis
metadata:
openclaw:
runtime:
node: ">=18"
---
# Daily Poem — 每日诗词
> 私人诗词助手 — 每日精选 · 中英双语 · 赏析解读 · 按需查诗
---
## 何时使用
- 用户说"来首诗""送我一首诗""今日诗词""给我推一首"
- 用户说"来首关于秋天/离别/励志/雨/爱情的诗"
- 用户说"唐诗宋词""现代诗""英文诗"
- 用户说"开启诗词推送""每天给我推诗"
- 每周日晚间自动发送本周诗词合辑
---
## 🌐 语言规则
- 默认中文;用户英文提问切英文
- 中文诗保留原文,附英译;英文诗保留原文,附中译
- 专有名词(诗人名、词牌名)保留原文,括号内附拼音/译文
---
## 📖 功能列表
### 每日推送
每日 08:00 推送一首精选诗,按以下规律轮换:
- 周一/三/五:中国古典诗词(唐诗、宋词、元曲)
- 周二/四:中国现代诗(1919-今)
- 周六/日:英文诗(莎士比亚、济慈、聂鲁达、弗罗斯特等)
每首包含:标题·作者·朝代/年代 → 原文 → 译文 → 背景故事(2-3句)→ 赏析要点(2-3个意象/手法)→ 朗读节奏
### 按需查诗
| 命令 | 示例 |
|------|------|
| 按心情 | `来首悲秋的诗` / `推荐励志的诗` |
| 按季节 | `春天的诗` / `冬日诗词` |
| 按主题 | `关于离别的诗` / `月亮诗词` / `战争诗` |
| 按作者 | `李白的诗` / `苏轼的词` / `Keats poem` |
| 按体裁 | `五言绝句` / `宋词` / `英文十四行诗` |
| 随机 | `来首诗` / `随机一首` |
### 每周诗词合辑
每周日 20:00 推送本周 7 首诗词合辑,附本周诗词主题总结。
---
## 🛠️ 脚本说明
```bash
# 每日推送(由 cron 调用)
node scripts/morning-push.js
node scripts/morning-push.js --lang en
node scripts/morning-push.js --theme 离别
# 按需查诗
node scripts/query.js 秋天
node scripts/query.js "李白" --lang zh
node scripts/query.js rain --lang en
# 周合辑
node scripts/weekly-digest.js
node scripts/weekly-digest.js --lang en
# 推送管理
node scripts/push-toggle.js on
node scripts/push-toggle.js off
node scripts/push-toggle.js status
```
---
## ⏰ Cron 配置
```bash
openclaw cron add "0 8 * * *" "cd ~/.openclaw/workspace/skills/daily-poem && node scripts/morning-push.js"
openclaw cron add "0 20 * * 0" "cd ~/.openclaw/workspace/skills/daily-poem && node scripts/weekly-digest.js"
openclaw cron list
openclaw cron delete <任务ID>
```
---
## 📁 数据文件
```
data/push-log.json # 推送历史(避免7天内重复推送同一首诗)
scripts/
morning-push.js # 每日早晨推送
query.js # 按需查诗
weekly-digest.js # 周合辑
push-toggle.js # cron 开关
```
---
## ⚠️ 注意事项
1. 不依赖外部 API,由 Agent 凭文学知识或 WebSearch 选诗
2. push-log.json 记录最近推送过的诗,避免短期内重复
3. 赏析内容由 Agent 生成,不代表唯一解读,欢迎用户提出不同见解
4. 英文诗版权:只推送版权已过期的作品(作者逝世超过 70 年)或引用公共领域作品
---
*Version: 1.0.0 · Created: 2026-04-04*
FILE:_meta.json
{
"ownerId": "kn79bebfnwg15sb0g7cj5z5nyd83gxh0",
"slug": "daily-poem",
"version": "1.0.0",
"publishedAt": null
}
FILE:data/push-log.json
[]
FILE:package.json
{
"name": "daily-poem",
"version": "1.0.0",
"description": "Daily Poem — 每日精选诗词推送,中英古典/现代诗交替,含译文赏析朗读节奏,支持按主题/作者按需查诗和周合辑。",
"keywords": [
"每日诗词", "古诗", "唐诗", "宋词", "现代诗", "英文诗", "诗词推送",
"赏析", "朗读", "按需查诗", "周合辑",
"daily poem", "poem of the day", "poetry", "classical poetry", "weekly digest"
],
"author": "jiajiaoy",
"license": "MIT",
"scripts": {
"morning": "node scripts/morning-push.js",
"morning:en": "node scripts/morning-push.js --lang en",
"query": "node scripts/query.js",
"random": "node scripts/query.js --random",
"weekly": "node scripts/weekly-digest.js",
"push-on": "node scripts/push-toggle.js on",
"push-off": "node scripts/push-toggle.js off",
"push-status": "node scripts/push-toggle.js status"
}
}
FILE:scripts/morning-push.js
#!/usr/bin/env node
/**
* daily-poem — morning-push.js
* 每日早晨诗词推送 prompt 生成器
* 由 openclaw cron 08:00 触发
*
* 用法:
* node scripts/morning-push.js
* node scripts/morning-push.js --lang en
* node scripts/morning-push.js --theme 离别
*/
const fs = require('fs');
const path = require('path');
const ALLOWED_THEMES = new Set([
'离别','思乡','爱情','友情','豪情','悲秋','咏春','励志','禅意','月亮',
'山水','战争','田园','咏物','闺怨','送别','饮酒','怀古',
'love','autumn','spring','farewell','courage','moon','nature','friendship'
]);
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = (langIdx !== -1 && args[langIdx + 1]) ? args[langIdx + 1] : 'zh';
const themeIdx = args.indexOf('--theme');
const rawTheme = themeIdx !== -1 ? args[themeIdx + 1] : null;
const theme = rawTheme && ALLOWED_THEMES.has(rawTheme) ? rawTheme : null;
const now = new Date();
const day = now.getDay(); // 0=Sun,1=Mon,...6=Sat
const dateISO = `now.getFullYear()-String(now.getMonth()+1).padStart(2,'0')-String(now.getDate()).padStart(2,'0')`;
// Rotation: Mon/Wed/Fri = classical, Tue/Thu = modern, Sat/Sun = English
const poetryType = (lang === 'en')
? 'english'
: ([1,3,5].includes(day) ? 'classical' : [2,4].includes(day) ? 'modern' : 'english');
// Load push log to avoid repeats
const logPath = path.join(__dirname, '..', 'data', 'push-log.json');
let recentPoems = [];
if (fs.existsSync(logPath)) {
try {
const log = JSON.parse(fs.readFileSync(logPath, 'utf8'));
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
recentPoems = log
.filter(e => new Date(e.date) > cutoff)
.map(e => e.title);
} catch (_) {}
}
const avoidNote = recentPoems.length
? `\n避免重复:近7天已推过以下诗(请勿重选):recentPoems.join('、')`
: '';
if (lang === 'en') {
const WEEKDAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const dateStr = `WEEKDAYS[day], MONTHS[now.getMonth()] now.getDate(), now.getFullYear()`;
const typeNote = theme ? `Theme: theme` : 'Poetry type: English (public domain)';
console.log(`Please deliver today's Daily Poem. Date: dateStr
typeNote''
Choose one outstanding English poem (author must have been deceased 70+ years, public domain). ${theme.` : ''}
Output format:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🌸 Daily Poem · dateStr
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📜 [Title]
✍️ [Author] · [Year/Collection]
[Full poem text — preserve line breaks]
🈶 Chinese Translation:
[Full translation]
📖 Background:
[2-3 sentences on the poet's life context when writing this]
💡 Analysis (2-3 points):
• [Image/technique 1]
• [Image/technique 2]
• [Optional: metre or sound device]
🎙 Recitation note:
[1 sentence on stress pattern or reading pace]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
💬 Reply "more like this" · "query [theme]" · "weekly digest on Sunday"`);
} else {
const WEEKDAYS_ZH = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六'];
const dateStr = `now.getFullYear()年now.getMonth()+1月now.getDate()日 WEEKDAYS_ZH[day]`;
const typeMap = { classical:'中国古典诗词(唐诗/宋词/元曲)', modern:'中国现代诗(1919年后)', english:'英文诗(公有领域)' };
const typeHint = theme ? `主题:theme` : `今日类型:typeMap[poetryType]`;
console.log(`请为今日推送精选诗词。日期:dateStr
typeHintavoidNote
选择一首符合今日类型的出色诗作,按以下格式完整呈现:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🌸 每日诗词 · dateStr
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📜 【诗题】
✍️ 作者 · 朝代/年代
【原文】(保留原始格式和换行)
''🈶 译文/注释:
【现代汉语释义或英译(若为英文诗则附中译)】
📖 背景故事:
【诗人创作此诗的生平背景,2-3句】
💡 赏析要点(2-3处):
• 【意象/手法 1】
• 【意象/手法 2】
• 【可选:音韵/结构特点】
🎙 朗读节奏:
【一句话说明朗读节奏或情感基调】
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
💬 回复"再来一首" · "查 [主题/作者]" · 周日晚见合辑`);
}
FILE:scripts/push-toggle.js
#!/usr/bin/env node
/**
* daily-poem — push-toggle.js
* 管理 openclaw cron 定时推送开关
*
* 用法:
* node scripts/push-toggle.js on
* node scripts/push-toggle.js off
* node scripts/push-toggle.js status
*/
const path = require('path');
const args = process.argv.slice(2);
const action = args[0];
if (!action || !['on', 'off', 'status'].includes(action)) {
console.error('Usage: node scripts/push-toggle.js on|off|status');
process.exit(1);
}
const skillDir = path.join(__dirname, '..');
const morningCmd = `cd skillDir && node scripts/morning-push.js`;
const weeklyCmd = `cd skillDir && node scripts/weekly-digest.js`;
if (action === 'on') {
console.log(`
开启每日诗词推送,请在终端运行以下命令:
openclaw cron add "0 8 * * *" "morningCmd"
openclaw cron add "0 20 * * 0" "weeklyCmd"
这将设置:
• 每日 08:00 推送一首精选诗词
• 每周日 20:00 推送本周诗词合辑
添加后验证:
openclaw cron list
关闭推送:
node scripts/push-toggle.js off
`);
} else if (action === 'off') {
console.log(`
关闭每日诗词推送,请运行:
openclaw cron list
找到含 "daily-poem" 的任务并记录 ID,然后:
openclaw cron delete <morning-task-id>
openclaw cron delete <weekly-task-id>
`);
} else if (action === 'status') {
console.log(`
查看每日诗词推送状态:
openclaw cron list
查找包含以下内容的条目:
时间:0 8 * * *(每日早晨)→ morning-push.js
时间:0 20 * * 0(每周日晚)→ weekly-digest.js
有记录 → 推送已开启 ✅
无记录 → 推送未开启(运行:node scripts/push-toggle.js on)
`);
}
FILE:scripts/query.js
#!/usr/bin/env node
/**
* daily-poem — query.js
* 按需查诗:按心情/季节/主题/作者/体裁查询并呈现诗词
*
* 用法:
* node scripts/query.js <keyword>
* node scripts/query.js "李白"
* node scripts/query.js "秋天" --lang zh
* node scripts/query.js rain --lang en
* node scripts/query.js --random
*/
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = (langIdx !== -1 && args[langIdx + 1]) ? args[langIdx + 1] : 'zh';
const isRandom = args.includes('--random');
// Sanitize keyword: strip special chars, limit length
const rawKeyword = args.filter(a => a !== '--lang' && a !== lang && a !== '--random')[0] || '';
const keyword = rawKeyword.replace(/[<>"';&|`$]/g, '').trim().substring(0, 50);
if (!keyword && !isRandom) {
console.error('Usage: node scripts/query.js <keyword|author|theme>');
console.error(' node scripts/query.js --random');
console.error('Examples:');
console.error(' node scripts/query.js 秋天');
console.error(' node scripts/query.js 李白');
console.error(' node scripts/query.js farewell --lang en');
process.exit(1);
}
const now = new Date();
const dateISO = `now.getFullYear()-String(now.getMonth()+1).padStart(2,'0')-String(now.getDate()).padStart(2,'0')`;
if (lang === 'en') {
const q = isRandom ? 'a random poem' : `poems about "keyword"`;
console.log(`Please find and present a poem matching: q
Date: dateISO
Selection criteria:
- If it's an author name: choose a well-known work by that author
- If it's a theme/mood: choose the most emotionally resonant poem on that theme
- Prefer public domain works (author deceased 70+ years)
- If no exact match, choose the closest thematic fit and note the connection
Output format:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 Poetry · "keyword"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📜 [Title]
✍️ [Author] · [Year]
[Full poem — preserve line breaks]
🈶 Chinese translation:
[Full translation]
📖 Why this poem fits "keyword":
[1-2 sentences]
💡 Key images/techniques:
• [Point 1]
• [Point 2]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
💬 Reply "more [theme]" for similar poems`);
} else {
const q = isRandom ? '随机一首' : `与"keyword"相关的诗词`;
console.log(`请查找并呈现q。
日期:dateISO
选诗标准:
- 若为作者名:选该作者最具代表性的一首
- 若为主题/心情:选情感最贴切、意境最深远的一首
- 优先中国古典/现代诗;若关键词明显指向英文(如 rain、love),可选英文诗
- 若无精确匹配,选最接近主题的诗,并说明关联
输出格式:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 诗词查询 · "keyword"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📜 【诗题】
✍️ 作者 · 朝代/年代
【原文】
🈶 译文/注释:
【现代汉语释义】
📖 与"keyword"的关联:
【1-2句说明选诗理由】
💡 赏析要点:
• 【要点 1】
• 【要点 2】
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
💬 回复"更多[主题]" 获取同类诗词`);
}
FILE:scripts/weekly-digest.js
#!/usr/bin/env node
/**
* daily-poem — weekly-digest.js
* 每周日晚间推送本周 7 首诗词合辑
* 由 openclaw cron 周日 20:00 触发
*
* 用法:
* node scripts/weekly-digest.js
* node scripts/weekly-digest.js --lang en
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const lang = (langIdx !== -1 && args[langIdx + 1]) ? args[langIdx + 1] : 'zh';
const now = new Date();
// Calculate the Monday of this week
const monday = new Date(now);
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
const mondayISO = `monday.getFullYear()-String(monday.getMonth()+1).padStart(2,'0')-String(monday.getDate()).padStart(2,'0')`;
const sundayISO = `now.getFullYear()-String(now.getMonth()+1).padStart(2,'0')-String(now.getDate()).padStart(2,'0')`;
// Load push log to reference this week's poems if available
const logPath = path.join(__dirname, '..', 'data', 'push-log.json');
let weekPoems = [];
if (fs.existsSync(logPath)) {
try {
const log = JSON.parse(fs.readFileSync(logPath, 'utf8'));
weekPoems = log.filter(e => e.date >= mondayISO && e.date <= sundayISO);
} catch (_) {}
}
const poemRef = weekPoems.length > 0
? `本周已记录推送:weekPoems.map(p => p.title).join('、')(可直接复用,补全未记录的)`
: '本周推送记录不可用,请自选7首优质诗词';
if (lang === 'en') {
console.log(`Please create this week's poetry digest (mondayISO to sundayISO).
Reference: poemRef.replace(/[\u4e00-\u9fff]/g, '')
Select 7 poems total: 3 classical Chinese, 2 modern Chinese, 2 English (public domain).
Find a common theme or thread connecting at least 4 of the 7 poems.
Output format:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📚 Weekly Poetry Digest · mondayISO – sundayISO
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🌟 This Week's Theme: [theme in 4-6 words]
[Repeat 7 times:]
──────────── Day N (Weekday) ────────────
📜 [Title] · [Author] · [Dynasty/Year]
[First 2-4 lines of poem]
💬 [One sentence why it fits this week]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📝 Week in Review:
[2-3 sentences on the emotional arc of this week's selection]
See you next Monday! 🌸`);
} else {
console.log(`请生成本周诗词合辑(mondayISO 至 sundayISO)。
参考:poemRef
共选 7 首:古典诗词 3 首、现代诗 2 首、英文诗 2 首(公有领域)。
尝试找出贯穿其中 4 首以上的共同主题或情感线索。
输出格式:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📚 本周诗词合辑 · mondayISO – sundayISO
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🌟 本周主题:【4-6字主题词】
【重复7次:】
──────────── 第N天(周X)────────────
📜 【诗题】 · 【作者】 · 【朝代/年代】
【诗歌前2-4行】
💬 【一句话说明与本周主题的关联】
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📝 本周回顾:
【2-3句话,总结本周诗词的情感脉络】
下周一见!🌸`);
}
Daily Mood delivers a warm, deeply thoughtful life message to every registered user each morning and evening — tuned to their emotional state. Unlike static...
---
name: daily-mood
description: |
Daily Mood delivers a warm, deeply thoughtful life message to every registered user each morning and evening — tuned to their emotional state. Unlike static quote cards, Daily Mood is mood-aware: users report how they're feeling (happy, anxious, tired, lost, grateful…) and instantly receive a message crafted for that exact headspace. Every morning at 08:00 a fresh message goes out; every evening at 21:00 a gentle night-time reflection closes the day. Multi-user support means the cron job walks every registered user and sends each one a personalised push in their preferred language (Chinese or English). No external API needed — all message generation is handled by the Agent's own language ability, grounded by a curated mood-to-tone mapping.
Trigger words: 心情寄语, 今日寄语, 人生寄语, 寄语, 今天心情, 我今天很累, 我很焦虑, 我很开心, 我迷茫了, 给我一句话, 鼓励我, 陪伴, 治愈, 晚安寄语, 早安寄语, daily mood, mood message, life message, morning message, evening message, send me a message, encourage me, daily wisdom, 每日寄语, 开启寄语推送.
keywords: 心情寄语, 今日寄语, 人生寄语, 寄语, 早安寄语, 晚安寄语, 每日寄语, 心情, 情绪, 治愈, 陪伴, 鼓励, 我很累, 我很焦虑, 我很开心, 我迷茫了, 给我一句话, 每天推送, 多用户推送, 个性化寄语, daily mood, mood message, life message, morning message, evening message, encourage me, daily wisdom, emotional support, personalized push, mood-aware
metadata:
openclaw:
runtime:
node: ">=18"
---
# Daily Mood — 每日心情寄语
> 情绪陪伴型人生寄语 · 早晨唤醒 · 夜间治愈 · 心情感知 · 中英双语 · 多用户定时推送
---
## 何时使用
- 用户说"给我一句寄语""今天的寄语""鼓励我一下"
- 用户说"我今天很累 / 很焦虑 / 很开心 / 很迷茫"
- 用户说"早安 / 晚安"(触发对应时段推送)
- 用户说"开启寄语推送""每天给我推寄语"
- cron 08:00 触发早晨全量推送
- cron 21:00 触发傍晚全量推送
---
## 🌐 语言规则
- 跟随用户注册档案的 `language` 字段(`zh` 或 `en`)
- 未注册用户:默认中文;英文提问自动切英文
- 寄语本体:以用户语言呈现;双语版本按需提供
---
## 💬 心情类型与寄语基调
| 心情 | 英文 | 寄语基调 |
|------|------|---------|
| 开心 / excited | happy | 共鸣共喜,引导感恩珍惜 |
| 低落 / sad | sad | 温柔接纳,不说"加油",说"你可以停下来" |
| 焦虑 / anxious | anxious | 放慢节奏,让当下变小,具体而微的安慰 |
| 疲惫 / tired | tired | 允许休息,休息本身就是努力 |
| 迷茫 / lost | lost | 不给答案,给方向感;迷茫是生长的前奏 |
| 平静 / calm | calm | 深化平静,引导觉察当下之美 |
| 感恩 / grateful | grateful | 扩展感恩,从小事到生命本身 |
| 愤怒 / angry | angry | 先接纳情绪,再引导释放,不评判 |
---
## 📖 功能列表
### 每日推送(多用户)
| 时间 | 脚本 | 内容 |
|------|------|------|
| 08:00 每日 | `morning-push.js` | 早晨唤醒寄语:根据用户心情档案定制 |
| 21:00 每日 | `evening-push.js` | 夜间疗愈寄语:温柔收尾,引导好眠 |
### 心情响应(即时)
用户报告心情后立即返回匹配寄语:
```
用户:我今天很焦虑
→ 一段专门写给焦虑状态的温暖寄语(100-150字)+ 一句简短金句
用户:I'm feeling lost today
→ A warm, mood-matched message in English (100-150 words) + one short quote
```
### 用户注册
注册后可享受:个性化语言设置、心情档案记忆、定时推送
---
## 🛠️ 脚本用法
```bash
# 注册用户(解锁定时推送)
node scripts/register.js <userId> [--lang zh|en] [--mood calm]
node scripts/register.js alice --lang zh --mood anxious
node scripts/register.js bob --lang en --mood happy
# 查看注册信息
node scripts/register.js --show <userId>
# 早晨推送(cron 调用 / 手动测试)
node scripts/morning-push.js # 全量推送所有注册用户
node scripts/morning-push.js --user <userId> # 测试单用户
node scripts/morning-push.js --mood tired # 指定心情(测试用)
# 傍晚推送
node scripts/evening-push.js
node scripts/evening-push.js --user <userId>
# 心情响应(用户实时触发)
node scripts/mood-response.js <mood> [--lang zh|en] [--userId <id>]
node scripts/mood-response.js anxious --lang zh
node scripts/mood-response.js happy --lang en
# 推送管理
node scripts/push-toggle.js on [--userId <id>] # 开启(全局或单用户)
node scripts/push-toggle.js off [--userId <id>]
node scripts/push-toggle.js status
```
---
## ⏰ Cron 配置
```bash
openclaw cron add "0 8 * * *" "cd ~/.openclaw/workspace/skills/daily-mood && node scripts/morning-push.js"
openclaw cron add "0 21 * * *" "cd ~/.openclaw/workspace/skills/daily-mood && node scripts/evening-push.js"
openclaw cron list
openclaw cron delete <任务ID>
```
---
## 📁 数据文件
```
data/users/<userId>.json # 用户档案:language, mood, pushEnabled, registeredAt
scripts/
morning-push.js # 早晨全量推送
evening-push.js # 傍晚全量推送
mood-response.js # 心情即时响应
register.js # 用户注册管理
push-toggle.js # cron 开关
```
---
## ⚠️ 注意事项
1. 寄语不是心理治疗——不对用户的情绪下诊断,不说"你一定没问题"
2. 低落/焦虑/愤怒状态:先共情,后寄语,避免"加油""想开点"等无效安慰
3. 用户数据仅含语言偏好和心情标签,不存储原始对话内容
4. 多用户推送时每位用户独立生成寄语,不共用同一段文字
5. 未注册用户可直接触发心情响应,注册后才启用定时推送
---
*Version: 1.0.0 · Created: 2026-04-04*
FILE:_meta.json
{
"ownerId": "kn79bebfnwg15sb0g7cj5z5nyd83gxh0",
"slug": "daily-mood",
"version": "1.0.0",
"publishedAt": null
}
FILE:package.json
{
"name": "daily-mood",
"version": "1.0.0",
"description": "Daily Mood — 每日心情寄语,早晨+傍晚定时推送,心情感知个性化,中英双语,多用户注册推送。",
"keywords": [
"心情寄语", "每日寄语", "人生寄语", "早安寄语", "晚安寄语", "情绪陪伴",
"治愈", "鼓励", "多用户推送", "个性化寄语",
"daily mood", "mood message", "life message", "morning message", "evening message",
"emotional support", "personalized push", "mood-aware"
],
"author": "jiajiaoy",
"license": "MIT",
"scripts": {
"morning": "node scripts/morning-push.js",
"evening": "node scripts/evening-push.js",
"mood": "node scripts/mood-response.js",
"register": "node scripts/register.js",
"users": "node scripts/register.js --list",
"push-on": "node scripts/push-toggle.js on",
"push-off": "node scripts/push-toggle.js off",
"push-status": "node scripts/push-toggle.js status"
}
}
FILE:scripts/evening-push.js
#!/usr/bin/env node
/**
* daily-mood — evening-push.js
* 傍晚全量寄语推送 — 温柔收尾一天,引导好眠
* 由 openclaw cron 21:00 每日触发
*
* 用法:
* node scripts/evening-push.js # 全量推送
* node scripts/evening-push.js --user <userId> # 单用户测试
* node scripts/evening-push.js --mood <mood> # 临时覆盖心情
*/
const fs = require('fs');
const path = require('path');
const ALLOWED_MOODS = new Set([
'happy','sad','anxious','tired','lost','calm','grateful','angry','neutral'
]);
const USERS_DIR = path.join(__dirname, '..', 'data', 'users');
const args = process.argv.slice(2);
const userIdx = args.indexOf('--user');
const targetUser = userIdx !== -1 ? args[userIdx + 1] : null;
const moodOverrideIdx = args.indexOf('--mood');
const rawMO = moodOverrideIdx !== -1 ? args[moodOverrideIdx + 1] : null;
const moodOverride = rawMO && ALLOWED_MOODS.has(rawMO) ? rawMO : null;
const now = new Date();
const dateStr_zh = `now.getFullYear()年now.getMonth()+1月now.getDate()日`;
const dateStr_en = now.toLocaleDateString('en-US', { weekday:'long', month:'long', day:'numeric' });
// Load users
let users = [];
if (targetUser) {
const fp = path.join(USERS_DIR, `targetUser.replace(/[^a-zA-Z0-9_-]/g,'').json`);
if (!fs.existsSync(fp)) {
console.error(`User "targetUser" not found.`);
process.exit(1);
}
users = [JSON.parse(fs.readFileSync(fp, 'utf8'))];
} else {
if (!fs.existsSync(USERS_DIR)) {
console.log('No users registered yet.');
process.exit(0);
}
users = fs.readdirSync(USERS_DIR)
.filter(f => f.endsWith('.json'))
.map(f => JSON.parse(fs.readFileSync(path.join(USERS_DIR, f), 'utf8')))
.filter(u => u.pushEnabled !== false);
}
if (users.length === 0) {
console.log('No active users to push.');
process.exit(0);
}
users.forEach((user, i) => {
const lang = user.language || 'zh';
const mood = moodOverride || user.mood || 'neutral';
const dateStr = lang === 'en' ? dateStr_en : dateStr_zh;
if (i > 0) console.log('\n' + '─'.repeat(60) + '\n');
if (lang === 'en') {
const eveningTones = {
happy: "User had a good day. Evening tone: celebrate quietly, invite rest as a gift to tomorrow's self.",
sad: "User had a hard day. Evening tone: night is a small closing — tomorrow is a new page. Very gentle, no pressure.",
anxious: "User was anxious today. Evening tone: let today go. The body rests even when the mind doesn't — trust the night.",
tired: "User is exhausted. Evening tone: softest possible — you don't have to carry today into tomorrow. Just rest.",
lost: "User felt lost today. Evening tone: even uncertain days count. You showed up. That is enough.",
calm: "User was calm today. Evening tone: deepen the stillness, invite a peaceful night.",
grateful: "User felt grateful. Evening tone: close the day in gratitude, a small blessing before sleep.",
angry: "User was angry today. Evening tone: let the day end, release what no longer needs to be held tonight.",
neutral: "User had an ordinary day. Evening tone: find the small worthwhile thing in an ordinary day."
};
console.log(`[Evening push to: user.userId | lang: en | mood: mood]
Please write an evening message to gently close this user's day.
Date: dateStr
Mood context: eveningTones[mood]
Requirements:
- Length: 80–120 words
- Softer and quieter than the morning message — the tone should slow down
- Do NOT give advice or tasks for tomorrow
- End with a short, calm goodnight line (≤ 15 words)
Output format:
🌙 Good Evening, user.userId · dateStr
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[Evening message — 80-120 words]
🕯️ "[A soft goodnight line — 15 words or fewer]"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
} else {
const eveningTones_zh = {
happy: '用户今天过得很开心。傍晚基调:安静地庆祝,让开心沉淀,把今天的好留给明天的自己。',
sad: '用户今天情绪低落。傍晚基调:夜晚是一个小小的收束,明天是新的一页。极轻柔,不施加压力。',
anxious: '用户今天焦虑。傍晚基调:让今天过去。身体在睡眠中会修复焦虑——交托给夜晚。',
tired: '用户今天非常疲惫。傍晚基调:最温柔的一段——不需要把今天带进明天,只是休息。',
lost: '用户今天感到迷茫。傍晚基调:就算不确定的一天也算数。你出现了,这就够了。',
calm: '用户今天平静。傍晚基调:深化宁静,引导平和入睡。',
grateful: '用户今天感恩。傍晚基调:用一点感谢收尾这一天,睡前的小小祝福。',
angry: '用户今天生气。傍晚基调:让这一天结束,放下今晚不再需要承载的东西。',
neutral: '用户今天普通平常。傍晚基调:在平凡一天里找到那件值得的小事。'
};
console.log(`[傍晚推送给:user.userId | 语言:zh | 心情:mood]
请为该用户写一段傍晚寄语,温柔收尾今天。
日期:dateStr
心情背景:eveningTones_zh[mood]
写作要求:
- 正文长度:80-120字
- 比早晨寄语更轻、更慢、更安静
- 不给明天的建议或任务
- 结尾一句晚安短语(不超过15字)
输出格式:
🌙 晚安,user.userId · dateStr
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[傍晚寄语 80-120字]
🕯️「[晚安短语,≤15字]」
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
}
});
FILE:scripts/mood-response.js
#!/usr/bin/env node
/**
* daily-mood — mood-response.js
* 用户报告当下心情后,即时返回匹配寄语 prompt
*
* 用法:
* node scripts/mood-response.js <mood> [--lang zh|en] [--userId <id>]
* node scripts/mood-response.js anxious
* node scripts/mood-response.js happy --lang en
* node scripts/mood-response.js tired --userId alice
*
* mood 可以是英文关键词,也可以传入中文(会自动映射):
* 开心/高兴/快乐 → happy
* 低落/难过/伤心/不开心 → sad
* 焦虑/紧张/担心/害怕 → anxious
* 疲惫/累/很累/累了 → tired
* 迷茫/不知道/困惑 → lost
* 平静/还好/一般 → calm
* 感恩/感谢/开心感激 → grateful
* 生气/愤怒/烦/烦躁 → angry
*/
const fs = require('fs');
const path = require('path');
const MOOD_MAP_ZH = {
'开心':'happy','高兴':'happy','快乐':'happy','很好':'happy',
'低落':'sad','难过':'sad','伤心':'sad','不开心':'sad','哭':'sad',
'焦虑':'anxious','紧张':'anxious','担心':'anxious','害怕':'anxious','慌':'anxious',
'疲惫':'tired','累':'tired','很累':'tired','累了':'tired','没劲':'tired',
'迷茫':'lost','不知道':'lost','困惑':'lost','不确定':'lost',
'平静':'calm','还好':'calm','一般':'calm','平和':'calm',
'感恩':'grateful','感谢':'grateful','感激':'grateful',
'生气':'angry','愤怒':'angry','烦':'angry','烦躁':'angry','火':'angry'
};
const ALLOWED_MOODS_EN = new Set([
'happy','sad','anxious','tired','lost','calm','grateful','angry','neutral'
]);
const args = process.argv.slice(2);
const langIdx = args.indexOf('--lang');
const langArg = langIdx !== -1 ? args[langIdx + 1] : null;
const userIdIdx = args.indexOf('--userId');
const userId = userIdIdx !== -1 ? args[userIdIdx + 1] : null;
const rawMood = args.filter(a => !a.startsWith('--') && a !== langArg && a !== userId)[0] || '';
if (!rawMood) {
console.error('Usage: node scripts/mood-response.js <mood> [--lang zh|en] [--userId <id>]');
console.error('');
console.error('English moods: happy, sad, anxious, tired, lost, calm, grateful, angry, neutral');
console.error('Chinese moods: 开心, 低落, 焦虑, 疲惫, 迷茫, 平静, 感恩, 生气, 还好 ...');
process.exit(1);
}
// Resolve mood
let mood = MOOD_MAP_ZH[rawMood] || (ALLOWED_MOODS_EN.has(rawMood) ? rawMood : null);
if (!mood) {
// Fuzzy: check if rawMood contains any zh key
for (const [key, val] of Object.entries(MOOD_MAP_ZH)) {
if (rawMood.includes(key)) { mood = val; break; }
}
}
if (!mood) mood = 'neutral';
// Determine language
let lang = 'zh';
if (langArg === 'en') {
lang = 'en';
} else if (userId) {
const USERS_DIR = path.join(__dirname, '..', 'data', 'users');
const fp = path.join(USERS_DIR, `userId.replace(/[^a-zA-Z0-9_-]/g,'').json`);
if (fs.existsSync(fp)) {
const u = JSON.parse(fs.readFileSync(fp, 'utf8'));
lang = u.language || 'zh';
// Update user's mood in profile
u.mood = mood;
u.updatedAt = new Date().toISOString();
fs.writeFileSync(fp, JSON.stringify(u, null, 2));
}
}
const now = new Date();
const dateStr = lang === 'en'
? now.toLocaleDateString('en-US', { weekday:'long', month:'long', day:'numeric' })
: `now.getFullYear()年now.getMonth()+1月now.getDate()日`;
const MOOD_CONTEXT_ZH = {
happy: '用户此刻开心愉悦。寄语:共鸣这份喜悦,引导珍惜,不过度煽情。',
sad: '用户此刻情绪低落。先温柔承接悲伤,让他/她感到被看见,再轻轻引向光。',
anxious: '用户此刻焦虑。把"未来"化为"此刻",提供一个小小落脚点。不说"别担心"。',
tired: '用户此刻疲惫。允许休息,此刻不需要解决任何问题,只是稍作停歇。',
lost: '用户此刻迷茫。给陪伴和方向感,不给"正确答案"。迷茫本身是在生长。',
calm: '用户此刻平静。深化这份宁静,引导觉察身边细微之美。',
grateful: '用户此刻感恩。呼应感恩,从一件小事扩展到生命本身的丰盈。',
angry: '用户此刻生气/烦躁。先接纳情绪,不评判,再引导情绪流动。',
neutral: '用户此刻心情普通。一段有质感的人生寄语,温暖而真实。'
};
const MOOD_CONTEXT_EN = {
happy: "User is feeling happy right now. Resonate with the joy, invite savoring, don't over-sentimentalize.",
sad: "User is feeling sad. First hold the sadness gently, make them feel seen, then softly point toward light.",
anxious: "User is anxious right now. Bring the overwhelming future into one small present moment. Don't say 'don't worry'.",
tired: "User is exhausted. Give permission to stop. Nothing needs solving right now — just a brief rest.",
lost: "User feels lost. Offer companionship and a sense of direction, not 'the answer'. Being lost is growth.",
calm: "User is calm. Deepen the stillness, invite noticing small present-moment beauty.",
grateful: "User feels grateful. Echo the gratitude, expand from one thing to the richness of being alive.",
angry: "User is angry. First honour the emotion, no judgement, then guide it to flow.",
neutral: "User is in a neutral state. A textured, warm life message — real, not bland."
};
const ctx = lang === 'en' ? MOOD_CONTEXT_EN[mood] : MOOD_CONTEXT_ZH[mood];
const userName = userId || (lang === 'en' ? 'you' : '你');
if (lang === 'en') {
console.log(`[Mood response | user: userName | mood: mood | lang: en | dateStr]
Please write an immediate mood-response message.
Mood context: ctx
Requirements:
- Length: 100–150 words
- Voice: like a wise, warm friend who truly gets it — not a therapist, not a coach
- One specific, concrete image or metaphor (not abstract platitudes)
- End with one distilled line (≤ 20 words)
- Do NOT use: "believe in yourself", "you got this", "everything happens for a reason"
Output format:
💬 Hey userName · dateStr
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[Message body — 100-150 words]
✨ "[Distilled line — 20 words or fewer]"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
} else {
console.log(`[心情响应 | 用户:userName | 心情:mood | 语言:zh | dateStr]
请写一段即时心情响应寄语。
心情背景:ctx
写作要求:
- 正文长度:100-150字
- 语气:像一个真正懂你的朋友——不是心理咨询师,不是励志导师
- 包含一个具体、真实的意象或比喻(不要空洞抽象)
- 结尾一句提炼语(不超过20字)
- 禁止使用:加油、相信自己、你最棒、一切都会好的、这都是天意
输出格式:
💬 userName · dateStr
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[寄语正文 100-150字]
✨「[提炼语,≤20字]」
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
}
FILE:scripts/morning-push.js
#!/usr/bin/env node
/**
* daily-mood — morning-push.js
* 早晨全量寄语推送 — 遍历所有已注册且 pushEnabled=true 的用户,为每人生成寄语 prompt
* 由 openclaw cron 08:00 每日触发
*
* 用法:
* node scripts/morning-push.js # 全量推送
* node scripts/morning-push.js --user <userId> # 单用户测试
* node scripts/morning-push.js --mood <mood> # 临时覆盖心情(测试用)
* node scripts/morning-push.js --dry-run # 仅输出 prompt,不记录日志
*/
const fs = require('fs');
const path = require('path');
const ALLOWED_MOODS = new Set([
'happy','sad','anxious','tired','lost','calm','grateful','angry','neutral'
]);
const USERS_DIR = path.join(__dirname, '..', 'data', 'users');
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const userIdx = args.indexOf('--user');
const targetUser = userIdx !== -1 ? args[userIdx + 1] : null;
const moodOverrideIdx = args.indexOf('--mood');
const rawMoodOverride = moodOverrideIdx !== -1 ? args[moodOverrideIdx + 1] : null;
const moodOverride = rawMoodOverride && ALLOWED_MOODS.has(rawMoodOverride) ? rawMoodOverride : null;
const now = new Date();
const dateStr_zh = `now.getFullYear()年now.getMonth()+1月now.getDate()日`;
const dateStr_en = now.toLocaleDateString('en-US', { weekday:'long', year:'numeric', month:'long', day:'numeric' });
// Load users
let users = [];
if (targetUser) {
const fp = path.join(USERS_DIR, `targetUser.replace(/[^a-zA-Z0-9_-]/g,'').json`);
if (!fs.existsSync(fp)) {
console.error(`User "targetUser" not found. Register first: node scripts/register.js targetUser`);
process.exit(1);
}
users = [JSON.parse(fs.readFileSync(fp, 'utf8'))];
} else {
if (!fs.existsSync(USERS_DIR)) {
console.log('No users registered yet. Use: node scripts/register.js <userId>');
process.exit(0);
}
users = fs.readdirSync(USERS_DIR)
.filter(f => f.endsWith('.json'))
.map(f => JSON.parse(fs.readFileSync(path.join(USERS_DIR, f), 'utf8')))
.filter(u => u.pushEnabled !== false);
}
if (users.length === 0) {
console.log('No active users to push. Register users or enable push first.');
process.exit(0);
}
// Generate prompt for each user
users.forEach((user, i) => {
const lang = user.language || 'zh';
const mood = moodOverride || user.mood || 'neutral';
const MOOD_TONE_ZH = {
happy: '用户今天心情开心愉悦。寄语基调:共鸣这份喜悦,引导珍惜当下,扩大感恩视角。',
sad: '用户今天情绪低落。寄语基调:先温柔接纳悲伤("难过是可以的"),不急于鼓励,给予被看见的感觉,再轻轻引导向前。',
anxious: '用户今天焦虑。寄语基调:放慢节奏,把"未来"的巨大变成"此刻"的具体微小,给一点落脚点。不说"别担心"。',
tired: '用户今天很疲惫。寄语基调:允许休息,休息本身就是努力的一部分。温柔而坚定地说:你已经做得够多了。',
lost: '用户今天感到迷茫。寄语基调:不给"正确答案",给方向感和陪伴感。迷茫是在生长,不是迷失。',
calm: '用户今天平静。寄语基调:深化这份平静,引导觉察当下细微之美,生活里的小丰盈。',
grateful: '用户今天感恩。寄语基调:呼应感恩,从一件小事延伸到生命本身的珍贵。',
angry: '用户今天生气/烦躁。寄语基调:先接纳情绪("愤怒说明你在乎"),不评判,再引导情绪流动而非压抑。',
neutral: '用户今天心情平常。寄语基调:一段温暖、有质感的人生寄语,既不过度激励也不无聊,像一位老朋友说的话。'
};
const MOOD_TONE_EN = {
happy: "User is feeling happy today. Tone: resonate with their joy, invite them to savour the moment and expand gratitude.",
sad: "User is feeling sad. Tone: first acknowledge the sadness gently ('it is okay to feel this'), don't rush to cheering up, make them feel seen, then softly point forward.",
anxious: "User is anxious. Tone: slow things down, shrink the overwhelming future into one small concrete present moment. Don't say 'don't worry'.",
tired: "User is tired. Tone: give permission to rest. Rest is not laziness — it is part of the effort. Say: you have done enough today.",
lost: "User feels lost. Tone: don't give 'the answer', give a sense of direction and companionship. Being lost means you are growing.",
calm: "User feels calm. Tone: deepen the stillness, invite noticing the small beauties in the present moment.",
grateful: "User feels grateful. Tone: echo the gratitude, expand from one small thing to the preciousness of being alive.",
angry: "User is angry. Tone: first honour the anger ('anger means you care'), no judgement, then guide the emotion to flow rather than be suppressed.",
neutral: "User is in a neutral state. Tone: a warm, textured life message — like a trusted friend talking, not overly motivational, not bland."
};
const toneHint = lang === 'en' ? MOOD_TONE_EN[mood] : MOOD_TONE_ZH[mood];
const dateStr = lang === 'en' ? dateStr_en : dateStr_zh;
if (i > 0) console.log('\n' + '─'.repeat(60) + '\n');
if (lang === 'en') {
console.log(`[Push to user: user.userId | lang: en | mood: mood]
Please write a morning life message for this user.
Date: dateStr
Mood context: toneHint
Requirements:
- Length: 100–150 words of main message body
- Voice: warm, personal, non-preachy — like a thoughtful friend, not a motivational poster
- End with one short distilled line (1 sentence, ≤ 20 words) that could stand alone as a quote
- No generic phrases like "believe in yourself", "every day is a gift", "you got this"
- Grounded in real human experience, not abstract positivity
Output format:
🌅 Good Morning, user.userId · dateStr
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[Main message — 100-150 words]
✨ Today's thought:
"[The distilled one-liner]"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
} else {
console.log(`[推送给用户:user.userId | 语言:zh | 心情:mood]
请为该用户写一段早晨人生寄语。
日期:dateStr
心情背景:toneHint
写作要求:
- 正文长度:100-150字
- 语气:温暖、真实、不说教 — 像一位懂你的朋友,不是励志海报
- 结尾附一句提炼语(不超过20字,可单独成句)
- 避免陈词滥调:不用"加油""相信自己""每一天都是礼物""你最棒"
- 写人真实会经历的处境,不写空洞正能量
输出格式:
🌅 早安,user.userId · dateStr
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[寄语正文 100-150字]
✨ 今日寄语:
「[提炼语,≤20字]」
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
}
});
FILE:scripts/push-toggle.js
#!/usr/bin/env node
/**
* daily-mood — push-toggle.js
* 管理 openclaw cron 定时推送开关,支持全局或单用户级别
*
* 用法:
* node scripts/push-toggle.js on # 开启全局 cron(早晨+傍晚)
* node scripts/push-toggle.js on --userId <id> # 仅开启某用户的推送标志
* node scripts/push-toggle.js off # 关闭全局 cron
* node scripts/push-toggle.js off --userId <id> # 仅关闭某用户的推送标志
* node scripts/push-toggle.js status # 查看 cron 状态
*/
const fs = require('fs');
const path = require('path');
const USERS_DIR = path.join(__dirname, '..', 'data', 'users');
const args = process.argv.slice(2);
const action = args[0];
if (!action || !['on','off','status'].includes(action)) {
console.error('Usage: node scripts/push-toggle.js on|off|status [--userId <id>]');
process.exit(1);
}
const userIdIdx = args.indexOf('--userId');
const userId = userIdIdx !== -1
? args[userIdIdx + 1].replace(/[^a-zA-Z0-9_-]/g,'').substring(0,64)
: null;
const skillDir = path.join(__dirname, '..');
const morningCmd = `cd skillDir && node scripts/morning-push.js`;
const eveningCmd = `cd skillDir && node scripts/evening-push.js`;
// Per-user toggle: just update the pushEnabled flag in the profile
if (userId) {
const fp = path.join(USERS_DIR, `userId.json`);
if (!fs.existsSync(fp)) {
console.error(`User "userId" not found. Register first: node scripts/register.js userId`);
process.exit(1);
}
const u = JSON.parse(fs.readFileSync(fp, 'utf8'));
if (action === 'on') {
u.pushEnabled = true;
fs.writeFileSync(fp, JSON.stringify(u, null, 2));
console.log(`Push ENABLED for user: userId`);
} else if (action === 'off') {
u.pushEnabled = false;
fs.writeFileSync(fp, JSON.stringify(u, null, 2));
console.log(`Push DISABLED for user: userId`);
} else {
console.log(`User userId: pushEnabled = u.pushEnabled`);
}
process.exit(0);
}
// Global cron toggle
if (action === 'on') {
console.log(`
开启每日心情寄语推送,请在终端运行以下命令:
openclaw cron add "0 8 * * *" "morningCmd"
openclaw cron add "0 21 * * *" "eveningCmd"
这将设置:
• 每日 08:00 早晨寄语推送(推送所有已注册用户)
• 每日 21:00 傍晚寄语推送(推送所有已注册用户)
添加后验证:
openclaw cron list
注册用户:
node scripts/register.js <userId> --lang zh --mood neutral
关闭推送:
node scripts/push-toggle.js off
`);
} else if (action === 'off') {
console.log(`
关闭每日心情寄语推送,请运行:
openclaw cron list
找到含 "daily-mood" 的任务并记录 ID,然后:
openclaw cron delete <morning-task-id>
openclaw cron delete <evening-task-id>
如只想暂停某一用户(不删除 cron):
node scripts/push-toggle.js off --userId <userId>
`);
} else {
// Show registered user count
let userCount = 0;
let enabledCount = 0;
if (fs.existsSync(USERS_DIR)) {
const files = fs.readdirSync(USERS_DIR).filter(f => f.endsWith('.json'));
userCount = files.length;
enabledCount = files.filter(f => {
const u = JSON.parse(fs.readFileSync(path.join(USERS_DIR, f), 'utf8'));
return u.pushEnabled !== false;
}).length;
}
console.log(`
每日心情寄语推送状态
─────────────────────────────
已注册用户:userCount
推送已开启:enabledCount 人
查看 cron 任务:
openclaw cron list
→ 查找含 "daily-mood" 的条目
→ 应有 2 条:morning-push.js (08:00) 和 evening-push.js (21:00)
如未配置 cron:
node scripts/push-toggle.js on
查看所有用户:
node scripts/register.js --list
`);
}
FILE:scripts/register.js
#!/usr/bin/env node
/**
* daily-mood — register.js
* 用户注册与档案管理:language、mood 偏好、推送开关
*
* 用法:
* node scripts/register.js <userId> [--lang zh|en] [--mood <mood>]
* node scripts/register.js --show <userId>
* node scripts/register.js --list
*/
const fs = require('fs');
const path = require('path');
const ALLOWED_LANGS = new Set(['zh', 'en']);
const ALLOWED_MOODS = new Set([
'happy','sad','anxious','tired','lost','calm','grateful','angry','neutral'
]);
const USERS_DIR = path.join(__dirname, '..', 'data', 'users');
const args = process.argv.slice(2);
// --list
if (args.includes('--list')) {
if (!fs.existsSync(USERS_DIR)) { console.log('No users registered yet.'); process.exit(0); }
const files = fs.readdirSync(USERS_DIR).filter(f => f.endsWith('.json'));
if (files.length === 0) { console.log('No users registered yet.'); process.exit(0); }
console.log(`\nRegistered users (files.length):\n`);
files.forEach(f => {
const u = JSON.parse(fs.readFileSync(path.join(USERS_DIR, f), 'utf8'));
console.log(` u.userId lang=u.language mood=u.mood push=u.pushEnabled since=u.registeredAt.substring(0,10)`);
});
process.exit(0);
}
// --show <userId>
const showIdx = args.indexOf('--show');
if (showIdx !== -1) {
const userId = args[showIdx + 1];
if (!userId) { console.error('Usage: node scripts/register.js --show <userId>'); process.exit(1); }
const filePath = path.join(USERS_DIR, `userId.json`);
if (!fs.existsSync(filePath)) { console.error(`User "userId" not found.`); process.exit(1); }
const u = JSON.parse(fs.readFileSync(filePath, 'utf8'));
console.log(JSON.stringify(u, null, 2));
process.exit(0);
}
// Register / update
const userId = args.filter(a => !a.startsWith('--'))[0];
if (!userId) {
console.error('Usage: node scripts/register.js <userId> [--lang zh|en] [--mood <mood>]');
console.error(' node scripts/register.js --show <userId>');
console.error(' node scripts/register.js --list');
console.error('');
console.error('Moods:', [...ALLOWED_MOODS].join(', '));
process.exit(1);
}
// Sanitize userId: alphanumeric + hyphen/underscore, max 64 chars
const safeUserId = userId.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 64);
if (!safeUserId) { console.error('ERROR: userId must be alphanumeric.'); process.exit(1); }
const langIdx = args.indexOf('--lang');
const rawLang = langIdx !== -1 ? args[langIdx + 1] : null;
const lang = rawLang && ALLOWED_LANGS.has(rawLang) ? rawLang : 'zh';
const moodIdx = args.indexOf('--mood');
const rawMood = moodIdx !== -1 ? args[moodIdx + 1] : null;
const mood = rawMood && ALLOWED_MOODS.has(rawMood) ? rawMood : 'neutral';
if (!fs.existsSync(USERS_DIR)) fs.mkdirSync(USERS_DIR, { recursive: true });
const filePath = path.join(USERS_DIR, `safeUserId.json`);
const existing = fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, 'utf8')) : {};
const profile = {
userId: safeUserId,
language: lang,
mood: mood,
pushEnabled: existing.pushEnabled !== undefined ? existing.pushEnabled : true,
registeredAt: existing.registeredAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
};
fs.writeFileSync(filePath, JSON.stringify(profile, null, 2));
const action = existing.userId ? 'Updated' : 'Registered';
console.log(`action user: safeUserId`);
console.log(` Language: lang | Default mood: mood | Push: profile.pushEnabled`);
console.log('');
console.log(`To enable scheduled push:`);
console.log(` node scripts/push-toggle.js on --userId safeUserId`);
Daily work journal + machine migration toolkit. Auto-scans all registered projects (git activity, file changes, API status), generates a daily dashboard, and...
---
name: ddday
version: 1.0.0
description: |
Daily work journal + machine migration toolkit.
Auto-scans all registered projects (git activity, file changes, API status),
generates a daily dashboard, and provides one-command data export/restore
for seamless machine migration.
allowed-tools:
- Bash
- Read
- Write
- AskUserQuestion
- Glob
- Grep
---
## What ddday Does
> **Record your daily work. Migrate your data. Resume instantly on a new machine.**
ddday is a personal work journal and migration toolkit. It tracks all your projects, generates daily HTML dashboards and markdown logs, and when you switch machines, packs everything into a portable bundle that any AI agent can read to immediately understand your full work context.
---
## Setup
### Step 1 — Initialize ddday
Create the ddday directory and workspace config:
```bash
DDDAY_HOME="$HOME/Desktop/ddday"
mkdir -p "$DDDAY_HOME"/{logs,context}
```
### Step 2 — Create workspace.json
Register your projects. Edit `$DDDAY_HOME/workspace.json`:
```json
{
"version": "1.0.0",
"updated": "YYYY-MM-DD",
"projects": [
{
"name": "my-project",
"path": "/path/to/my-project",
"type": "node",
"emoji": "📦",
"description": "Brief description of the project"
}
]
}
```
**Supported types**: `node`, `python`, `shopify`, `design`, `workspace`, `skill`
### Step 3 — Register as Claude Code skill
```bash
ln -sfn "$DDDAY_HOME" "$HOME/.claude/skills/ddday"
```
### Step 4 — Set up daily cron (optional)
```bash
chmod +x "$DDDAY_HOME/generate_dashboard.py"
(crontab -l 2>/dev/null; echo "2 18 * * * cd $DDDAY_HOME && python3 generate_dashboard.py") | crontab -
```
---
## Commands
```
/ddday — Scan all projects, generate daily log + dashboard
/ddday add <path> — Register a new project
/ddday context — Generate AI context pack (for new AI conversations)
/ddday snapshot — Full work snapshot (all data + business reports + AI memory)
/ddday export — One-command migration bundle to Desktop
/ddday doctor — Environment health check (run after migration)
/ddday log — View latest daily log
/ddday status — Quick project status (no log written)
```
---
## Mode Recognition
Parse user input to select mode:
- Contains `add` + a path → **Mode 2 (Register project)**
- Contains `context` → **Mode 3 (AI context pack)**
- Contains `export` → **Mode 4 (Migration export)**
- Contains `setup` → **Mode 5 (New machine restore)**
- Contains `doctor` → **Mode 6 (Health check)**
- Contains `snapshot` → **Mode 7 (Work snapshot)**
- Contains `log` → Read latest log file
- Contains `status` → Quick scan, no file output
- Default → **Mode 1 (Daily scan + record)**
---
## Mode 1 — Daily Scan + Record (Default)
### Step 1 — Load workspace
```python
import json, os
DDDAY_HOME = os.environ.get("DDDAY_HOME", os.path.expanduser("~/Desktop/ddday"))
ws_file = os.path.join(DDDAY_HOME, "workspace.json")
with open(ws_file, "r") as f:
workspace = json.load(f)
projects = workspace.get("projects", [])
print(f"Registered {len(projects)} projects")
for p in projects:
print(f" - {p['name']}: {p['path']}")
```
### Step 2 — Scan each project
For each registered project folder:
```bash
PROJECT_PATH="/path/to/project"
echo "=== Today's commits ==="
cd "$PROJECT_PATH" && git log --oneline --since="today" 2>/dev/null || echo "Not a git repo"
echo "=== Uncommitted changes ==="
cd "$PROJECT_PATH" && git diff --stat 2>/dev/null
echo "=== Untracked files ==="
cd "$PROJECT_PATH" && git status --short 2>/dev/null | head -20
echo "=== Recently modified files (24h) ==="
find "$PROJECT_PATH" -maxdepth 3 -type f -mtime -1 \
-not -path '*/node_modules/*' \
-not -path '*/.git/*' \
-not -path '*/dist/*' \
-not -name '.DS_Store' \
2>/dev/null | head -20
```
Also read the project's `CLAUDE.md` or `README.md` if present.
### Step 3 — Generate daily log
Save scan results to `logs/YYYY-MM-DD.md`:
```markdown
# ddday work log · {date}
## Overview
- Today's commits: {total_commits}
- Active projects: {active}/{total}
- Pending changes: {pending}
## {emoji} {project_name} ({type})
- Status: {status_emoji} {status_text}
- Branch: {branch}
- Today's commits: {count}
- {commit_messages}
- Uncommitted changes: {diff_stat}
## Summary
- Completed: {from git commits}
- In progress: {from diff}
- Next steps: {AI suggestions}
```
### Step 4 — Generate HTML dashboard
Run the dashboard generator to create `dashboard.html`:
- Project cards with status indicators
- Commit history per project
- Stats bar (total commits, active projects, pending changes)
- Light/dark theme toggle
### Step 5 — Update project overview
Save `context/projects-overview.md` for AI consumption.
---
## Mode 2 — Register Project (/ddday add <path>)
### Step 1 — Validate path and detect type
```python
import os
path = os.path.expanduser("<user-provided-path>")
has_git = os.path.isdir(os.path.join(path, ".git"))
has_package = os.path.isfile(os.path.join(path, "package.json"))
has_shopify = os.path.isdir(os.path.join(path, ".shopify"))
has_python = os.path.isfile(os.path.join(path, "requirements.txt"))
project_type = "unknown"
if has_shopify: project_type = "shopify"
elif has_package: project_type = "node"
elif has_python: project_type = "python"
```
### Step 2 — Add to workspace.json
Append project entry, check for duplicates first.
### Step 3 — Ask for description
Use AskUserQuestion to get a brief description from the user.
---
## Mode 3 — AI Context Pack (/ddday context)
Generates `context/projects-overview.md` containing:
- All project metadata, paths, descriptions
- Recent git log per project
- Key file listings
- Current status
Purpose: feed to any new AI conversation for instant project awareness.
---
## Mode 4 — Migration Export (/ddday export)
### What it does
1. **Generates a work snapshot** first (Mode 7)
2. **Collects all data**:
- ddday directory (logs, context, config)
- Report archives (e.g., `~/.shopadmin/` or custom report dirs)
- Claude Code skills
- Credential files (user-configured paths)
- Claude project memory (`~/.claude/projects/*/memory/`)
- Crontab backup
- Python requirements
3. **Creates `manifest.json`**: path mappings, credentials index, cron jobs, dependencies
4. **Packs into** `~/Desktop/ddday-migration-{date}.tar.gz`
5. **Includes** `READ-ME-FIRST.md` at pack root (= the work snapshot)
### Migration bundle structure
```
ddday-migration/
├── READ-ME-FIRST.md # Work snapshot — new AI reads this first
├── manifest.json # Path mappings, dependencies, cron
├── ddday/ # Full ddday directory
├── skills/ # Custom skill definitions
├── credentials/ # Credential files
├── claude-memory/ # Claude project memory files
├── crontab.bak # Crontab backup
├── requirements.txt # Python dependencies
├── setup.sh # One-command restore script
├── doctor.py # Health check script
└── work_snapshot.py # Snapshot generator
```
---
## Mode 5 — New Machine Restore (/ddday setup)
Run on new machine after extracting the migration bundle:
```bash
tar xzf ddday-migration-*.tar.gz
cd ddday-migration
bash setup.sh
```
### setup.sh automatically:
1. Detects new `$HOME` and username
2. Installs missing dependencies (Homebrew, Python, Git)
3. Installs Python packages
4. Copies all files to correct locations
5. Replaces all hardcoded paths (old `$HOME` → new `$HOME`)
6. Creates skill symlinks
7. Registers cron jobs
---
## Mode 6 — Health Check (/ddday doctor)
Checks 8 categories:
1. **Project paths** — Do all registered projects exist?
2. **Core files** — SKILL.md, workspace.json, scripts present?
3. **Credentials** — Required credential files in place?
4. **Python deps** — Required packages installed?
5. **Skills** — Symlinks correct, skill directories intact?
6. **Cron jobs** — Registered and scripts exist?
7. **API connectivity** — Can reach configured APIs?
8. **Path consistency** — Any stale paths from old machine?
Output: traffic-light report with pass/warn/fail counts.
---
## Mode 7 — Work Snapshot (/ddday snapshot)
Generates a **self-contained Markdown file** that any AI can read to immediately resume all work:
1. **User profile** — Role, tech stack, work style
2. **Project panorama** — Path, git status, README, .env keys, recent commits
3. **Business data** — Latest reports from all configured report directories
4. **AI memory** — Claude Code project memory files
5. **Recent logs** — Last 7 days of ddday logs
6. **Team handbook** — Available skills and their triggers
7. **Environment** — Cron, credentials, symlinks, system info
8. **Quick start guide** — How to immediately pick up work
Output:
- `context/work-snapshot-YYYY-MM-DD.md` — dated archive
- `context/LATEST-SNAPSHOT.md` — latest copy (AI reads this)
---
## Full Migration Flow
### Old machine
```bash
/ddday export # Auto-generates snapshot + packs everything
```
### New machine
```bash
tar xzf ddday-migration-*.tar.gz
cd ddday-migration
cat READ-ME-FIRST.md # AI reads this → instantly knows all your work
bash setup.sh # Auto-restore environment
python3 doctor.py # Health check
/ddday # Start working
```
---
## Configuration
ddday uses `$DDDAY_HOME` (defaults to `~/Desktop/ddday`) as its root:
```
$DDDAY_HOME/
├── SKILL.md # This skill definition
├── workspace.json # Project registry
├── generate_dashboard.py # Dashboard generator
├── work_snapshot.py # Snapshot generator
├── export.py # Migration exporter
├── setup.sh # New machine restore
├── doctor.py # Health check
├── run_ddday.sh # Cron entry point
├── dashboard.html # Latest dashboard (auto-generated)
├── logs/ # Daily logs (YYYY-MM-DD.md)
└── context/ # AI context files
├── projects-overview.md
├── LATEST-SNAPSHOT.md
└── work-snapshot-*.md
```
## Customization
### Adding report directories
If you have report tools (analytics, monitoring, etc.), edit `export.py` to include their output directories in the migration bundle. The work snapshot (`work_snapshot.py`) can also be extended to read from custom report paths.
### Extending project types
Add new type detection logic in Mode 2 by checking for framework-specific files (e.g., `Cargo.toml` for Rust, `go.mod` for Go).