@clawhub-ramboxie-90852d38cc
Daily non-ferrous metals briefing for AI agents. Collects real-time base metals prices (Cu/Zn/Ni/Co/Mg/Bi) from Yahoo Finance, CCMN 長江有色, SMM, and Westmetall...
---
name: metal-price
description: Daily non-ferrous metals briefing for AI agents. Collects real-time base metals prices (Cu/Zn/Ni/Co/Mg/Bi) from Yahoo Finance, CCMN 長江有色, SMM, and Westmetall, then delivers a six-section professional investment research report via Telegram at 14:00 CST. Zero paid APIs required. Use when you need automated metals market monitoring, LME price tracking, or professional trading briefings with macro/inventory/futures-structure/sentiment cross-analysis.
---
# 有色小鑽風 · Metal Price Daily 🦞📊
> AI-driven non-ferrous metals daily briefing — six-section investment research report via Telegram.
每日 14:00 CST(上午盤收盤後)自動採集有色金屬行情,生成六板塊專業投研報告並推送到 Telegram。**零付費 API,開箱即用。**
## Features
- 📊 **多源價格聚合** — Yahoo Finance (USD)、CCMN 長江有色 (CNY)、SMM 上海有色、Westmetall (LME Cash)
- 📰 **新聞日期過濾** — Google News RSS(中英文,嚴格 48h 時效過濾,不顯示過期條目)
- 🏦 **機構觀點提煉** — 自動識別高盛/摩根大通/花旗報告,提煉中文結論,不堆原始鏈接
- 📈 **技術面分析** — 遠期曲線(spot/+2M/+6M)、Backwardation/Contango 結構判斷、各品種支撐阻力位
- 🔮 **四維交叉推理** — 宏觀(A/H 分化)× 庫存 × 期貨結構 × 情緒,段落式分析
- 🎯 **操作參考** — 六個品種各有具體價位建議(支撐/阻力/止損),非泛泛而談
- 🚫 **零付費 API** — 全部免費數據源
## Metals Covered(目標品種)
| Metal | USD | CNY |
|-------|-----|-----|
| Copper (Cu) | Yahoo HG=F ✅ + COMEX 遠期 ✅ | CCMN ✅ + SMM ✅(交叉驗證)|
| Zinc (Zn) | Westmetall LME Cash ✅ | CCMN ✅ + SMM ✅(交叉驗證)|
| Nickel (Ni) | Westmetall LME Cash ✅ | CCMN ✅ + SMM ✅(交叉驗證)|
| Cobalt (Co) | TradingEconomics ✅ | CCMN ✅ |
| Bismuth (Bi) | SMM CIF USD/kg ✅ | SMM 精鉍 ✅ |
| Magnesium (Mg) | — | CCMN 1#鎂 ✅ |
> 鋁(Al)不在本 Skill 追蹤範圍內。
## Report Format(六板塊)
```
一、行情快照 — 實時價格 + 漲跌 + 數據源
二、行業指數 — XME / COPX / 申萬有色,A/H 分化信號
三、技術面 — 各品種支撐/阻力/趨勢判斷
四、基本面 — LME 庫存(Cu/Zn/Ni)+ 去庫/累庫信號
五、市場情緒 — 機構觀點(中文提煉)+ 市場情緒分析
六、四維推理 — 宏觀/庫存/結構/情緒交叉推理 + 操作參考
```
## Quick Start
```bash
git clone https://github.com/RAMBOXIE/metal-price.git
cd metal-price
cp .env.example .env # 填入 TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID
node scripts/fetch-all-data.mjs # 採集數據(~5s)
node scripts/daily-report.mjs # 採集 + 生成 + 發送完整日報
```
## Environment Variables
```env
TELEGRAM_BOT_TOKEN= # 必填:Telegram Bot Token
TELEGRAM_CHAT_ID= # 必填:目標群組/頻道 ID
```
## Key Scripts
| Script | Description |
|--------|-------------|
| `scripts/fetch-all-data.mjs` | 主數據採集腳本,~5s 完成,輸出完整 JSON(價格/庫存/新聞/情緒/指數)|
| `scripts/daily-report.mjs` | 六板塊日報(調用 fetch-all-data → 生成分析 → Telegram 發送)|
| `scripts/fetch-news.mjs` | 獨立新聞抓取,含 48h 日期過濾 |
| `scripts/send-telegram.mjs` | Telegram 發送工具 |
## Agent Integration (OpenClaw Cron)
在 OpenClaw 中設置每日 14:00 定時任務:
```json
{
"schedule": { "kind": "cron", "expr": "0 14 * * *", "tz": "Asia/Shanghai" },
"payload": {
"kind": "agentTurn",
"message": "Run node D:\\Projects\\metal-price\\scripts\\daily-report.mjs and confirm delivery.",
"timeoutSeconds": 90
}
}
```
## Data Sources
| Source | Metals | Status |
|--------|--------|--------|
| Yahoo Finance (HG=F) | Cu USD | ✅ Free |
| CCMN 長江有色 | Cu/Zn/Ni/Co/Mg CNY | ✅ Free |
| SMM hq.smm.cn/h5 | Bi/Pb/Sn CNY+USD | ✅ Free, no login |
| Westmetall | Zn/Ni USD + LME庫存 | ✅ Free |
| TradingEconomics | Co USD | ✅ Free (scrape) |
| OmetalCN | Cu/Zn/Ni/Sn CNY (備用) | ✅ Free |
| Reddit r/Commodities | 情緒 | ✅ JSON API |
| Google News RSS | 中英文新聞 | ✅ Free |
| LME official | 庫存 | ❌ Cloudflare封鎖(Westmetall替代)|
## Changelog
- **v1.3.7** — 报告重构为“1)行业指数 2)品种数据 3)交易要点 4)关键风险”固定目录;预警改为低/中/高;强化短中期趋势判断(趋势阶段/方向/置信度/动量状态);行业指数新增中文名称与周环比(较约5个交易日前);发送改为纯文本避免 Telegram Markdown 实体报错。
- **v1.3.6** — 日报升级为“结论→论据→执行→失效条件”逐标的结构(Cu/Zn/Ni/Co/Bi/Mg);新增“证据总览表”;新增 K 线趋势判断(Cu: HG=F 5/10/20MA + 斜率,Al 预留接口);新增可复盘记忆系统 `memory/signal-history.jsonl`(按日记录 date/cny/usd/lmeInv/cnyChange/alertLevel/trendTag/keyEvidence);对无连续K线品种改用近N日现货序列趋势;缺失数据强制输出“缺失与替代依据”。
- **v1.3.5** — 日報框架升級:新增「全標的加速預警(Cu/Zn/Ni/Co/Bi/Mg)」模塊(0-3 級+觸發因子+可交易建議);第六部分新增「關鍵數值依據」行(庫存/基差/進口盈虧/需求均值);第七部分保持「結論→證據→影響」;操作參考為六品種新增狀態標籤(趨勢/加速/震盪/觀望)+一句執行建議;全報告維持 CNY/噸主展示、USD 僅括號輔助。
- **v1.3.4** — 全報告價格主展示統一為 CNY/噸(含技術面/結構維度/操作參考);USD 僅保留為括號輔助資訊,避免非人民幣主展示。
- **v1.3.3** → 第七部分重寫為「結論→證據→影響」簡報研報格式(去標題黨/去省略號);CCMN 故障時 Cu/Zn/Ni 自動切 SMM 備援;USD/CNY 新增多源備援(Yahoo + exchangerate.host + frankfurter);外盤主顯示統一換算為 CNY/噸(保留原始 USD 於括號)
- **v1.3.2** — 第七部分改為「今日新增」模式:機構觀點按與昨日差異輸出(附日期),情緒模塊無新增時明示「第七部分無新增」,避免連續重複文案;新增報告狀態快照(memory/daily-report-state.json)
- **v1.3.1** — 机构觀點/市場情緒全中文提煉,去除英文原文;48h 時效過濾,無匹配直接跳過;無新增時明示,防止幻覺;報告前自檢(缺字段/無新訊提示),DRY_RUN 可安全預覽
- **v1.3.0** — 庫存三件套(交易所/保稅/社會佔位)、進口盈虧/到岸成本(Cu/Zn/Ni)、信號摘要四維打分(庫存/基差/進口/需求)、宏觀風險溫度計(DXY/VIX/CRB佔位/10Y)、DRY_RUN 安全開關
- **v1.2.0** — 新增鎂(Mg)品種;移除鋁(Al);六板塊投研格式重寫;新聞 48h 時效過濾;操作建議含具體價位
- **v1.1.2** — OmetalCN 備用源;Westmetall 超時重試
- **v1.1.1** — SMM 交叉驗證(Cu/Zn/Ni);Reddit 異動偵測
- **v1.1.0** — LME 庫存(Westmetall);期貨遠期曲線;行業指數(XME/COPX/申萬)
## 声明(ClawHub 发布要求)
- 本 Skill 仅提供公开市场数据整理与研究框架,不构成投资建议或收益承诺。
- 数据来自第三方公开源(Yahoo/CCMN/SMM/Westmetall/Reddit/Google News),可能存在延迟、修订或缺失,使用前请二次核验。
- 报告中的趋势/预警为模型化信号,不应替代风控、仓位管理与独立判断。
- 本 Skill 不包含或分发任何受保护的私有数据、付费接口凭据、破解逻辑或侵权内容。
## License
MIT · [GitHub](https://github.com/RAMBOXIE/metal-price)
FILE:package.json
{
"name": "metal-price",
"version": "1.3.7",
"type": "module",
"scripts": {
"start": "node scripts/daily-report.mjs",
"backfill": "node scripts/backfill-history.mjs",
"prices": "node scripts/fetch-prices.mjs",
"news": "node scripts/fetch-news.mjs"
}
}
FILE:README.md
# metal-price 🦞
> AI-powered non-ferrous metals daily briefing — data collection + professional analyst report via Telegram.
A lightweight Node.js system that collects real-time base metals prices from multiple free sources, aggregates market news and forum sentiment, then delivers a professional trading-style analysis to Telegram every day at 14:00 CST (after China's morning session closes).
## Features
- 📊 **Multi-source price aggregation** — Yahoo Finance (USD), CCMN 長江有色 (CNY), SMM/Westmetall cross-checks
- 📰 **News & sentiment** — Google News (CN+EN) with 48h filter, SMM 快訊, Reddit r/Commodities 異動偵測
- 🏦 **Investment bank signals** — 自動抽取高盛/摩根大通/花旗的基本金屬觀點
- 📈 **Technical** — forward curve (spot/+2M/+6M), basis, contango/backwardation detection
- 📦 **庫存三件套** — 交易所 / 保稅 / 社會庫存(佔位兜底),周環比箭頭
- 🚢 **進口盈虧/到岸成本** — Cu/Zn/Ni 匯率+外盤→內盤,盈虧/壓力標註
- 📊 **信號摘要** — 庫存 / 基差 / 進口盈虧 / 需求 四維 +/0/- 打分
- 🌡️ **宏觀溫度計** — DXY / VIX / CRB(佔位)/ 10Y,提示風險開關
- 🔮 **Cross reasoning** — 宏觀 × 庫存 × 結構 × 情緒,段落式分析 + 操作參考
- ⏰ **14:00 CST timing** — after China morning session + LME overnight data
- 🚫 **Zero paid APIs** — all free data sources; no API key required
## Metals Covered
| Metal | USD Source | CNY Source |
|-------|------------|------------|
| Copper (Cu) | Yahoo `HG=F` + COMEX forwards | CCMN 長江有色 + SMM 交叉驗證 |
| Zinc (Zn) | Westmetall LME Cash | CCMN 長江有色 + SMM 交叉驗證 |
| Nickel (Ni) | Westmetall LME Cash | CCMN 長江有色 + SMM 交叉驗證 |
| Cobalt (Co) | TradingEconomics (USD) | CCMN 長江有色 |
| Bismuth (Bi)| SMM CIF USD/kg | SMM 精鉍 |
| Magnesium (Mg) | — | CCMN 1#鎂 |
## Architecture
```
fetch-all-data.mjs ← Master data script (≤3s, runs in parallel)
├── Yahoo Finance USD spot + forward contracts
├── CCMN 長江有色 API CNY spot prices (Cu/Zn/Ni/Co + 30 others)
├── Stooq Bismuth USD/t
├── LME inventory 3 methods, all Cloudflare-blocked (returns null)
├── Google News RSS (CN) Chinese metals news
├── Google News RSS (EN) Investment bank base metals analysis
├── SMM 上海有色網 Flash news headlines
└── Reddit r/Commodities Top (weekly) + Hot (realtime) with metal keyword filter
+ surging post detection (hot but not in top)
send-telegram.mjs ← Utility: pipe JSON or arg to Telegram Markdown message
```
The actual analysis/briefing is written by an AI agent (Claude) that runs `fetch-all-data.mjs`, reads the JSON, fetches 2 news articles, and composes a professional trading brief.
## Quick Start
### 1. Clone & install
```bash
git clone https://github.com/RAMBOXIE/metal-price.git
cd metal-price
# No npm install needed — zero external dependencies
```
### 2. Configure environment
```bash
cp .env.example .env
# Edit .env with your Telegram bot token and chat ID
```
### 3. Run data collection
```bash
node scripts/fetch-all-data.mjs
```
Output is a JSON object with prices, forwards, inventory, news, ibNews, and forumSentiment.
### 4. Send a message
```bash
# Send a string
node scripts/send-telegram.mjs "Hello from metal-price 🦞"
# Pipe JSON summary
node scripts/fetch-all-data.mjs | node scripts/send-telegram.mjs
```
## Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `TELEGRAM_BOT_TOKEN` | Your Telegram bot token (from @BotFather) | ✅ |
| `TELEGRAM_CHAT_ID` | Target chat/channel ID | ✅ |
| `METAL_PRICE_API_KEY` | metalpriceapi.com key (free tier = precious metals only, not used) | ❌ |
| `ALPHA_VANTAGE_KEY` | Alpha Vantage key (monthly copper only, not used) | ❌ |
## Output JSON Structure
```json
{
"date": "2026-03-15",
"dataDate": "2026-03-13",
"isMarketOpen": false,
"marketNote": "休市:數據截至 2026/03/13(上個交易日)",
"changeNote": "所有漲跌均為日環比(vs 前一交易日收盤)",
"prices": {
"copper": { "usd": 5.757, "usdChangePct": 0.75, "usdUnit": "USD/lb", "cny": 100690, "cnyChange": -330 },
"zinc": { "usd": null, "usdChangePct": null, "usdUnit": "USD/t", "cny": 24130, "cnyChange": -220 },
"aluminum":{ "usd": 3423, "usdChangePct": 1.18, "usdUnit": "USD/t", "cny": null, "cnyChange": null },
"nickel": { "usd": null, "cny": 141750, "cnyChange": 600 },
"cobalt": { "usd": null, "cny": 432000, "cnyChange": 0 },
"bismuth": { "usd": 2272, "usdUnit": "USD/t", "source": "Stooq/BI.F", "reliabilityNote": "..." }
},
"forwards": {
"copper": {
"spot": { "price": 5.757, "symbol": "HG=F", "expiry": "2026-03" },
"near": { "price": 5.757, "symbol": "HGK26.CMX", "expiry": "2026-05" },
"far": { "price": 5.873, "symbol": "HGU26.CMX", "expiry": "2026-09" }
}
},
"inventory": { "copper": null, "note": "LME blocked by Cloudflare (403)" },
"news": [ { "title": "...", "url": "..." } ],
"ibNews": [ { "title": "Goldman Sachs expects copper...", "url": "..." } ],
"forumSentiment": {
"smmHighlights": "【SMM快訊】...",
"redditSummary": "[31↑] Copper supply concerns...",
"redditSurging": "[🔥] Sudden copper mine shutdown...",
"xueqiuSummary": null
}
}
```
## Data Sources & Status
| Source | Status | Data |
|--------|--------|------|
| Yahoo Finance (HG=F) | ✅ Free | Copper USD spot + forward contracts |
| Yahoo Finance (ALI=F) | ✅ Free | Aluminum USD spot |
| Yahoo Finance (ZNC=F) | ❌ Disabled | Stale prevClose (2019), changePct unreliable |
| CCMN 長江有色 | ✅ Free | Cu/Zn/Ni/Co/Pb/Sn + 30 metals CNY |
| Stooq (BI.F) | ⚠️ Unreliable | Bismuth USD/t, price validity unconfirmed |
| LME official | ❌ Cloudflare 403 | All 3 fetch methods blocked |
| SMM 上海有色 | ✅ Headlines free | Flash news, prices require login |
| Reddit r/Commodities | ✅ JSON API | Top + Hot + surging detection |
| Google News RSS | ✅ Free | CN metals news + EN IB analysis |
| 雪球 Xueqiu | 🔒 Login required | Not implemented |
## Known Limitations
- **LME inventory**: All fetch methods return HTTP 403 (Cloudflare). Reported as `null` in output.
- **Bismuth (Bi)**: Stooq `BI.F` price ($2,272/t) is below market average ($6,600–$13,200/t). Reliability unconfirmed — use with caution.
- **Zinc USD**: Yahoo `ZNC=F` has a stale `prevClose` from 2019, making `changePct` meaningless. Disabled; CNY via CCMN still works.
- **Nickel/Cobalt USD**: Yahoo Finance has no LME Ni/Co contracts. CNY via CCMN only.
## 声明(ClawHub 发布要求)
- 本项目仅提供公开数据的整理与研究,不构成投资建议或收益承诺。
- 所有行情与资讯来自第三方公开来源,可能存在延迟、缺失或口径差异,请在交易前二次核验。
- 趋势与预警信号用于辅助决策,不替代仓位管理、风控纪律与独立判断。
- 项目不包含付费接口密钥、侵权数据或绕过访问控制的实现。
## License
MIT
FILE:references/ANALYSIS_DAILY_TEMPLATE.md
# Daily Analysis Template (Per Metal)
## [Metal]
- 状态:趋势 / 加速 / 震荡 / 观望
- 预警等级:L0-L3
- 后验判断:偏多 xx% / 中性 xx% / 偏空 xx%
### 结论
一句话结论(可执行)。
### 证据
- 数值证据 1:...
- 数值证据 2:...
- 事件/情绪证据:...
### 执行
- 动作:追随 / 回踩 / 区间 / 观望
- 仓位建议:轻 / 中 / 重
### 失效条件
- 若出现 XXX(价位/库存/情绪反转),则结论失效,切换为 XXX。
FILE:references/ANALYSIS_PLAYBOOK.md
# Metal Daily Analysis Playbook (v1.0)
目标:从“信息收集”升级为“可前瞻、可执行、可复盘”的分析系统。
## 1) 决策框架:OODA × Bayesian × Scenario
### OODA(Observe-Orient-Decide-Act)
- **Observe**:价格、库存、基差、进口盈亏、新闻/情绪。
- **Orient**:判断当前所处市场状态(趋势/加速/震荡/反转)。
- **Decide**:给出方向 + 仓位建议 + 风险条件。
- **Act**:输出可执行策略(追/等回踩/区间/观望)。
### Bayesian(证据更新)
每个标的维护先验与后验:
- 先验:昨日偏多/中性/偏空概率
- 新证据:库存、价差、情绪、事件
- 后验:更新后的偏多/中性/偏空概率
### Scenario(情景推演)
每个标的每天给 3 档情景:
1. 基准情景(概率最高)
2. 上行情景(触发条件)
3. 下行情景(触发条件)
---
## 2) 六标的统一评分体系(0-100)
每个标的:
- 供给压力/扰动(0-25)
- 库存与结构(0-20)
- 需求与现货(0-20)
- 跨市价差与进口盈亏(0-15)
- 资金与情绪(0-20)
输出:
- 0-39:弱势/观望
- 40-59:震荡/区间
- 60-74:偏强/趋势
- 75-100:加速/高波动
---
## 3) 加速预警(0-3)统一规则
- **L0**:无加速迹象
- **L1**:单因子触发(如单日涨幅异常)
- **L2**:双因子共振(价格+库存/价差/情绪其一)
- **L3**:三因子以上共振(高风险加速段)
触发因子(示例):
- 日内涨跌幅/涨跌额超阈值
- 去库/累库超阈值
- 近远月结构异常(Backwardation/Contango 快速切换)
- 关键词命中(squeeze, shortage, export ban, policy shock)
执行规则:
- L0-L1:正常策略
- L2:不追涨,回踩确认
- L3:禁止追高,轻仓/保护性止盈
---
## 4) 每日报告最小必填项(防“空话”)
每个标的必须包含:
1. 状态标签(趋势/加速/震荡/观望)
2. 预警等级(0-3)
3. 2-3 条证据(至少 1 条数值证据)
4. 执行动作(一句话)
5. 风险触发器(失效条件)
---
## 5) 反思闭环(持续改进)
每天收盘后记录:
- 当日判断:方向/节奏/风险
- 实际结果:是否触发预案
- 偏差来源:信号滞后/权重偏差/样本噪声
- 次日调整:阈值、权重、文案
每周一次:
- 统计预测命中率(方向、节奏、风险三维)
- 调整权重,不改核心框架
---
## 6) 原则
- 先给结论,再给证据,再给动作。
- 不输出“仅情绪描述”结论。
- 不做确定性表述,统一概率语言。
- 任何“强判断”必须配失效条件。
FILE:scripts/backfill-history.mjs
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = join(__dirname, '..');
const SIGNAL_HISTORY_PATH = join(PROJECT_ROOT, 'memory', 'signal-history.jsonl');
const MEMORY_DIR = join(PROJECT_ROOT, 'memory');
const METALS = [
{ code: 'Cu', key: 'copper', unit: 'USD/lb', yahoo: 'HG=F', westmetallField: 'LME_Cu_stock', westmetallUnit: 'USD/t' },
{ code: 'Zn', key: 'zinc', unit: 'USD/t', yahoo: 'ZNC=F', westmetallField: 'LME_Zn_stock', westmetallUnit: 'USD/t' },
{ code: 'Ni', key: 'nickel', unit: 'USD/t', yahoo: null, westmetallField: 'LME_Ni_stock', westmetallUnit: 'USD/t' },
{ code: 'Co', key: 'cobalt', unit: 'USD/t', yahoo: null, westmetallField: null },
{ code: 'Bi', key: 'bismuth', unit: 'USD/t', yahoo: null, westmetallField: null },
{ code: 'Mg', key: 'magnesium', unit: 'USD/t', yahoo: null, westmetallField: null },
];
const METAL_CODE_SET = new Set(METALS.map(m => m.code));
function usdToCnyPerTon(usd, unit, fxRate) {
if (usd == null || fxRate == null) return null;
const usdPerTon = unit === 'USD/lb' ? usd * 2204.62 : usd;
return usdPerTon * fxRate;
}
function parseWestmetallDate(str) {
// e.g. 24. April 2026
const m = str.match(/^(\d{1,2})\.\s+([A-Za-z]+)\s+(\d{4})$/);
if (!m) return null;
const months = {
January: 1, February: 2, March: 3, April: 4, May: 5, June: 6,
July: 7, August: 8, September: 9, October: 10, November: 11, December: 12,
};
const d = Number(m[1]);
const mo = months[m[2]];
const y = Number(m[3]);
if (!mo) return null;
return `y-String(mo).padStart(2, '0')-String(d).padStart(2, '0')`;
}
function parseNum(s) {
if (s == null) return null;
const cleaned = String(s).replace(/[,$\s]/g, '');
const n = Number(cleaned);
return Number.isFinite(n) ? n : null;
}
function loadJsonl(filePath) {
if (!existsSync(filePath)) return [];
return readFileSync(filePath, 'utf-8')
.split('\n')
.map(x => x.trim())
.filter(Boolean)
.map(line => {
try { return JSON.parse(line); } catch { return null; }
})
.filter(Boolean);
}
async function fetchYahooChart(symbol, range = '6mo') {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/encodeURIComponent(symbol)?interval=1d&range=range`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const data = await res.json();
const result = data?.chart?.result?.[0];
const ts = result?.timestamp || [];
const close = result?.indicators?.quote?.[0]?.close || [];
const out = [];
for (let i = 0; i < ts.length; i++) {
if (close[i] == null) continue;
const date = new Date(ts[i] * 1000).toISOString().slice(0, 10);
out.push({ date, close: Number(close[i]) });
}
return out;
}
async function fetchWestmetallHistory(field) {
const url = `https://www.westmetall.com/en/markdaten.php?action=table&field=encodeURIComponent(field)`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'text/html' },
signal: AbortSignal.timeout(20000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const html = await res.text();
const rows = [...html.matchAll(/<tr[^>]*>\s*<td[^>]*>([^<]+)<\/td>\s*<td[^>]*>([^<]+)<\/td>\s*<td[^>]*>([^<]+)<\/td>/g)];
return rows.map(m => {
const date = parseWestmetallDate(m[1].trim());
const lmeInv = parseNum(m[2]);
const usd = parseNum(m[3]);
if (!date || usd == null) return null;
return { date, usd, lmeInv };
}).filter(Boolean);
}
function indexFxByDate(rows) {
const m = new Map();
for (const r of rows) m.set(r.date, r.close);
return m;
}
function nearestFx(date, fxMap) {
if (fxMap.has(date)) return fxMap.get(date);
const keys = [...fxMap.keys()].sort();
let prev = null;
for (const k of keys) {
if (k > date) break;
prev = k;
}
return prev ? fxMap.get(prev) : null;
}
function parseLocalMarkdownHistory() {
if (!existsSync(MEMORY_DIR)) return [];
const mdFiles = readdirSync(MEMORY_DIR).filter(n => /^\d{4}-\d{2}-\d{2}\.md$/.test(n));
const out = [];
for (const f of mdFiles) {
const date = f.replace('.md', '');
const text = readFileSync(join(MEMORY_DIR, f), 'utf-8');
const lines = text.split(/\r?\n/);
for (const line of lines) {
// 解析诸如 Cu=311600t 或 Co 415000 这类简易线索,保守处理
const pairs = [
{ code: 'Cu', re: /(?:Cu|铜)[^\d]{0,8}(\d{4,7})/i },
{ code: 'Zn', re: /(?:Zn|锌)[^\d]{0,8}(\d{4,7})/i },
{ code: 'Ni', re: /(?:Ni|镍)[^\d]{0,8}(\d{4,7})/i },
{ code: 'Co', re: /(?:Co|钴)[^\d]{0,8}(\d{4,7})/i },
{ code: 'Bi', re: /(?:Bi|铋)[^\d]{0,8}(\d{4,7})/i },
{ code: 'Mg', re: /(?:Mg|镁)[^\d]{0,8}(\d{4,6})/i },
];
for (const p of pairs) {
const m = line.match(p.re);
if (!m) continue;
const cny = Number(m[1]);
if (!Number.isFinite(cny)) continue;
out.push({ date, metal: p.code, cny, source: 'local-memory-md' });
}
}
}
return out;
}
async function fetchCurrentSnapshot() {
try {
const scriptPath = join(__dirname, 'fetch-all-data.mjs');
const cp = await import('child_process');
const { promisify } = await import('util');
const execFileAsync = promisify(cp.execFile);
const { stdout } = await execFileAsync(process.execPath, [scriptPath], { timeout: 90000, maxBuffer: 8 * 1024 * 1024 });
const d = JSON.parse(stdout);
const rows = [];
for (const m of METALS) {
const item = d?.prices?.[m.key] || {};
if (item.cny == null && item.usd == null) continue;
rows.push({
date: d.date,
metal: m.code,
cny: item.cny ?? null,
usd: item.usd ?? null,
lmeInv: null,
cnyChange: item.cnyChange ?? null,
alertLevel: 0,
trendTag: '震荡',
keyEvidence: '回填快照:fetch-all-data',
source: 'fetch-all-data',
});
}
return rows;
} catch {
return [];
}
}
function dedupeAndSort(records) {
const map = new Map();
for (const r of records) {
if (!r?.date || !r?.metal || !METAL_CODE_SET.has(r.metal)) continue;
const k = `r.date|r.metal`;
const prev = map.get(k);
if (!prev) map.set(k, r);
else {
const prevScore = (prev.cny != null ? 2 : 0) + (prev.usd != null ? 1 : 0);
const currScore = (r.cny != null ? 2 : 0) + (r.usd != null ? 1 : 0);
if (currScore >= prevScore) map.set(k, { ...prev, ...r });
}
}
return [...map.values()].sort((a, b) => a.date === b.date ? a.metal.localeCompare(b.metal) : a.date.localeCompare(b.date));
}
async function main() {
const existing = loadJsonl(SIGNAL_HISTORY_PATH).filter(r => METAL_CODE_SET.has(r.metal));
const localMd = parseLocalMarkdownHistory();
let fxMap = new Map();
try {
const fxRows = await fetchYahooChart('USDCNY=X', '6mo');
fxMap = indexFxByDate(fxRows);
} catch {}
const fetched = [];
// Source A: Yahoo chart
for (const m of METALS.filter(x => x.yahoo)) {
try {
const rows = await fetchYahooChart(m.yahoo, '6mo');
for (const r of rows) {
const fx = nearestFx(r.date, fxMap);
fetched.push({
date: r.date,
metal: m.code,
usd: r.close,
cny: usdToCnyPerTon(r.close, m.unit, fx),
lmeInv: null,
cnyChange: null,
alertLevel: 0,
trendTag: '震荡',
keyEvidence: `backfill: Yahoo m.yahoo`,
source: `yahoo:m.yahoo`,
});
}
} catch {}
}
// Source A/B supplement: Westmetall history (Cu/Zn/Ni)
for (const m of METALS.filter(x => x.westmetallField)) {
try {
const rows = await fetchWestmetallHistory(m.westmetallField);
for (const r of rows) {
const fx = nearestFx(r.date, fxMap);
fetched.push({
date: r.date,
metal: m.code,
usd: r.usd,
cny: usdToCnyPerTon(r.usd, m.westmetallUnit || m.unit, fx),
lmeInv: r.lmeInv ?? null,
cnyChange: null,
alertLevel: 0,
trendTag: '震荡',
keyEvidence: `backfill: Westmetall m.westmetallField`,
source: `westmetall:m.westmetallField`,
});
}
} catch {}
}
// Source B: local structured history + markdown notes
const currentSnapshot = await fetchCurrentSnapshot();
const merged = dedupeAndSort([...existing, ...localMd, ...fetched, ...currentSnapshot]);
const beforeKeys = new Set(existing.map(r => `r.date|r.metal`));
const added = merged.filter(r => !beforeKeys.has(`r.date|r.metal`));
writeFileSync(SIGNAL_HISTORY_PATH, merged.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf-8');
const stats = {};
for (const m of METALS) {
const allCount = merged.filter(r => r.metal === m.code).length;
const addCount = added.filter(r => r.metal === m.code).length;
stats[m.code] = { total: allCount, added: addCount };
}
console.log(JSON.stringify({
ok: true,
output: SIGNAL_HISTORY_PATH,
added: added.length,
total: merged.length,
stats,
}, null, 2));
}
main().catch(err => {
console.error(err);
process.exit(1);
});
FILE:scripts/daily-report.mjs
import { readFileSync, existsSync, mkdirSync, writeFileSync, appendFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = join(__dirname, '..');
const REPORT_CACHE_PATH = join(PROJECT_ROOT, 'memory', 'daily-report-state.json');
const SIGNAL_HISTORY_PATH = join(PROJECT_ROOT, 'memory', 'signal-history.jsonl');
const execFileAsync = promisify(execFile);
function loadEnv() {
const envPath = join(PROJECT_ROOT, '.env');
const env = {};
try {
const content = readFileSync(envPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
env[key] = value;
}
} catch {}
return env;
}
async function runScript(scriptName) {
const scriptPath = join(__dirname, scriptName);
const { stdout, stderr } = await execFileAsync(process.execPath, [scriptPath], {
timeout: 70000,
maxBuffer: 8 * 1024 * 1024,
});
if (stderr) process.stderr.write(stderr);
return JSON.parse(stdout);
}
function fmtNum(n, decimals = 0) {
if (n == null || Number.isNaN(Number(n))) return '—';
const parts = Number(n).toFixed(decimals).split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return decimals > 0 ? parts.join('.') : parts[0];
}
function fmtPct(n, decimals = 2) {
if (n == null || Number.isNaN(Number(n))) return '—';
return `''Number(n).toFixed(decimals)%`;
}
function usdToCnyPerTon(usd, unit, fxRate) {
if (usd == null || fxRate == null) return null;
const usdPerTon = unit === 'USD/lb' ? usd * 2204.62 : usd;
return usdPerTon * fxRate;
}
function loadSignalHistory() {
if (!existsSync(SIGNAL_HISTORY_PATH)) return [];
const raw = readFileSync(SIGNAL_HISTORY_PATH, 'utf-8').split('\n').filter(Boolean);
const rows = [];
for (const l of raw) {
try { rows.push(JSON.parse(l)); } catch {}
}
return rows;
}
function appendSignalHistory(records) {
const dir = join(PROJECT_ROOT, 'memory');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const existing = loadSignalHistory();
const existingKey = new Set(existing.map(r => `r.date|r.metal`));
const toAppend = records.filter(r => !existingKey.has(`r.date|r.metal`));
if (!toAppend.length) return 0;
appendFileSync(SIGNAL_HISTORY_PATH, toAppend.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf-8');
return toAppend.length;
}
function recentSeries(history, metal, n = 20) {
return history.filter(r => r.metal === metal && r.cny != null).slice(-n);
}
function toArrayMaybe(v) {
return Array.isArray(v) ? v : [];
}
function normalizeSymbol(symbol) {
return String(symbol || '').toUpperCase().replace(/\s+/g, '');
}
function pickIndustryRaw(data = {}) {
return [
...toArrayMaybe(data.industryIndices),
...toArrayMaybe(data.indices),
...toArrayMaybe(data.metalIndices),
...toArrayMaybe(data.industryIndex),
];
}
function ensureIndustryIndices(data = {}) {
const industryRaw = pickIndustryRaw(data);
const targets = [
{ symbol: 'XME', name: 'SPDR S&P Metals & Mining ETF', cnName: '标普金属与采矿ETF', aliases: ['XME'] },
{ symbol: 'COPX', name: 'Global X Copper Miners ETF', cnName: '全球X铜矿ETF', aliases: ['COPX'] },
{ symbol: '000812.SS', name: '上证有色金属指数', cnName: '上证有色金属指数', aliases: ['000812.SS', '000812.SH', '000812', 'SH000812'] },
];
return targets.map(t => {
const hit = industryRaw.find(x => {
const s = normalizeSymbol(x?.symbol ?? x?.code ?? x?.ticker ?? x?.name);
return t.aliases.some(a => normalizeSymbol(a) === s);
});
const price = hit?.price ?? hit?.last ?? hit?.value ?? hit?.close ?? null;
const changePct = hit?.changePct ?? hit?.pct ?? hit?.change_percent ?? hit?.percent ?? null;
const wowPct = hit?.wowPct ?? hit?.weekOverWeekPct ?? hit?.weeklyChangePct ?? null;
if (!hit || price == null) {
return {
symbol: t.symbol,
name: t.name,
cnName: t.cnName,
missing: true,
reason: `缺失:t.symbol 暂无可用行情;替代:以六品种趋势一致性与预警共振做判断。`,
};
}
return {
symbol: t.symbol,
name: t.name,
cnName: t.cnName,
missing: false,
price,
changePct,
wowPct,
};
});
}
function keywordEvidence(text, keywords) {
const t = (text || '').toLowerCase();
const hits = keywords.filter(k => t.includes(k.toLowerCase()));
return { count: hits.length, hits: hits.slice(0, 4) };
}
function hardAlertLevel({ cny, cnyChange, invChange, sentimentCount, hasInventory }) {
const prev = (cny != null && cnyChange != null) ? (cny - cnyChange) : null;
const volPct = (prev && prev !== 0) ? Math.abs((cnyChange / prev) * 100) : 0;
const volScore = volPct >= 2.0 ? 2 : volPct >= 1.0 ? 1 : 0;
const invScore = hasInventory
? (Math.abs(invChange ?? 0) >= 8000 ? 2 : Math.abs(invChange ?? 0) >= 3000 ? 1 : 0)
: 0;
const sentScore = sentimentCount >= 2 ? 1 : 0;
return Math.max(0, Math.min(3, volScore + (invScore > 0 ? 1 : 0) + sentScore));
}
function alertLevelLabel(level) {
if (level >= 3) return '高';
if (level >= 2) return '中';
return '低';
}
function calcSlopePct(series, window = 3) {
if (!series?.length || series.length < window) return null;
const s = series.slice(-window);
const first = s[0]?.cny;
const last = s[s.length - 1]?.cny;
if (first == null || last == null || first === 0) return null;
return ((last - first) / first) * 100;
}
function spreadStatus(cny, extCny) {
if (cny == null || extCny == null) return { label: '缺失', basis: '内外盘价差缺失' };
const diff = cny - extCny;
const pct = extCny !== 0 ? (diff / extCny) * 100 : 0;
if (pct >= 0.8) return { label: '内强', basis: `内外价差fmtNum(diff, 0)/吨(fmtPct(pct, 2))` };
if (pct <= -0.8) return { label: '外强', basis: `内外价差fmtNum(diff, 0)/吨(fmtPct(pct, 2))` };
return { label: '均衡', basis: `内外价差fmtNum(diff, 0)/吨(fmtPct(pct, 2))` };
}
function inventoryDirection(invChange, hasInventory) {
if (!hasInventory) return '未知';
if (invChange == null) return '缺失';
if (invChange > 0) return '累库';
if (invChange < 0) return '去库';
return '平稳';
}
function trendDirection(slope5) {
if (slope5 == null) return '震荡';
if (slope5 >= 0.8) return '上行';
if (slope5 <= -0.8) return '下行';
return '震荡';
}
function trendMomentum({ slope3, slope5, invDir, direction }) {
if (slope3 == null || slope5 == null) {
return { status: '不明', basis: '样本不足,无法比较近3日与近5日斜率' };
}
const sameSign = Math.sign(slope3) === Math.sign(slope5) || slope3 === 0 || slope5 === 0;
const faster = Math.abs(slope3) >= Math.abs(slope5) * 1.15;
const slower = Math.abs(slope3) <= Math.abs(slope5) * 0.75;
const invAligned = direction === '上行'
? invDir === '去库'
: direction === '下行'
? invDir === '累库'
: false;
if (!sameSign) {
return { status: '衰减', basis: `近3日斜率fmtPct(slope3)与近5日斜率fmtPct(slope5)方向分歧` };
}
if (faster && (invAligned || invDir === '未知' || invDir === '缺失')) {
return { status: '增强', basis: `近3日斜率fmtPct(slope3)高于近5日fmtPct(slope5)''` };
}
if (slower) {
return { status: '衰减', basis: `近3日斜率fmtPct(slope3)低于近5日fmtPct(slope5),趋势斜率放缓` };
}
return { status: '维持', basis: `近3日斜率fmtPct(slope3)与近5日fmtPct(slope5)同向,节奏稳定` };
}
function trendStage({ direction, momentum, slope3, slope5 }) {
if (slope3 != null && slope5 != null && Math.sign(slope3) !== Math.sign(slope5) && slope3 !== 0 && slope5 !== 0) {
return '转折观察';
}
if (direction === '震荡') return '转折观察';
if (momentum === '增强') return '启动期';
if (momentum === '维持') return '延续期';
if (momentum === '衰减') return '钝化期';
return '转折观察';
}
function trendConfidence({ direction, slope3, slope5, invDir, spread, sentimentCount }) {
const checks = [];
if (slope3 != null && slope5 != null) checks.push(Math.sign(slope3) === Math.sign(slope5));
if (direction === '上行') {
if (invDir !== '未知' && invDir !== '缺失') checks.push(invDir === '去库' || invDir === '平稳');
if (spread !== '缺失') checks.push(spread !== '外强');
} else if (direction === '下行') {
if (invDir !== '未知' && invDir !== '缺失') checks.push(invDir === '累库' || invDir === '平稳');
if (spread !== '缺失') checks.push(spread !== '内强');
} else {
checks.push(true);
}
checks.push(sentimentCount >= 2);
const agree = checks.filter(Boolean).length;
if (agree >= 4) return '高';
if (agree >= 2) return '中';
return '低';
}
function oneLineOverview(items) {
const up = items.filter(x => x.trendDirection === '上行').length;
const down = items.filter(x => x.trendDirection === '下行').length;
const highAlert = items.filter(x => x.alertLevel >= 2).map(x => x.code);
if (up > down) return `主线:有色处于偏多趋势,优先跟踪 '核心品种' 的回踩建仓机会。`;
if (down > up) return `主线:有色偏弱,维持防守仓位,优先处理 '高波动品种' 的回撤风险。`;
return `主线:板块震荡分化,仓位以均衡配置为主,盯住 '关键品种' 趋势确认信号。`;
}
function buildTradePoints(items) {
const up = items.filter(x => x.trendDirection === '上行').map(x => x.code);
const down = items.filter(x => x.trendDirection === '下行').map(x => x.code);
const highConf = items.filter(x => x.trendConfidence === '高').map(x => x.code);
const momentumDecay = items.filter(x => x.momentum.status === '衰减').map(x => x.code);
const trendLine = up.length > down.length
? `主趋势线(未来1-3周):偏上行,重点跟踪 up.slice(0, 4).join('/') 的延续与回踩确认。`
: down.length > up.length
? `主趋势线(未来1-3周):偏下行,重点防守 down.slice(0, 4).join('/') 的反弹失败风险。`
: '主趋势线(未来1-3周):震荡分化,等待趋势置信度升至“高”的品种成为主线。';
const config = up.length > down.length
? `配置建议:进攻/防守≈6:4,进攻端优先 '趋势置信度较高品种',防守端保留现金与低波动品种。`
: down.length > up.length
? '配置建议:进攻/防守≈3:7,防守端以现金和低波动品种为主,仅对高置信度反弹做小比例试仓。'
: '配置建议:进攻/防守≈5:5,采用均衡配置,等待趋势共振后再偏向单侧。';
const trigger = momentumDecay.length
? `触发器:若 momentumDecay.join('/') 持续两日“动量衰减”且斜率转负,减仓10%-20%;若转为“增强”并伴随库存配合,再恢复仓位。`
: '触发器:当任一主线品种由“维持”切换为“增强”并保持两日,分批加仓;若转为“衰减”,先降仓再观察。';
return { trendLine, config, trigger };
}
function buildRisks(items) {
const reversal = items.filter(x => x.trendStage === '转折观察' || x.momentum.status === '衰减').map(x => x.code);
return [
`趋势反转风险:'当前主线品种' 出现斜率背离或动量衰减时,按失效条件先减仓后验证。`,
'宏观/汇率风险:美元与风险偏好共振时,内外盘价差可能快速重估;USD/CNY波动放大阶段降低进攻仓位。',
'数据失真或时滞风险:库存与资讯存在发布时滞,若价格与数据连续两日背离,降低数据权重并以价格趋势优先。',
];
}
function buildStrategy(x) {
if (x.trendDirection === '上行') {
return '策略:建仓区=近5日均线下方0%-1.5%回踩区;加仓条件=动量“增强”且库存去库/平稳,减仓条件=动量转“衰减”,失效条件=收盘跌破近5日低点。';
}
if (x.trendDirection === '下行') {
return '策略:建仓区=仅保留防守底仓并等待企稳;加仓条件=由“衰减”转“维持”并连续两日,减仓条件=反弹至近5日高位受阻,失效条件=趋势方向转“上行”且置信度升至中高。';
}
return '策略:建仓区=区间下沿分批试仓;加仓条件=趋势方向明确并置信度升至“中”以上,减仓条件=区间中上沿动量衰减,失效条件=区间破位并延续两日。';
}
function buildReport(d, histRows) {
const fx = d.fxRates?.usdCny?.price ?? null;
const p = d.prices || {};
const inv = d.inventory || {};
const forum = d.forumSentiment || {};
const newsText = [
...(d.news || []).map(x => x.title || ''),
...(d.ibNews || []).map(x => x.title || ''),
forum.smmHighlights || '',
forum.redditSurging || '',
forum.redditSummary || '',
].join(' | ');
const defs = [
{ key: 'copper', code: 'Cu', name: '铜', unit: 'USD/lb', invKey: 'copper', kws: ['copper', '铜', 'comex', 'tc'] },
{ key: 'zinc', code: 'Zn', name: '锌', unit: 'USD/t', invKey: 'zinc', kws: ['zinc', '锌', 'galvanized', '镀锌'] },
{ key: 'nickel', code: 'Ni', name: '镍', unit: 'USD/t', invKey: 'nickel', kws: ['nickel', '镍', 'npi', '不锈钢'] },
{ key: 'cobalt', code: 'Co', name: '钴', unit: 'USD/t', invKey: null, kws: ['cobalt', '钴', 'battery', '新能源'] },
{ key: 'bismuth', code: 'Bi', name: '铋', unit: 'USD/t', invKey: null, kws: ['bismuth', '铋', '半导体', '医药'] },
{ key: 'magnesium', code: 'Mg', name: '镁', unit: 'USD/t', invKey: null, kws: ['magnesium', '镁', '轻量化', '煤'] },
];
const items = [];
const records = [];
for (const def of defs) {
const item = p[def.key] || {};
const invRow = def.invKey ? inv?.[def.invKey] : null;
const kw = keywordEvidence(newsText, def.kws);
const extCny = item.usd != null && fx != null ? usdToCnyPerTon(item.usd, def.unit, fx) : null;
const spread = spreadStatus(item.cny ?? null, extCny);
const invDir = inventoryDirection(invRow?.change ?? null, Boolean(def.invKey));
const hist = recentSeries(histRows, def.code, 20);
const series = [...hist];
if (item.cny != null) {
const lastDate = series[series.length - 1]?.date;
if (lastDate !== d.date) series.push({ date: d.date, cny: item.cny });
}
const slope3 = calcSlopePct(series, 3);
const slope5 = calcSlopePct(series, 5);
const direction = trendDirection(slope5);
const momentum = trendMomentum({ slope3, slope5, invDir, direction });
const stage = trendStage({ direction, momentum: momentum.status, slope3, slope5 });
const confidence = trendConfidence({
direction,
slope3,
slope5,
invDir,
spread: spread.label,
sentimentCount: kw.count,
});
const alertLevel = hardAlertLevel({
cny: item.cny ?? null,
cnyChange: item.cnyChange ?? 0,
invChange: invRow?.change ?? null,
sentimentCount: kw.count,
hasInventory: Boolean(def.invKey),
});
const inventoryText = def.invKey
? (invRow?.tonnes != null
? `fmtNum(invRow.tonnes)t(''fmtNum(invRow.change ?? 0)t,invDir)`
: '缺失与替代依据:库存缺失,改用近3日/5日斜率与价差判断')
: '缺失与替代依据:该品种无稳定库存口径,改用斜率+价差+情绪辅助';
items.push({
code: def.code,
name: def.name,
trendStage: stage,
trendDirection: direction,
trendConfidence: confidence,
observeCycle: '短中期(5-20交易日)',
slope3,
slope5,
momentum,
spread,
invDir,
inventoryText,
sentimentHits: kw.hits,
sentimentCount: kw.count,
cny: item.cny ?? null,
usd: item.usd ?? null,
cnyChange: item.cnyChange ?? null,
alertLevel,
});
records.push({
date: d.date,
metal: def.code,
cny: item.cny ?? null,
usd: item.usd ?? null,
lmeInv: invRow?.tonnes ?? null,
cnyChange: item.cnyChange ?? null,
alertLevel,
trendTag: direction,
trendStage: stage,
trendConfidence: confidence,
keyEvidence: `slope3=slope3 ?? 'NA' slope5=slope5 ?? 'NA' inv=invDir spread=spread.label sentiment=kw.count`,
});
}
const industry = ensureIndustryIndices(d);
const overview = oneLineOverview(items);
const points = buildTradePoints(items);
const risks = buildRisks(items);
const lines = [];
lines.push(`有色金属趋势研报 | d.date`);
lines.push('');
lines.push(`一句话总览:overview`);
lines.push('');
lines.push('1) 行业指数');
for (const idx of industry) {
const label = `idx.symbol(idx.cnName || idx.name || idx.symbol)`;
if (idx.missing) {
lines.push(`- label:idx.reason`);
} else {
const wowText = idx.wowPct == null
? '周环比缺失'
: `周环比 '') + fmtNum(idx.wowPct, 2)%`;
lines.push(`- label:fmtNum(idx.price, 3)(日变 ''fmtNum(idx.changePct ?? 0, 2)%|wowText)`);
}
}
lines.push('');
lines.push('2) 品种数据');
for (const x of items) {
const extText = (x.sentimentHits?.length ?? 0) > 0
? `x.sentimentCount(x.sentimentHits.join('、'))`
: `x.sentimentCount`;
lines.push(`*x.name(x.code)*`);
lines.push(`趋势阶段:x.trendStage|趋势方向:x.trendDirection|趋势置信度:x.trendConfidence|观察周期:x.observeCycle|预警:alertLevelLabel(x.alertLevel)`);
lines.push(`依据:近3日斜率fmtPct(x.slope3)|近5日斜率fmtPct(x.slope5)|库存x.inventoryText|内外盘价差x.spread.basis|情绪命中extText(辅助)`);
lines.push(`趋势动量状态:x.momentum.status(x.momentum.basis)`);
lines.push(buildStrategy(x));
lines.push('');
}
lines.push('3) 交易要点(技术面/信号摘要)');
lines.push(`- points.trendLine`);
lines.push(`- points.config`);
lines.push(`- points.trigger`);
lines.push('');
lines.push('4) 关键风险');
for (const r of risks) lines.push(`- r`);
lines.push('');
lines.push(`数据源与时间戳:Yahoo/CCMN/SMM/Westmetall/GoogleNews/Reddit;生成时间 new Date().toISOString()`);
return { message: lines.join('\n'), records };
}
async function sendTelegram(token, chatId, text) {
const url = `https://api.telegram.org/bottoken/sendMessage`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ chat_id: chatId, text }),
signal: AbortSignal.timeout(15000),
});
const data = await res.json();
if (!data.ok) throw new Error(`Telegram API error: JSON.stringify(data)`);
return data;
}
function saveReportState(state) {
try {
const dir = join(PROJECT_ROOT, 'memory');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(REPORT_CACHE_PATH, JSON.stringify(state, null, 2), 'utf-8');
} catch {}
}
async function main() {
const data = await runScript('fetch-all-data.mjs');
const history = loadSignalHistory();
const { message, records } = buildReport(data, history);
console.log('\n─── 报告预览 ───');
console.log(message);
console.log('────────────────\n');
const env = loadEnv();
const token = env.TELEGRAM_BOT_TOKEN;
const chatId = env.TELEGRAM_CHAT_ID;
const dryRun = process.env.DRY_RUN === '1' || process.env.DRY_RUN === 'true';
if (!dryRun) {
appendSignalHistory(records);
saveReportState({ generatedAt: new Date().toISOString(), date: data.date, recordsCount: records.length });
}
if (!token) {
process.stderr.write('[daily-report] 未配置 TELEGRAM_BOT_TOKEN,跳过发送\n');
return;
}
if (dryRun) {
process.stderr.write('[daily-report] DRY_RUN=1,仅预览,不发送\n');
return;
}
try {
await sendTelegram(token, chatId, message);
process.stderr.write('[daily-report] ✅ 发送成功\n');
} catch (err) {
process.stderr.write(`[daily-report] ❌ 发送失败:err.message\n`);
process.stderr.write('[daily-report] 以下为可复制全文:\n');
process.stderr.write(message + '\n');
throw err;
}
}
main().catch(err => {
console.error('Fatal error:', err.message);
process.exit(1);
});
FILE:scripts/fetch-all-data.mjs
/**
* fetch-all-data.mjs
* 收集所有有色金屬原始數據,輸出完整 JSON 到 stdout
* 目標:≤15 秒完成
*
* 輸出結構:
* { date, dataDate, isMarketOpen, marketNote, changeNote, bismuthNote, prices, forwards, inventory, news, ibNews, forumSentiment }
*
* v4 新增:
* - fetchSmmNews(): SMM上海有色網新聞(免費,HTTP可達)
* - fetchRedditCommodities(): Reddit r/Commodities 最新帖子
* - forumSentiment: { redditSummary, smmHighlights } 市場情緒字段
*/
// ────────────────────────────────────────────
// 工具函數
// ────────────────────────────────────────────
function today() {
return new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
}
// ────────────────────────────────────────────
// 1. CCMN 長江有色現貨(CNY)
// ────────────────────────────────────────────
async function fetchCcmnPrices() {
const url = 'https://m.ccmn.cn/mhangqing/getCorpStmarketPriceList?marketVmid=40288092327140f601327141c0560001';
try {
const res = await fetch(url, {
headers: {
'Referer': 'https://m.ccmn.cn/mhangqing/mcjxh/',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
signal: AbortSignal.timeout(12000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const data = await res.json();
if (!data.success) throw new Error(data.msg || 'API error');
const list = data.body?.priceList;
if (!Array.isArray(list)) throw new Error('No priceList');
const nameMap = {
'1#铜': 'copper',
'0#锌': 'zinc',
'1#镍': 'nickel',
'1#钴': 'cobalt',
'A00铝': 'aluminum', // v8 新增:嘗試從 CCMN 獲取鋁價
'1#铝': 'aluminum', // 備用牌號
'1#镁': 'magnesium', // v12 新增:鎂
};
// 升級一:提取 dataDate 與 isMarketOpen
const rawDate = list[0]?.publishDate ?? null;
// publishDate 可能是 "2026-03-13" 或 "2026/03/13",統一轉為 "YYYY-MM-DD"
const dataDate = rawDate ? rawDate.replace(/\//g, '-').slice(0, 10) : null;
const todaySH = today();
const isMarketOpen = dataDate ? (dataDate === todaySH) : null;
const result = { copper: null, zinc: null, nickel: null, cobalt: null, aluminum: null, magnesium: null, dataDate, isMarketOpen };
for (const item of list) {
const key = nameMap[item.productSortName];
if (key) {
const price = parseFloat(item.avgPrice);
const updown = parseFloat(item.highsLowsAmount);
result[key] = {
price: isNaN(price) ? null : price,
updown: isNaN(updown) ? null : updown,
};
}
}
return result;
} catch (err) {
process.stderr.write(`[fetch-all-data] CCMN 錯誤: err.message\n`);
return null;
}
}
// ────────────────────────────────────────────
// 1b. OmetalCN 長江現貨(GBK 頁面,備用源)
// URL: http://app.ometal.cn/data/mlist.asp
// 品種:Cu / Al / Pb / Zn / Ni / Sn + 升貼水
// 優勢:有 A00鋁(CCMN 常缺)、解析穩定、響應快
// ────────────────────────────────────────────
async function fetchOmetal() {
try {
const res = await fetch('http://app.ometal.cn/data/mlist.asp', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html',
'Referer': 'http://app.ometal.cn/',
},
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const buf = await res.arrayBuffer();
const text = new TextDecoder('gbk').decode(buf);
// 提取純文字(去除HTML標籤)
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
// 解析價格表格:格式為「品名 高-低 均價 ↑↓變動 更多」
function parseRow(name) {
// 匹配:品名後面跟數字區間、均價、漲跌
const re = new RegExp(
name.replace(/[#()]/g, c => '\\' + c) +
'\\s+([\\d,]+-[\\d,-]+)\\s+([\\d,]+)\\s+[↑↓]([\\-\\d,]+)'
);
const m = plain.match(re);
if (!m) return null;
const range = m[1].split('-');
const avg = parseFloat(m[2].replace(/,/g, ''));
const change = parseFloat(m[3].replace(/,/g, ''));
// range: 有時是「22850-22950」也有負數「-130--90」
let low = null, high = null;
if (range.length === 2) {
low = parseFloat(range[0].replace(/,/g, ''));
high = parseFloat(range[1].replace(/,/g, ''));
}
if (isNaN(avg)) return null;
return { price: avg, high: isNaN(high) ? null : high, low: isNaN(low) ? null : low, change: isNaN(change) ? null : change };
}
// 提取日期(格式:日期: 3/26)
const dateMatch = plain.match(/日期[::]\s*(\d+)\/(\d+)/);
let dataDate = null;
if (dateMatch) {
const year = new Date().getFullYear();
dataDate = `year-String(parseInt(dateMatch[1])).padStart(2,'0')-String(parseInt(dateMatch[2])).padStart(2,'0')`;
}
const result = {
copper: parseRow('1#铜'),
aluminum: parseRow('A00铝'),
lead: parseRow('1#铅'),
zinc: parseRow('0#锌'),
nickel: parseRow('1#镍板'),
tin: parseRow('1#锡'),
copperPremium: parseRow('铜升贴水'),
aluminumPremium: parseRow('铝升贴水'),
dataDate,
source: 'OmetalCN/app.ometal.cn',
};
process.stderr.write(
`[fetch-all-data] OmetalCN: Cu=¥result.copper?.price Al=¥result.aluminum?.price Zn=¥result.zinc?.price Ni=¥result.nickel?.price Sn=¥result.tin?.price\n`
);
return result;
} catch (err) {
process.stderr.write(`[fetch-all-data] OmetalCN 失敗: err.message\n`);
return null;
}
}
// ────────────────────────────────────────────
// 2. Yahoo Finance v8(USD 現貨 / 遠期合約)
// ────────────────────────────────────────────
async function fetchYahoo(symbol) {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/encodeURIComponent(symbol)?interval=1d&range=2d`;
try {
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json',
},
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const data = await res.json();
const result = data?.chart?.result?.[0];
if (!result) throw new Error('No result');
const meta = result.meta;
const price = meta.regularMarketPrice ?? null;
const prevClose = meta.chartPreviousClose ?? meta.previousClose ?? null;
let changePct = null;
if (price != null && prevClose != null) {
changePct = +((price - prevClose) / prevClose * 100).toFixed(2);
}
// 驗證數據是否過期(超過 30 天視為無效)
const tradingDate = new Date(meta.regularMarketTime * 1000).toISOString().slice(0, 10);
const daysDiff = (Date.now() - meta.regularMarketTime * 1000) / 86400000;
if (daysDiff > 30) {
return { symbol, ok: false, price: null, changePct: null, expiry: null, error: `Stale data: last traded tradingDate (Math.floor(daysDiff)d ago)` };
}
// 合約到期月份(從 symbol 推算)
let expiry = null;
if (symbol !== 'HG=F' && symbol !== 'ZNC=F' && symbol !== 'ALI=F') {
const m = symbol.match(/HG([FGHJKMNQUVXZ])(\d{2})\.CMX/);
if (m) {
const monthCodeMap = { F:1,G:2,H:3,J:4,K:5,M:6,N:7,Q:8,U:9,V:10,X:11,Z:12 };
const mNum = String(monthCodeMap[m[1]]).padStart(2, '0');
const yr = 2000 + parseInt(m[2]);
expiry = `yr-mNum`;
}
} else {
// 現貨合約用當前月份
const now = new Date();
expiry = `now.getFullYear()-String(now.getMonth()+1).padStart(2,'0')`;
}
return { symbol, ok: true, price, changePct, expiry };
} catch (err) {
process.stderr.write(`[fetch-all-data] Yahoo symbol 錯誤: err.message\n`);
return { symbol, ok: false, price: null, changePct: null, expiry: null };
}
}
// 周环比(较约5个交易日前收盘)
async function fetchYahooWoW(symbol) {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/encodeURIComponent(symbol)?interval=1d&range=1mo`;
try {
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json',
},
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const data = await res.json();
const result = data?.chart?.result?.[0];
const closes = result?.indicators?.quote?.[0]?.close?.filter(v => v != null) || [];
if (closes.length < 6) return null;
const last = closes[closes.length - 1];
const prevWeek = closes[closes.length - 6]; // 约5个交易日前
if (last == null || prevWeek == null || prevWeek === 0) return null;
return +(((last - prevWeek) / prevWeek) * 100).toFixed(2);
} catch (err) {
process.stderr.write(`[fetch-all-data] Yahoo WoW symbol 錯誤: err.message\n`);
return null;
}
}
// USD/CNY 匯率(Yahoo Finance + 備援)
async function fetchUsdcny() {
// A) Yahoo(主來源)
const fx = await fetchYahoo('USDCNY=X');
if (fx.ok && fx.price != null) {
return { price: fx.price, changePct: fx.changePct, source: 'Yahoo/USDCNY=X' };
}
// B) exchangerate.host(免費備援)
try {
const res = await fetch('https://api.exchangerate.host/convert?from=USD&to=CNY&amount=1', {
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json' },
signal: AbortSignal.timeout(8000),
});
if (res.ok) {
const data = await res.json();
const p = Number(data?.result ?? data?.info?.rate);
if (Number.isFinite(p) && p > 0) {
return { price: +p.toFixed(4), changePct: null, source: 'exchangerate.host' };
}
}
} catch (err) {
process.stderr.write(`[fetch-all-data] FX 備援 exchangerate.host 錯誤: err.message\n`);
}
// C) frankfurter.app(免費備援)
try {
const res = await fetch('https://api.frankfurter.app/latest?from=USD&to=CNY', {
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json' },
signal: AbortSignal.timeout(8000),
});
if (res.ok) {
const data = await res.json();
const p = Number(data?.rates?.CNY);
if (Number.isFinite(p) && p > 0) {
return { price: +p.toFixed(4), changePct: null, source: 'frankfurter.app' };
}
}
} catch (err) {
process.stderr.write(`[fetch-all-data] FX 備援 frankfurter.app 錯誤: err.message\n`);
}
return null;
}
// ────────────────────────────────────────────
// v7 新增:有色金屬行業指數(Yahoo Finance)
// 測試結果(2026-03-15):
// ^LMEX ⚠️ 過舊(2019)| JJM ⚠️ 過舊(2023)
// XME ✅ | COPX ✅ | PICK ✅ | 000812.SS ✅
// 512400 ❌404 | 159163 ❌404
// 選定:XME(廣泛礦業) + COPX(銅礦股) + 000812.SS(申萬A股)
// ────────────────────────────────────────────
async function fetchMetalIndices() {
const symbols = [
{ symbol: 'XME', name: 'SPDR S&P Metals & Mining ETF', market: 'US', currency: 'USD' },
{ symbol: 'COPX', name: 'Global X Copper Miners ETF', market: 'US', currency: 'USD' },
{ symbol: '000812.SS', name: '申萬有色金屬指數', market: 'CN', currency: 'CNY' },
];
const results = await Promise.all(symbols.map(async ({ symbol, name, market, currency }) => {
const [data, wowPct] = await Promise.all([
fetchYahoo(symbol),
fetchYahooWoW(symbol),
]);
if (!data.ok || data.price === null) return null;
const changeAbs = (data.price != null && data.changePct != null)
? +(data.price * data.changePct / (100 + data.changePct)).toFixed(3)
: null;
return {
symbol,
name,
market,
currency,
price: data.price,
changePct: data.changePct,
changeAbs,
wowPct,
};
}));
return results.filter(Boolean);
}
// 宏觀風險指標:DXY / VIX / CRB / 美債10Y
async function fetchMacroIndicators() {
const symbols = [
{ symbol: '^DXY', name: '美元指數', unit: 'pts' },
{ symbol: '^VIX', name: 'VIX恐慌指數', unit: 'pts' },
{ symbol: 'CRY', name: 'CRB商品指數', unit: 'pts' },
{ symbol: '^TNX', name: '美債10Y收益率', unit: '%' },
];
const results = await Promise.all(symbols.map(async (cfg) => {
let data = await fetchYahoo(cfg.symbol);
if ((!data.ok || data.price == null) && cfg.symbol === 'CRY') {
// CRB 指數備用 symbol:TRJEFFCR
data = await fetchYahoo('TRJEFFCR');
cfg = { ...cfg, symbol: 'TRJEFFCR' };
}
if ((!data.ok || data.price == null) && cfg.symbol === '^DXY') {
// DXY 備用 symbol:DX-Y.NYB
data = await fetchYahoo('DX-Y.NYB');
cfg = { ...cfg, symbol: 'DX-Y.NYB' };
}
if (!data.ok || data.price == null) return null;
// ^TNX 報價通常為收益率×10(若>20則縮放),否則直接使用
const rawPrice = data.price;
const price = cfg.symbol === '^TNX' && rawPrice > 20 ? +(rawPrice / 10).toFixed(3) : rawPrice;
return {
...cfg,
price,
changePct: data.changePct,
source: 'Yahoo',
};
}));
return results.filter(Boolean);
}
// ────────────────────────────────────────────
// 3. 鉍(Bi)— SMM 上海有色網 h5 頁面(免費,__NEXT_DATA__ 嵌入)
// ────────────────────────────────────────────
// URL: https://hq.smm.cn/h5/bismuth-price
// 數據:精鉍價格(CNY/t) + 精鉍CIF(USD/kg) + 4N/5N三氧化二鉍(CNY/t)
// 欄位:high / low / average / vchange(日變動絕對值) / vchange_rate(%) / renew_date
// 無需登錄,__NEXT_DATA__ 直接嵌入完整 JSON
// ────────────────────────────────────────────
async function fetchSmmBismuth() {
try {
const res = await fetch('https://hq.smm.cn/h5/bismuth-price', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Referer': 'https://www.smm.cn/',
},
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const html = await res.text();
// 解析 __NEXT_DATA__
const nd = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!nd) throw new Error('__NEXT_DATA__ not found');
const json = JSON.parse(nd[1]);
const sections = json?.props?.pageProps?.datas?.BIP01?.data;
if (!Array.isArray(sections)) throw new Error('BIP01 data not found');
const result = { cny: null, usd: null, source: 'SMM/hq.smm.cn' };
for (const section of sections) {
for (const item of (section.data || [])) {
const name = item.product_name || '';
// 铋 = U+94CB
// 精铋价格 (CNY/t):name 含「铋」且不含 CIF,unit 含「元」
// ⚠️ SMM vchange_rate 為小數格式(如 -0.0033 = -0.33%),需 ×100 轉為百分比
if (name.includes('\u94cb') && !name.includes('CIF') && item.unit && item.unit.includes('\u5143') && !name.includes('N\u4e09')) {
result.cny = {
average: item.average,
high: item.high,
low: item.low,
change: item.vchange,
changePct: item.vchange_rate != null ? +(item.vchange_rate * 100).toFixed(4) : null,
unit: item.unit,
dataDate: item.renew_date,
};
} else if (name.includes('\u94cb') && name.includes('CIF')) {
// 精铋CIF价格 (USD/kg → 換算 USD/t)
const avgUsdPerKg = item.average;
result.usd = {
averagePerKg: avgUsdPerKg,
average: avgUsdPerKg != null ? +(avgUsdPerKg * 1000).toFixed(0) : null, // USD/t
high: item.high != null ? item.high * 1000 : null,
low: item.low != null ? item.low * 1000 : null,
change: item.vchange != null ? +(item.vchange * 1000).toFixed(0) : null,
changePct: item.vchange_rate != null ? +(item.vchange_rate * 100).toFixed(4) : null,
unit: 'USD/t',
dataDate: item.renew_date,
};
}
}
}
if (!result.cny && !result.usd) throw new Error('No bismuth prices found in page');
const cnyAvg = result.cny?.average;
const usdAvg = result.usd?.average;
process.stderr.write(`[fetch-all-data] SMM 鉍價格:¥cnyAvg/t(日變動 result.cny?.change)/ $usdAvg/t CIF\n`);
return result;
} catch (err) {
process.stderr.write(`[fetch-all-data] SMM 鉍抓取失敗: err.message\n`);
return null;
}
}
// ────────────────────────────────────────────
// 3b. SMM 長江現貨交叉驗證(Cu / Zn / Ni)
// ────────────────────────────────────────────
// 數據源調研結論(2026-03):
// ✅ cu-price: 長江現貨銅價 / ✅ zn-price: 長江現貨鋅錠 / ✅ ni-price: 長江鎳價
// ❌ al-price: 404(鋁無 h5 頁面)/ ❌ cobalt-price/co-price: 404(鈷無 h5 頁面)
// ❌ 所有金屬均無 LME USD 報價(Zn/Ni/Co USD 無免費數據源)
// ⚠️ vchange_rate 為小數格式,×100 轉百分比
async function fetchSmmCrossCheck() {
const SMM_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Referer': 'https://www.smm.cn/',
};
// 遞歸提取所有含 product_name 的條目
function walkItems(obj) {
const items = [];
function walk(o) {
if (!o || typeof o !== 'object') return;
if (o.product_name !== undefined && o.average != null) {
items.push(o); return;
}
if (Array.isArray(o)) { o.forEach(walk); return; }
Object.values(o).forEach(walk);
}
walk(obj);
return items;
}
async function fetchSmm(slug, targetName, attempt = 1) {
try {
const res = await fetch(`https://hq.smm.cn/h5/slug`, {
headers: SMM_HEADERS, signal: AbortSignal.timeout(12000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const html = await res.text();
const nd = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!nd) throw new Error('no __NEXT_DATA__');
const json = JSON.parse(nd[1]);
const items = walkItems(json?.props?.pageProps?.datas);
const match = items.find(i => i.product_name && i.product_name.includes(targetName));
if (!match) throw new Error(`"targetName" not found in items.length items`);
return {
average: match.average,
high: match.high,
low: match.low,
change: match.vchange,
changePct: match.vchange_rate != null ? +(match.vchange_rate * 100).toFixed(4) : null,
unit: match.unit,
dataDate: match.renew_date,
productName: match.product_name,
source: `SMM/slug`,
};
} catch(err) {
if (attempt === 1) {
process.stderr.write(`[fetch-all-data] SMM cross-check slug 第一次失敗 (err.message),1.5秒後重試...\n`);
await new Promise(r => setTimeout(r, 1500));
return fetchSmm(slug, targetName, 2);
}
process.stderr.write(`[fetch-all-data] SMM cross-check slug 最終失敗: err.message\n`);
return null;
}
}
const [cu, zn, ni] = await Promise.all([
fetchSmm('cu-price', '\u957f\u6c5f\u73b0\u8d27\u94dc\u4ef7'), // 长江现货铜价 铜=94DC
fetchSmm('zn-price', '\u4e0a\u6d77\u73b0\u8d27\u950c\u952d\u4ef7\u683c0#'), // 上海现货锌锭价格0# 锌=950C 锭=952D
fetchSmm('ni-price', '\u957f\u6c5f\u954d\u4ef7\u683c'), // 长江镍价格 镍=954D
]);
process.stderr.write(`[fetch-all-data] SMM交叉驗證: Cu=cu?.average Zn=zn?.average Ni=ni?.average\n`);
return { copper: cu, zinc: zn, nickel: ni };
}
// ────────────────────────────────────────────
// 3c. 通用 fetchSmmMetal(v8 新增)
// 適用於任何有 __NEXT_DATA__ 的 SMM h5 頁面
// ────────────────────────────────────────────
async function fetchSmmMetal(slug, targetName) {
const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Referer': 'https://www.smm.cn/',
};
try {
const res = await fetch(`https://hq.smm.cn/h5/slug`, {
headers, signal: AbortSignal.timeout(12000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const html = await res.text();
const nd = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!nd) throw new Error('no __NEXT_DATA__');
const json = JSON.parse(nd[1]);
// 展開所有 data 條目
const items = [];
function walk(o) {
if (!o || typeof o !== 'object') return;
if (o.product_name !== undefined && o.average != null) { items.push(o); return; }
if (Array.isArray(o)) { o.forEach(walk); return; }
Object.values(o).forEach(walk);
}
walk(json?.props?.pageProps?.datas);
const match = items.find(i => i.product_name && i.product_name.includes(targetName));
if (!match) throw new Error(`"targetName" not found in items.length items`);
const changePct = match.vchange_rate != null ? +(match.vchange_rate * 100).toFixed(3) : null;
return {
average: match.average,
high: match.high,
low: match.low,
change: match.vchange,
changePct,
unit: match.unit,
dataDate: match.renew_date,
productName: match.product_name,
source: `SMM/hq.smm.cn/slug`,
};
} catch (err) {
process.stderr.write(`[fetch-all-data] fetchSmmMetal(slug) 失敗: err.message\n`);
return null;
}
}
// 交叉驗證說明生成器
function buildCrossCheckNote(ccmnPrice, smmPrice, smmLabel) {
if (!ccmnPrice || !smmPrice) return null;
const diffPct = +((smmPrice - ccmnPrice) / ccmnPrice * 100).toFixed(2);
const sign = diffPct >= 0 ? '+' : '';
const absDiff = Math.abs(diffPct);
if (absDiff < 0.5) return `雙源一致:CCMN ¥ccmnPrice vs smmLabel ¥smmPrice (signdiffPct%)`;
if (absDiff > 1) return `差異>1%:CCMN ¥ccmnPrice vs smmLabel ¥smmPrice (signdiffPct%)`;
return `差異<1%:CCMN ¥ccmnPrice vs smmLabel ¥smmPrice (signdiffPct%)`;
}
// ────────────────────────────────────────────
// 4a. Westmetall.com — LME 庫存 + USD 現貨價格
// ────────────────────────────────────────────
// URL: https://www.westmetall.com/en/markdaten.php?action=table&field=LME_XX_stock
// 返回: { tonnes, change, cashUsd, threeMonthUsd, dataDate }
// 覆蓋品種: Cu / Zn / Ni(Westmetall 不提供 Co 數據)
// ────────────────────────────────────────────
const WESTMETALL_MONTHS = {
January:1, February:2, March:3, April:4, May:5, June:6,
July:7, August:8, September:9, October:10, November:11, December:12
};
function parseWestmetallDate(str) {
const m = str.match(/(\d{1,2})\.\s+(\w+)\s+(\d{4})/);
if (!m) return null;
const d = String(parseInt(m[1])).padStart(2, '0');
const mon = String(WESTMETALL_MONTHS[m[2]] || 0).padStart(2, '0');
return `m[3]-mon-d`;
}
async function fetchWestmetallMetal(fieldName, attempt = 1) {
// fieldName: 'LME_Cu_stock' | 'LME_Zn_stock' | 'LME_Ni_stock'
const url = `https://www.westmetall.com/en/markdaten.php?action=table&field=fieldName`;
try {
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html',
'Referer': 'https://www.westmetall.com/en/markdaten.php',
},
signal: AbortSignal.timeout(15000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const html = await res.text();
// 取 <tbody> 第一個 <tr>(最新一行)
const tbodyMatch = html.match(/<tbody>([\s\S]*?)<\/tbody>/);
if (!tbodyMatch) throw new Error('No <tbody> found');
const tbody = tbodyMatch[1];
const trMatch = tbody.match(/<tr>([\s\S]*?)<\/tr>/);
if (!trMatch) throw new Error('No <tr> in tbody');
const tr = trMatch[1];
// 提取所有 <td> 文本
const tdValues = [...tr.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)]
.map(m => m[1].replace(/<[^>]+>/g, '').trim());
if (tdValues.length < 4) throw new Error(`Expected ≥4 td, got tdValues.length`);
const [dateStr, stockStr, cashStr, threeMonthStr] = tdValues;
const dataDate = parseWestmetallDate(dateStr);
// 解析下一行的庫存變化
const rows = [...tbody.matchAll(/<tr>([\s\S]*?)<\/tr>/g)];
let change = null;
if (rows.length >= 2) {
const prevTd = [...rows[1][1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)]
.map(m => m[1].replace(/<[^>]+>/g, '').trim());
if (prevTd.length >= 2) {
const curr = parseFloat(stockStr.replace(/,/g, ''));
const prev = parseFloat(prevTd[1].replace(/,/g, ''));
if (!isNaN(curr) && !isNaN(prev)) change = curr - prev;
}
}
return {
tonnes: parseFloat(stockStr.replace(/,/g, '')) || null,
change,
cashUsd: parseFloat(cashStr.replace(/,/g, '')) || null,
threeMonthUsd: parseFloat(threeMonthStr.replace(/,/g, '')) || null,
dataDate,
source: `Westmetall/fieldName`,
};
} catch (err) {
if (attempt === 1) {
process.stderr.write(`[fetch-all-data] Westmetall fieldName 第一次失敗 (err.message),2秒後重試...\n`);
await new Promise(r => setTimeout(r, 2000));
return fetchWestmetallMetal(fieldName, 2);
}
process.stderr.write(`[fetch-all-data] Westmetall fieldName 最終失敗: err.message\n`);
return null;
}
}
async function fetchWestmetallAll() {
const [cu, zn, ni] = await Promise.all([
fetchWestmetallMetal('LME_Cu_stock'),
fetchWestmetallMetal('LME_Zn_stock'),
fetchWestmetallMetal('LME_Ni_stock'),
]);
process.stderr.write(`[fetch-all-data] Westmetall: Cu=cu?.cashUsd Zn=zn?.cashUsd Ni=ni?.cashUsd | stocks: Cu=cu?.tonnes Zn=zn?.tonnes Ni=ni?.tonnes\n`);
return { copper: cu, zinc: zn, nickel: ni };
}
// ────────────────────────────────────────────
// 4. LME 庫存(主函數 — 優先用 Westmetall)
// ────────────────────────────────────────────
async function fetchLmeInventory() {
const errors = [];
// 方案A:Westmetall.com(Cu/Zn/Ni LME 庫存,可靠免費源)
// 同時緩存 cashUsd 供 main() 直接使用,避免重複請求
try {
const wm = await fetchWestmetallAll();
const result = { copper: null, zinc: null, nickel: null, cobalt: null, note: null, _wmPrices: wm };
for (const [metal, data] of [['copper', wm.copper], ['zinc', wm.zinc], ['nickel', wm.nickel]]) {
if (data && data.tonnes != null) {
result[metal] = {
tonnes: data.tonnes,
change: data.change,
cashUsd: data.cashUsd,
source: data.source,
unit: 'tonnes',
dataDate: data.dataDate,
};
}
}
const hasData = Object.entries(result).some(([k, v]) => k !== 'note' && k !== 'cobalt' && k !== '_wmPrices' && v != null);
if (!hasData) throw new Error('Westmetall 返回空數據');
process.stderr.write('[fetch-all-data] LME 方案A (Westmetall) 成功\n');
return result;
} catch (err) {
errors.push(`方案A: err.message`);
process.stderr.write(`[fetch-all-data] LME 方案A (Westmetall) 失敗: err.message\n`);
}
// 方案B:LME 倉庫統計頁面 HTML
try {
const res = await fetch(
'https://www.lme.com/Market-Data/Reports-and-data/Warehouse-Stock-Statistics',
{
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml',
},
signal: AbortSignal.timeout(5000),
}
);
if (!res.ok) throw new Error(`HTTP res.status`);
const html = await res.text();
if (html.includes('Just a moment') || html.includes('Cloudflare')) throw new Error('Cloudflare 攔截');
const result = { copper: null, zinc: null, nickel: null, cobalt: null, note: null };
const metalPatterns = [
{ key: 'copper', regex: /[Cc]opper[^0-9]*?([\d,]+)\s*tonnes?/i },
{ key: 'nickel', regex: /[Nn]ickel[^0-9]*?([\d,]+)\s*tonnes?/i },
{ key: 'zinc', regex: /[Zz]inc[^0-9]*?([\d,]+)\s*tonnes?/i },
];
for (const { key, regex } of metalPatterns) {
const m = html.match(regex);
if (m) {
const tonnes = parseInt(m[1].replace(/,/g, ''), 10);
if (!isNaN(tonnes)) {
result[key] = { tonnes, change: null, source: 'LME', unit: 'tonnes' };
}
}
}
const hasData = Object.values(result).some(v => v && v.tonnes != null);
if (!hasData) throw new Error('頁面無可解析數據');
process.stderr.write('[fetch-all-data] LME 方案B 成功\n');
return result;
} catch (err) {
errors.push(`方案B: err.message`);
process.stderr.write(`[fetch-all-data] LME 方案B 失敗: err.message\n`);
}
// 方案C:Investing.com metals data
try {
const res = await fetch(
'https://api.investing.com/api/financialdata/assets/equitiesByType?country=&type=metals&page=0&pageSize=20',
{
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json',
'domain-id': 'www',
},
signal: AbortSignal.timeout(10000),
}
);
if (!res.ok) throw new Error(`HTTP res.status`);
const data = await res.json();
if (!data) throw new Error('Empty response');
// Investing.com 不提供庫存數據,此方案會失敗
throw new Error('Investing.com 不提供 LME 庫存數據');
} catch (err) {
errors.push(`方案C: err.message`);
process.stderr.write(`[fetch-all-data] LME 方案C 失敗: err.message\n`);
}
// 所有方案失敗
const note = `LME 庫存獲取失敗: errors.join(' | ')`;
process.stderr.write(`[fetch-all-data] 所有 LME 方案失敗,返回 null\n`);
return {
copper: null,
zinc: null,
nickel: null,
cobalt: null,
note,
};
}
// ────────────────────────────────────────────
// 5. Google News RSS 新聞
// ────────────────────────────────────────────
function parseRssItems(xml) {
const items = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/gi;
let match;
while ((match = itemRegex.exec(xml)) !== null) {
const block = match[1];
const titleMatch = block.match(/<title><!\[CDATA\[([\s\S]*?)\]\]><\/title>/) ||
block.match(/<title>([\s\S]*?)<\/title>/);
const linkMatch = block.match(/<link>([\s\S]*?)<\/link>/) ||
block.match(/<guid[^>]*>(https?:\/\/[^\s<]+)<\/guid>/);
const title = titleMatch ? titleMatch[1].trim() : '';
const url = linkMatch ? linkMatch[1].trim() : '';
if (title) items.push({ title, url });
}
return items;
}
async function fetchNews() {
const rssUrl = 'https://news.google.com/rss/search?q=%E6%9C%89%E8%89%B2%E9%87%91%E5%B1%9E+%E4%BB%B7%E6%A0%BC&hl=zh-CN&gl=CN&ceid=CN:zh-Hans';
try {
const res = await fetch(rssUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MetalPriceBot/1.0)',
'Accept': 'application/rss+xml, application/xml, text/xml, */*',
},
signal: AbortSignal.timeout(12000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const xml = await res.text();
const items = parseRssItems(xml);
return items.slice(0, 5);
} catch (err) {
process.stderr.write(`[fetch-all-data] 新聞抓取失敗: err.message\n`);
return [];
}
}
// ────────────────────────────────────────────
// 6. 投行分析新聞(ibNews)
// v5: 改為基本金屬雙重過濾(投行名字 AND 基本金屬關鍵詞)
// ────────────────────────────────────────────
async function fetchIbNews() {
const queries = [
'Goldman+Sachs+JPMorgan+Citi+copper+nickel+zinc+outlook',
'copper+nickel+zinc+cobalt+forecast+bank+2026',
'base+metals+copper+nickel+Goldman+JPMorgan+forecast',
];
const ibKeywords = ['Goldman', 'JPMorgan', 'Citi', 'Morgan Stanley', 'Bank of America', 'UBS', 'HSBC', 'Barclays', 'BNP', 'Deutsche'];
const metalKeywords = ['copper', 'nickel', 'zinc', 'cobalt', 'alumin', 'base metal', 'industrial metal'];
const allItems = [];
for (const q of queries) {
try {
const url = `https://news.google.com/rss/search?q=q&hl=en-US&gl=US&ceid=US:en`;
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MetalPriceBot/1.0)',
'Accept': 'application/rss+xml, */*',
},
signal: AbortSignal.timeout(10000),
});
if (!res.ok) continue;
const xml = await res.text();
const items = parseRssItems(xml);
// 雙重過濾:同時包含投行名字 AND 基本金屬關鍵詞
const doubleFiltered = items.filter(i =>
ibKeywords.some(k => i.title.toLowerCase().includes(k.toLowerCase())) &&
metalKeywords.some(m => i.title.toLowerCase().includes(m.toLowerCase()))
);
if (doubleFiltered.length > 0) {
process.stderr.write(`[fetch-all-data] ibNews 雙重過濾找到 doubleFiltered.length 條基本金屬投行新聞 (query: q)\n`);
// 合併去重
for (const item of doubleFiltered) {
if (!allItems.some(x => x.title === item.title)) allItems.push(item);
}
} else {
process.stderr.write(`[fetch-all-data] ibNews 雙重過濾無結果 (query: q),嘗試只過濾金屬關鍵詞\n`);
// fallback:只過濾金屬關鍵詞
const metalOnly = items.filter(i =>
metalKeywords.some(m => i.title.toLowerCase().includes(m.toLowerCase()))
);
for (const item of metalOnly) {
if (!allItems.some(x => x.title === item.title)) {
allItems.push({ ...item, source: 'industry_news' });
}
}
}
} catch (err) {
process.stderr.write(`[fetch-all-data] IB news fetch failed: err.message\n`);
}
}
if (allItems.length > 0) {
process.stderr.write(`[fetch-all-data] ibNews 最終 allItems.length 條\n`);
return allItems.slice(0, 4);
}
process.stderr.write('[fetch-all-data] ibNews 未找到相關新聞\n');
return [];
}
// ────────────────────────────────────────────
// v4 新增:7. SMM上海有色網新聞(免費公開)
// 狀態:✅ 200 OK,無需登錄即可抓取新聞標題
// ────────────────────────────────────────────
async function fetchSmmNews() {
try {
const res = await fetch('https://www.smm.cn/', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,*/*',
'Accept-Language': 'zh-CN,zh;q=0.9',
},
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const html = await res.text();
// 提取新聞標題(SMM 主頁快訊格式:包含日期時間和標題)
const items = [];
// 方式1:匹配 SMM快讯 格式
const flashRegex = /【SMM[^】]*】([^<\n]{10,100})/g;
let m;
while ((m = flashRegex.exec(html)) !== null && items.length < 8) {
const title = m[0].replace(/<[^>]+>/g, '').trim();
if (title.length > 10) items.push({ title, source: 'SMM' });
}
// 方式2:匹配帶有日期的新聞標題區塊
if (items.length < 3) {
const titleRegex = /<a[^>]+href="https:\/\/news\.smm\.cn\/news\/[^"]+">([^<]{10,120})<\/a>/g;
while ((m = titleRegex.exec(html)) !== null && items.length < 8) {
const title = m[1].trim();
if (title.length > 8 && !items.some(i => i.title === title)) {
items.push({ title, source: 'SMM' });
}
}
}
process.stderr.write(`[fetch-all-data] SMM新聞: 提取 items.length 條\n`);
return items.slice(0, 5);
} catch (err) {
process.stderr.write(`[fetch-all-data] SMM新聞抓取失敗: err.message\n`);
return [];
}
}
// ────────────────────────────────────────────
// v5 更新:8. Reddit 有色金屬相關討論
// 改為 r/Economics 搜索 copper+metals(v5測試結果:3條有效帖)
// 測試結果:r/mining(1帖), r/metallurgy(2帖), r/investing(0帖),
// r/Economics search(3帖✅), global search(3帖但多為遊戲/藝術)
// ────────────────────────────────────────────
async function fetchRedditCommodities() {
const metalKw = ['copper', 'nickel', 'zinc', 'cobalt', 'alumin', 'lead', 'tin',
'base metal', 'industrial metal', 'non-ferrous', 'lme', 'comex',
'mining', 'ore', 'smelter', 'refinery'];
try {
// 並行抓取 top(本週)和 hot(當前)
const [topRes, hotRes] = await Promise.all([
fetch('https://www.reddit.com/r/Commodities/top.json?t=week&limit=25', {
headers: { 'User-Agent': 'MetalPriceBot/5.0 (non-ferrous metals research)' },
signal: AbortSignal.timeout(8000),
}),
fetch('https://www.reddit.com/r/Commodities/hot.json?limit=25', {
headers: { 'User-Agent': 'MetalPriceBot/5.0 (non-ferrous metals research)' },
signal: AbortSignal.timeout(8000),
}),
]);
const topData = topRes.ok ? await topRes.json() : { data: { children: [] } };
const hotData = hotRes.ok ? await hotRes.json() : { data: { children: [] } };
const parsePosts = (data) => (data?.data?.children ?? [])
.filter(p => p?.data?.title)
.map(p => ({
id: p.data.id,
title: p.data.title,
score: p.data.score || 0,
url: `https://reddit.comp.data.permalink`,
}));
const topPosts = parsePosts(topData);
const hotPosts = parsePosts(hotData);
// 金屬關鍵詞過濾
const isMetalRelated = (title) =>
metalKw.some(k => title.toLowerCase().includes(k));
const metalTop = topPosts.filter(p => isMetalRelated(p.title));
const metalHot = hotPosts.filter(p => isMetalRelated(p.title));
// 找出異動帖(在 hot 榜但不在 top 榜的 id)
const topIds = new Set(topPosts.map(p => p.id));
const surgingPosts = metalHot.filter(p => !topIds.has(p.id));
// 組合輸出:金屬相關 top + 異動帖
const combined = [
...metalTop.slice(0, 4).map(p => ({ ...p, tag: 'top' })),
...surgingPosts.slice(0, 2).map(p => ({ ...p, tag: 'surging' })),
];
// 如果完全沒有金屬相關帖子,返回前3條 top 帖(帶 tag: 'general')供參考
const result = combined.length > 0
? combined
: topPosts.slice(0, 3).map(p => ({ ...p, tag: 'general_commodities' }));
const metalCount = metalTop.length + surgingPosts.length;
process.stderr.write(`[fetch-all-data] Reddit r/Commodities: top=topPosts.length帖, hot=hotPosts.length帖, 金屬相關=metalCount帖, 異動=surgingPosts.length帖\n`);
return result;
} catch (err) {
process.stderr.write(`[fetch-all-data] Reddit抓取失敗: err.message\n`);
return [];
}
}
// ────────────────────────────────────────────
// v4 新增:9. 合成 forumSentiment 字段
// ────────────────────────────────────────────
function buildForumSentiment(smmItems, redditItems) {
let smmHighlights = null;
if (smmItems.length > 0) {
smmHighlights = smmItems.map(i => i.title).join(' | ');
}
let redditSummary = null;
let redditSurging = null;
if (redditItems.length > 0) {
const topItems = redditItems.filter(p => p.tag === 'top' || p.tag === 'general_commodities');
const surgingItems = redditItems.filter(p => p.tag === 'surging');
if (topItems.length > 0) {
redditSummary = topItems.map(i => `[i.score↑] i.title`).join(' | ');
}
if (surgingItems.length > 0) {
redditSurging = surgingItems.map(i => `[異動🔥] i.title`).join(' | ');
}
}
return {
smmHighlights,
redditSummary, // 金屬相關 top 帖
redditSurging, // 異動帖(hot but not top)
xueqiuSummary: null,
fetchedAt: new Date().toISOString(),
};
}
// ────────────────────────────────────────────
// 3d. 鈷(Co)USD 現貨價格
// 主源:tradingeconomics.com(meta description 嵌入,穩定可靠)
// 備用:dailymetalprice.com(JSON 數組,USD/lb → USD/t)
// 測試日期:2026-03-17
// ────────────────────────────────────────────
async function fetchCobaltUsd() {
// === 方案A:tradingeconomics.com meta description ===
// 格式:"Cobalt traded flat at 56,290 USD/T on March 12, 2026."
try {
const res = await fetch('https://tradingeconomics.com/commodity/cobalt', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Cache-Control': 'no-cache',
},
signal: AbortSignal.timeout(12000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const html = await res.text();
const metaDesc = html.match(/<meta[^>]+name="description"[^>]+content="([^"]+)"/);
if (!metaDesc) throw new Error('meta description not found');
const priceMatch = metaDesc[1].match(/at\s+([\d,]+)\s*USD\/T/i);
if (!priceMatch) throw new Error(`price pattern not found in: metaDesc[1].slice(0, 80)`);
const price = parseFloat(priceMatch[1].replace(/,/g, ''));
if (isNaN(price) || price <= 0) throw new Error(`invalid price: priceMatch[1]`);
// 提取日期
const dateMatch = metaDesc[1].match(/on\s+(\w+)\s+(\d+),\s+(\d{4})/i);
let dataDate = null;
if (dateMatch) {
const monthNames = { January:1,February:2,March:3,April:4,May:5,June:6,
July:7,August:8,September:9,October:10,November:11,December:12 };
const m = monthNames[dateMatch[1]];
if (m) {
dataDate = `dateMatch[3]-String(m).padStart(2,'0')-String(parseInt(dateMatch[2])).padStart(2,'0')`;
}
}
process.stderr.write(`[fetch-all-data] 鈷 USD 方案A (TradingEconomics): $price/t, date=dataDate\n`);
return { price, unit: 'USD/t', dataDate, source: 'TradingEconomics' };
} catch(err) {
process.stderr.write(`[fetch-all-data] 鈷 USD 方案A (TradingEconomics) 失敗: err.message\n`);
}
// === 方案B:dailymetalprice.com JSON array(USD/lb)===
// 格式:data = [[timestamp_ms, price_usd_per_lb], ...]
try {
const res = await fetch('https://www.dailymetalprice.com/metalpricecharts.php?c=co&u=usd&d=5', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,*/*',
'Referer': 'https://www.dailymetalprice.com/',
},
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const text = await res.text();
// Extract data array: [[timestamp, price], ...]
const arrMatch = text.match(/data\s*[=:]\s*(\[\[[\s\S]*?\]\])/);
if (!arrMatch) throw new Error('data array not found');
const arr = JSON.parse(arrMatch[1]);
if (!Array.isArray(arr) || arr.length === 0) throw new Error('empty data array');
// Take the most recent entry (first = latest)
const [ts, pricePerLb] = arr[0];
if (typeof pricePerLb !== 'number' || pricePerLb <= 0) throw new Error(`invalid price: pricePerLb`);
// Convert USD/lb → USD/t (1 short ton = 2000 lb, but metal convention uses metric ton = 2204.623 lb)
const pricePerTon = Math.round(pricePerLb * 2204.623);
const dataDate = new Date(ts).toISOString().slice(0, 10);
process.stderr.write(`[fetch-all-data] 鈷 USD 方案B (DailyMetalPrice): pricePerLb USD/lb → $pricePerTon/t, date=dataDate\n`);
return { price: pricePerTon, pricePerLb, unit: 'USD/t', dataDate, source: 'DailyMetalPrice' };
} catch(err) {
process.stderr.write(`[fetch-all-data] 鈷 USD 方案B (DailyMetalPrice) 失敗: err.message\n`);
}
// === 方案C:SMM CNY ÷ USD/CNY 匯率估算 ===
// 使用 CCMN 鈷 CNY 價格 + Yahoo Finance USD/CNY 匯率反推
try {
const fxRes = await fetch('https://query1.finance.yahoo.com/v8/finance/chart/USDCNY=X?interval=1d&range=2d', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json',
},
signal: AbortSignal.timeout(8000),
});
if (!fxRes.ok) throw new Error(`Yahoo FX HTTP fxRes.status`);
const fxData = await fxRes.json();
const usdcny = fxData?.chart?.result?.[0]?.meta?.regularMarketPrice;
if (!usdcny || usdcny <= 0) throw new Error(`invalid USD/CNY: usdcny`);
// Will be combined with CCMN cobalt CNY price in main()
process.stderr.write(`[fetch-all-data] 鈷 USD 方案C 準備: USD/CNY=usdcny\n`);
return { price: null, usdcny, unit: 'USD/t', source: 'SMM-CNY/FX-estimate', needsCny: true };
} catch(err) {
process.stderr.write(`[fetch-all-data] 鈷 USD 方案C (SMM CNY/FX) 失敗: err.message\n`);
}
return null;
}
// ────────────────────────────────────────────
// 主函數
// ────────────────────────────────────────────
async function main() {
const startTime = Date.now();
// 計算遠期合約 symbol
const now = new Date();
const monthCodes = ['F','G','H','J','K','M','N','Q','U','V','X','Z'];
const curr = now.getMonth(); // 0-11
const m2 = monthCodes[(curr + 2) % 12];
const m6 = monthCodes[(curr + 6) % 12];
const y2 = (curr + 2 >= 12) ? (now.getFullYear() + 1) % 100 : now.getFullYear() % 100;
const y6 = (curr + 6 >= 12) ? (now.getFullYear() + 1) % 100 : now.getFullYear() % 100;
const sym2 = `HGm2y2.CMX`;
const sym6 = `HGm6y6.CMX`;
process.stderr.write(`[fetch-all-data] 遠期合約: 近月=sym2, 遠月=sym6\n`);
// 並行抓取所有數據(v10: 新增 fetchCobaltUsd)
const [
ccmn,
ometal,
copperSpot,
zincSpot,
alumSpot,
fwdNear,
fwdFar,
bismuth,
smmCross,
smmLead,
smmTin,
inventory,
cobaltUsdData,
news,
ibNews,
smmNews,
redditPosts,
metalIndices,
fxRate,
macroIndicators,
] = await Promise.all([
fetchCcmnPrices(),
fetchOmetal(), // v11 新增:OmetalCN 備用源(Cu/Al/Pb/Zn/Ni/Sn)
fetchYahoo('HG=F'),
fetchYahoo('ZNC=F'),
fetchYahoo('ALI=F'), // 鋁現貨 USD/t
fetchYahoo(sym2),
fetchYahoo(sym6),
fetchSmmBismuth(), // 鉍:SMM h5 __NEXT_DATA__(含 CNY + CIF USD)
fetchSmmCrossCheck(), // SMM 長江報價交叉驗證(Cu/Zn/Ni)
fetchSmmMetal('pb-price', '长江现货铅锭价格'), // 鉛 CNY
fetchSmmMetal('sn-price', '长江锡锭价格'), // 錫 CNY
fetchLmeInventory(), // Westmetall LME 庫存 + Zn/Ni USD
fetchCobaltUsd(), // 鈷 USD 現貨(TradingEconomics / DailyMetalPrice)
fetchNews(),
fetchIbNews(),
fetchSmmNews(),
fetchRedditCommodities(),
fetchMetalIndices(),
fetchUsdcny(), // USD/CNY 匯率(供進口盈虧、基差)
fetchMacroIndicators(), // 宏觀風險指標(DXY/VIX/CRB/TNX)
]);
// v9: 從 inventory._wmPrices 提取 Westmetall 現貨 USD 數據(從輸出中清理內部字段)
const westmetall = inventory?._wmPrices ?? { copper: null, zinc: null, nickel: null };
if (inventory) delete inventory._wmPrices;
// 升級一:dataDate / isMarketOpen / marketNote
const dataDate = ccmn?.dataDate ?? null;
const isMarketOpen = ccmn?.isMarketOpen ?? null;
const todaySH = today();
let marketNote = null;
if (isMarketOpen === false && dataDate) {
const displayDate = dataDate.replace(/-/g, '/');
marketNote = `休市:數據截至 displayDate(上個交易日)`;
}
// 組裝 prices(v8:CCMN+SMM 交叉驗證 + 鉛/錫新增 + 鋁CNY從CCMN)
const prices = {
copper: {
usd: copperSpot.price,
usdChangePct: copperSpot.changePct, // 日環比 %(vs 前一交易日收盤)
usdUnit: 'USD/lb',
cny: ccmn?.copper?.price ?? smmCross?.copper?.average ?? null,
cnyChange: ccmn?.copper?.updown ?? smmCross?.copper?.change ?? null, // 日環比 元/噸
// v8 交叉驗證:SMM 長江現貨銅價
smmCny: smmCross?.copper?.average ?? null,
crossCheckNote: buildCrossCheckNote(ccmn?.copper?.price, smmCross?.copper?.average, 'SMM長江銅'),
},
zinc: {
// v9: 從 Westmetall LME Cash-Settlement 獲取 USD 現貨
usd: westmetall?.zinc?.cashUsd ?? null,
usdChangePct: null, // Westmetall 不提供日漲跌%,保持 null
usdUnit: 'USD/t',
cny: ccmn?.zinc?.price ?? smmCross?.zinc?.average ?? null,
cnyChange: ccmn?.zinc?.updown ?? smmCross?.zinc?.change ?? null,
// v8 交叉驗證:SMM 上海現貨0#鋅(與 CCMN 廣東市場報價較接近)
smmCny: smmCross?.zinc?.average ?? null,
crossCheckNote: buildCrossCheckNote(ccmn?.zinc?.price, smmCross?.zinc?.average, 'SMM上海0#鋅'),
},
aluminum: {
usd: alumSpot.ok ? alumSpot.price : null,
usdChangePct: alumSpot.ok ? alumSpot.changePct : null,
usdUnit: 'USD/t',
// v11: 優先 CCMN A00鋁 → 備用 OmetalCN A00鋁(SMM 無鋁 h5 頁面)
cny: ccmn?.aluminum?.price ?? ometal?.aluminum?.price ?? null,
cnyChange: ccmn?.aluminum?.updown ?? ometal?.aluminum?.change ?? null,
cnySource: ccmn?.aluminum?.price != null ? 'CCMN' : (ometal?.aluminum?.price != null ? 'OmetalCN' : null),
},
nickel: {
// v9: 從 Westmetall LME Cash-Settlement 獲取 USD 現貨
usd: westmetall?.nickel?.cashUsd ?? null,
usdChangePct: null, // Westmetall 不提供日漲跌%,保持 null
usdUnit: 'USD/t',
cny: ccmn?.nickel?.price ?? smmCross?.nickel?.average ?? null,
cnyChange: ccmn?.nickel?.updown ?? smmCross?.nickel?.change ?? null,
// v8 交叉驗證:SMM 長江鎳價格(電解鎳)
smmCny: smmCross?.nickel?.average ?? null,
crossCheckNote: buildCrossCheckNote(ccmn?.nickel?.price, smmCross?.nickel?.average, 'SMM電解鎳'),
},
cobalt: (() => {
// v10: 解析 fetchCobaltUsd() 返回值
let cobaltUsd = null;
let cobaltUsdSource = null;
let cobaltUsdDate = null;
if (cobaltUsdData) {
if (cobaltUsdData.needsCny && cobaltUsdData.usdcny) {
// 方案C:SMM CNY ÷ FX 估算
const cnyCobalt = ccmn?.cobalt?.price;
if (cnyCobalt && cnyCobalt > 0) {
cobaltUsd = Math.round(cnyCobalt / cobaltUsdData.usdcny);
cobaltUsdSource = 'SMM-CNY/FX-estimate';
}
} else if (cobaltUsdData.price) {
cobaltUsd = cobaltUsdData.price;
cobaltUsdSource = cobaltUsdData.source;
cobaltUsdDate = cobaltUsdData.dataDate;
}
}
return {
usd: cobaltUsd,
usdChangePct: null,
usdUnit: 'USD/t',
usdDataDate: cobaltUsdDate,
usdSource: cobaltUsdSource,
cny: ccmn?.cobalt?.price ?? null,
cnyChange: ccmn?.cobalt?.updown ?? null,
};
})(),
// 鉍(Bi)— SMM 上海有色網實時數據
bismuth: bismuth ? {
cny: bismuth.cny?.average ?? null,
cnyHigh: bismuth.cny?.high ?? null,
cnyLow: bismuth.cny?.low ?? null,
cnyChange: bismuth.cny?.change ?? null, // 日環比絕對值 元/噸
cnyChangePct: bismuth.cny?.changePct ?? null, // 日環比 %(SMM vchange_rate×100)
cnyUnit: '\u5143/\u5428',
usd: bismuth.usd?.average ?? null, // USD/t (CIF)
usdHigh: bismuth.usd?.high ?? null,
usdLow: bismuth.usd?.low ?? null,
usdChange: bismuth.usd?.change ?? null,
usdChangePct: bismuth.usd?.changePct ?? null,
usdUnit: 'USD/t',
dataDate: bismuth.cny?.dataDate ?? bismuth.usd?.dataDate ?? null,
source: bismuth.source,
} : {
cny: null, usd: null,
source: null,
note: 'SMM抓取失敗,暫無鉍數據',
},
// v12 新增:鎂(Mg)— CCMN 1#鎂
magnesium: ccmn?.magnesium ? {
cny: ccmn.magnesium.price ?? null,
cnyChange: ccmn.magnesium.updown ?? null,
cnyUnit: '\u5143/\u5428',
usd: null,
source: 'CCMN',
} : { cny: null, usd: null, source: null },
// v8 新增:鉛(Pb)— SMM pb-price 長江現貨
lead: smmLead ? {
cny: smmLead.average,
cnyHigh: smmLead.high,
cnyLow: smmLead.low,
cnyChange: smmLead.change,
cnyChangePct: smmLead.changePct,
cnyUnit: '\u5143/\u5428',
usd: null, // SMM pb-price 無 LME USD 頁面
dataDate: smmLead.dataDate,
source: smmLead.source,
} : { cny: null, usd: null, source: null },
// v8 新增:錫(Sn)— SMM sn-price 長江現貨
tin: smmTin ? {
cny: smmTin.average,
cnyHigh: smmTin.high,
cnyLow: smmTin.low,
cnyChange: smmTin.change,
cnyChangePct: smmTin.changePct,
cnyUnit: '\u5143/\u5428',
usd: null, // SMM sn-price 無 LME USD 頁面
dataDate: smmTin.dataDate,
source: smmTin.source,
} : { cny: null, usd: null, source: null },
};
// 組裝 forwards
const spotExpiry = `now.getFullYear()-String(now.getMonth()+1).padStart(2,'0')`;
const forwards = {
copper: {
spot: {
price: copperSpot.price,
symbol: 'HG=F',
expiry: spotExpiry,
},
near: {
price: fwdNear.price,
symbol: sym2,
expiry: fwdNear.expiry,
},
far: {
price: fwdFar.price,
symbol: sym6,
expiry: fwdFar.expiry,
},
},
};
// v4 新增:組裝 forumSentiment
const forumSentiment = buildForumSentiment(smmNews, redditPosts);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
process.stderr.write(`[fetch-all-data] 完成,耗時 elapseds\n`);
// 計算交叉驗證差異
const crossCheckDiff = (ccmnVal, smmVal) => {
if (ccmnVal == null || smmVal == null) return null;
return +((Math.abs(ccmnVal - smmVal) / ccmnVal * 100).toFixed(3));
};
const output = {
date: todaySH,
dataDate,
isMarketOpen,
marketNote,
changeNote: '所有漲跌均為日環比(vs 前一交易日收盤)',
prices,
forwards,
indices: metalIndices,
macro: macroIndicators,
fxRates: {
usdCny: fxRate,
},
inventory,
// SMM 長江報價交叉驗證(與 CCMN 對比,差異 <1% 為正常市場誤差)
smmCrossCheck: {
copper: smmCross?.copper ? {
smmAvg: smmCross.copper.average,
smmChange: smmCross.copper.change,
smmChangePct: smmCross.copper.changePct,
ccmnAvg: ccmn?.copper?.price ?? null,
diffPct: crossCheckDiff(ccmn?.copper?.price, smmCross.copper.average),
consistent: crossCheckDiff(ccmn?.copper?.price, smmCross.copper.average) != null
? crossCheckDiff(ccmn?.copper?.price, smmCross.copper.average) < 1
: null,
note: smmCross.copper.productName,
} : null,
zinc: smmCross?.zinc ? {
smmAvg: smmCross.zinc.average,
smmChange: smmCross.zinc.change,
smmChangePct: smmCross.zinc.changePct,
ccmnAvg: ccmn?.zinc?.price ?? null,
diffPct: crossCheckDiff(ccmn?.zinc?.price, smmCross.zinc.average),
consistent: crossCheckDiff(ccmn?.zinc?.price, smmCross.zinc.average) != null
? crossCheckDiff(ccmn?.zinc?.price, smmCross.zinc.average) < 1
: null,
note: smmCross.zinc.productName,
} : null,
nickel: smmCross?.nickel ? {
smmAvg: smmCross.nickel.average,
smmChange: smmCross.nickel.change,
smmChangePct: smmCross.nickel.changePct,
ccmnAvg: ccmn?.nickel?.price ?? null,
diffPct: crossCheckDiff(ccmn?.nickel?.price, smmCross.nickel.average),
consistent: crossCheckDiff(ccmn?.nickel?.price, smmCross.nickel.average) != null
? crossCheckDiff(ccmn?.nickel?.price, smmCross.nickel.average) < 1
: null,
note: smmCross.nickel.productName,
} : null,
},
// 數據可用性說明(v9 更新:新增 Westmetall LME庫存+Zn/Ni USD現貨)
dataAvailability: {
copper: { usd: 'Yahoo HG=F ✅', cny: ccmn?.copper?.price ? 'CCMN ✅(主)/ SMM長江✅(校驗)' : (smmCross?.copper?.average ? 'SMM長江 ✅(CCMN故障自動切備援)' : '❌ CNY源缺失') },
zinc: { usd: westmetall?.zinc?.cashUsd ? `Westmetall LME Cash ✅ $westmetall.zinc.cashUsd/t` : '❌ Westmetall抓取失敗', cny: ccmn?.zinc?.price ? 'CCMN ✅(主)/ SMM上海0#✅(校驗)' : (smmCross?.zinc?.average ? 'SMM上海0# ✅(CCMN故障自動切備援)' : '❌ CNY源缺失') },
aluminum:{ usd: 'Yahoo ALI=F ✅', cny: ccmn?.aluminum?.price ? 'CCMN A00鋁 ✅' : (ometal?.aluminum?.price ? 'OmetalCN A00鋁 ✅(備用)' : '❌ 無鋁CNY數據') },
nickel: { usd: westmetall?.nickel?.cashUsd ? `Westmetall LME Cash ✅ $westmetall.nickel.cashUsd/t` : '❌ Westmetall抓取失敗', cny: ccmn?.nickel?.price ? 'CCMN ✅(主)/ SMM電解鎳✅(校驗)' : (smmCross?.nickel?.average ? 'SMM電解鎳 ✅(CCMN故障自動切備援)' : '❌ CNY源缺失') },
cobalt: {
usd: cobaltUsdData?.price
? `cobaltUsdData.source ✅ $cobaltUsdData.price/t (cobaltUsdData.dataDate)`
: cobaltUsdData?.needsCny
? `SMM-CNY/FX-estimate ✅(估算)`
: '❌ 所有源失敗',
cny: 'CCMN ✅',
},
bismuth: { usd: 'SMM CIF ✅(精鉍USD/kg×1000)', cny: 'SMM精鉍 ✅' },
magnesium: { usd: '❌ 無免費USD源', cny: ccmn?.magnesium?.price ? 'CCMN 1#鎂 ✅(v12新增)' : '❌ CCMN無鎂數據' },
lead: { usd: '❌ 無免費源', cny: smmLead ? 'SMM長江鉛錠 ✅(v8新增)' : '❌ SMM抓取失敗' },
tin: { usd: '❌ 無免費源', cny: smmTin ? 'SMM長江錫錠 ✅(v8新增)' : '❌ SMM抓取失敗' },
lmeInventory: westmetall?.copper?.tonnes ? `Westmetall ✅(Cu=westmetall.copper.tonnest, Zn=westmetall.zinc?.tonnest, Ni=westmetall.nickel?.tonnest)` : '❌ Westmetall抓取失敗',
},
news,
ibNews,
forumSentiment,
};
console.log(JSON.stringify(output, null, 2));
}
main().catch(err => {
process.stderr.write(`[fetch-all-data] 致命錯誤: err.message\n`);
// 即使崩潰也輸出合法 JSON
console.log(JSON.stringify({
date: today(),
dataDate: null,
isMarketOpen: null,
marketNote: null,
changeNote: '所有漲跌均為日環比(vs 前一交易日收盤)',
prices: { copper: null, zinc: null, aluminum: null, nickel: null, cobalt: null, bismuth: null, magnesium: null, lead: null, tin: null },
forwards: { copper: null },
indices: [],
macro: [],
fxRates: { usdCny: null },
inventory: { copper: null, zinc: null, nickel: null, cobalt: null, note: err.message },
news: [],
ibNews: [],
forumSentiment: { smmHighlights: null, redditSummary: null, redditSurging: null, xueqiuSummary: null, fetchedAt: new Date().toISOString() },
error: err.message,
}, null, 2));
process.exit(1);
});
FILE:scripts/fetch-news.mjs
/**
* fetch-news.mjs
* 抓取有色金屬相關新聞(RSS 解析,無需外部庫)
* 數據源優先級:Google News RSS → Reuters RSS → Yahoo Finance RSS
*/
const METAL_KEYWORDS = ['copper', 'zinc', 'nickel', 'cobalt', 'metal', '铜', '锌', '镍', '钴', '有色'];
// ────────────────────────────────────────────
// RSS XML 手動解析
// ────────────────────────────────────────────
function parseRSS(xml) {
const items = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/gi;
let match;
while ((match = itemRegex.exec(xml)) !== null) {
const block = match[1];
// 標題
const titleMatch = block.match(/<title><!\[CDATA\[([\s\S]*?)\]\]><\/title>/) ||
block.match(/<title>([\s\S]*?)<\/title>/);
const title = titleMatch ? titleMatch[1].trim() : '';
// URL
const linkMatch = block.match(/<link>([\s\S]*?)<\/link>/) ||
block.match(/<guid[^>]*>(https?:\/\/[^\s<]+)<\/guid>/);
const url = linkMatch ? linkMatch[1].trim() : '';
// 發布時間
const pubDateMatch = block.match(/<pubDate>([\s\S]*?)<\/pubDate>/);
const publishedAt = pubDateMatch ? pubDateMatch[1].trim() : '';
if (title) {
items.push({ title, url, publishedAt });
}
}
return items;
}
// ────────────────────────────────────────────
// 過濾金屬相關新聞(關鍵詞匹配)
// ────────────────────────────────────────────
function filterMetalNews(items) {
return items.filter(item => {
const text = (item.title + ' ' + (item.description || '')).toLowerCase();
return METAL_KEYWORDS.some(kw => text.includes(kw.toLowerCase()));
});
}
// ────────────────────────────────────────────
// 單個 RSS 源抓取
// ────────────────────────────────────────────
// ────────────────────────────────────────────
// 日期過濾(所有源通用,只保留 36h 內)
// ────────────────────────────────────────────
function filterByDate(items) {
const cutoff = Date.now() - 36 * 60 * 60 * 1000;
return items.filter(item => {
if (!item.publishedAt) return true; // 無日期字段不過濾
const ts = Date.parse(item.publishedAt);
return isNaN(ts) || ts >= cutoff;
});
}
async function fetchRSS(url, needFilter = false) {
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MetalPriceBot/1.0)',
'Accept': 'application/rss+xml, application/xml, text/xml, */*',
},
signal: AbortSignal.timeout(12000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const xml = await res.text();
let items = parseRSS(xml);
if (needFilter) items = filterMetalNews(items);
items = filterByDate(items); // 所有源都過濾日期
return items;
}
// ────────────────────────────────────────────
// 主函數:按優先級嘗試數據源
// ────────────────────────────────────────────
async function main() {
const sources = [
{
name: 'Google News RSS',
url: 'https://news.google.com/rss/search?q=%E6%9C%89%E8%89%B2%E9%87%91%E5%B1%9E+%E4%BB%B7%E6%A0%BC&hl=zh-CN&gl=CN&ceid=CN:zh-Hans',
needFilter: false,
},
{
name: 'Reuters RSS',
url: 'https://feeds.reuters.com/reuters/UKBusinessNews',
needFilter: true,
},
{
name: 'Yahoo Finance RSS',
url: 'https://finance.yahoo.com/rss/topstories',
needFilter: true,
},
];
let news = [];
let usedSource = null;
for (const source of sources) {
try {
process.stderr.write(`[fetch-news] 嘗試 source.name...\n`);
const items = await fetchRSS(source.url, source.needFilter);
if (items.length > 0) {
news = items.slice(0, 5);
usedSource = source.name;
process.stderr.write(`[fetch-news] ✅ source.name 成功,獲取 news.length 條\n`);
break;
} else {
process.stderr.write(`[fetch-news] ⚠️ source.name 返回 0 條,繼續下一個\n`);
}
} catch (err) {
process.stderr.write(`[fetch-news] ❌ source.name 失敗: err.message\n`);
}
}
if (news.length === 0) {
process.stderr.write('[fetch-news] 所有數據源均失敗或無相關新聞\n');
}
const output = {
source: usedSource,
count: news.length,
items: news,
};
console.log(JSON.stringify(output, null, 2));
}
main().catch(err => {
console.error('Fatal error:', err.message);
process.exit(1);
});
FILE:scripts/fetch-prices.mjs
/**
* fetch-prices.mjs
* 抓取有色金属现货/期货价格
* 数据源:Yahoo Finance v8 API + 长江有色(CCMN) + Stooq
*/
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = join(__dirname, '..');
// ────────────────────────────────────────────
// 读取 .env
// ────────────────────────────────────────────
function loadEnv() {
const envPath = join(PROJECT_ROOT, '.env');
const env = {};
try {
const content = readFileSync(envPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
env[key] = value;
}
} catch { /* ignore */ }
return env;
}
// ────────────────────────────────────────────
// Yahoo Finance v8 API
// ────────────────────────────────────────────
async function fetchYahooPrice(symbol, name, unit, exchange) {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/encodeURIComponent(symbol)?interval=1d&range=2d`;
try {
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json',
},
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const data = await res.json();
const result = data?.chart?.result?.[0];
if (!result) throw new Error('No result in response');
const meta = result.meta;
const currentPrice = meta.regularMarketPrice;
const prevClose = meta.chartPreviousClose ?? meta.previousClose;
let changePct = null;
if (prevClose && currentPrice) {
changePct = +((currentPrice - prevClose) / prevClose * 100).toFixed(2);
}
return {
name,
price: currentPrice ?? null,
changePct,
unit,
exchange,
source: 'Yahoo Finance',
ccmnPrice: null,
ccmnUpdown: null,
};
} catch (err) {
process.stderr.write(`[fetch-prices] Yahoo Finance symbol error: err.message\n`);
return {
name,
price: null,
changePct: null,
unit,
exchange,
source: 'Yahoo Finance',
ccmnPrice: null,
ccmnUpdown: null,
};
}
}
// ────────────────────────────────────────────
// Stooq CSV API(镍 NI.F 等,备用)
// 价格单位:美分/磅(cents/lb),需转换为 USD/t
// ────────────────────────────────────────────
async function fetchStooqPrice(symbol, name, unit, exchange) {
const url = `https://stooq.com/q/l/?s=encodeURIComponent(symbol)&f=sd2t2ohlcv&h&e=csv`;
try {
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0' },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const text = await res.text();
const lines = text.trim().split('\n');
if (lines.length < 2) throw new Error('Empty CSV');
const headers = lines[0].split(',').map(h => h.trim());
const values = lines[1].split(',').map(v => v.trim());
const obj = {};
headers.forEach((h, i) => { obj[h] = values[i]; });
if (obj['Close'] === 'N/D' || !obj['Close']) throw new Error('No data (N/D)');
const rawPrice = parseFloat(obj['Close']);
if (isNaN(rawPrice)) throw new Error('Invalid price');
let price = rawPrice;
if (unit === 'USD/t') {
price = +(rawPrice / 100 * 2204.62).toFixed(2);
}
return {
name,
price,
changePct: null,
unit,
exchange,
source: 'Stooq',
ccmnPrice: null,
ccmnUpdown: null,
};
} catch (err) {
process.stderr.write(`[fetch-prices] Stooq symbol error: err.message\n`);
return {
name,
price: null,
changePct: null,
unit,
exchange,
source: 'Stooq',
ccmnPrice: null,
ccmnUpdown: null,
};
}
}
// ────────────────────────────────────────────
// 长江有色 CCMN API
// 提供 Cu/Zn/Ni/Co 人民币现货价 + 涨跌额
// ────────────────────────────────────────────
async function fetchCcmnPrices() {
const url = 'https://m.ccmn.cn/mhangqing/getCorpStmarketPriceList?marketVmid=40288092327140f601327141c0560001';
try {
const res = await fetch(url, {
headers: {
'Referer': 'https://m.ccmn.cn/mhangqing/mcjxh/',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`HTTP res.status`);
const data = await res.json();
if (!data.success) throw new Error(data.msg || 'API error');
const list = data.body?.priceList;
if (!Array.isArray(list)) throw new Error('No priceList in response');
// productSortName -> key 映射
const nameMap = {
'1#铜': 'copper',
'0#锌': 'zinc',
'1#镍': 'nickel',
'1#钴': 'cobalt',
};
const result = { copper: null, zinc: null, nickel: null, cobalt: null };
for (const item of list) {
const key = nameMap[item.productSortName];
if (key) {
const price = parseFloat(item.avgPrice);
const updown = parseFloat(item.highsLowsAmount);
result[key] = {
price: isNaN(price) ? null : price,
updown: isNaN(updown) ? null : updown,
};
}
}
return result;
} catch (err) {
process.stderr.write(`[fetch-prices] CCMN API error: err.message\n`);
return null;
}
}
// ────────────────────────────────────────────
// 主函数
// ────────────────────────────────────────────
async function main() {
// 1. 并行:Yahoo Finance (Cu/Zn USD) + CCMN (Cu/Zn/Ni/Co CNY)
const [copperRaw, zincRaw, ccmn] = await Promise.all([
fetchYahooPrice('HG=F', '铜', 'USD/lb', 'COMEX'),
fetchYahooPrice('ZNC=F', '锌', 'USD/t', 'LME'),
fetchCcmnPrices(),
]);
// 2. Cu:Yahoo Finance 为主,CCMN 补充人民币数据
const copper = {
...copperRaw,
ccmnPrice: ccmn?.copper?.price ?? null,
ccmnUpdown: ccmn?.copper?.updown ?? null,
};
// 3. Zn:Yahoo Finance 为主,CCMN 补充人民币数据
const zinc = {
...zincRaw,
ccmnPrice: ccmn?.zinc?.price ?? null,
ccmnUpdown: ccmn?.zinc?.updown ?? null,
};
// 4. Ni:优先 CCMN(有涨跌数据),Stooq 作备用
let nickel;
if (ccmn?.nickel?.price != null) {
nickel = {
name: '镍',
price: null,
changePct: null,
unit: 'CNY/t',
exchange: '長江現貨',
source: 'ccmn',
ccmnPrice: ccmn.nickel.price,
ccmnUpdown: ccmn.nickel.updown,
};
} else {
process.stderr.write('[fetch-prices] CCMN 镍数据不可用,回退到 Stooq\n');
const stooqNi = await fetchStooqPrice('NI.F', '镍', 'USD/t', 'LME');
nickel = {
...stooqNi,
ccmnPrice: null,
ccmnUpdown: null,
};
}
// 5. Co:优先 CCMN(唯一免费来源),失败则 null
const cobalt = {
name: '钴',
price: null,
changePct: null,
unit: 'CNY/t',
exchange: '長江現貨',
source: ccmn?.cobalt?.price != null ? 'ccmn' : 'none',
ccmnPrice: ccmn?.cobalt?.price ?? null,
ccmnUpdown: ccmn?.cobalt?.updown ?? null,
};
// 6. Bi:暂无免费数据源
const bismuth = {
name: '铋',
price: null,
changePct: null,
unit: 'USD/t',
exchange: 'N/A',
source: 'none',
ccmnPrice: null,
ccmnUpdown: null,
};
const results = [copper, zinc, nickel, cobalt, bismuth];
console.log(JSON.stringify(results));
}
main().catch(err => {
console.error('Fatal error:', err.message);
process.exit(1);
});
FILE:scripts/lib/market-data-utils.mjs
function toNumber(value) {
if (value == null) return null;
const normalized = String(value).replace(/,/g, '').trim();
if (!normalized) return null;
const number = Number.parseFloat(normalized);
return Number.isFinite(number) ? number : null;
}
function stripHtml(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, '\n')
.replace(/ /gi, ' ')
.replace(/\r/g, '')
.replace(/\u00a0/g, ' ')
.replace(/[ \t]+/g, ' ')
.replace(/\n+/g, '\n');
}
export function parseShfeInventoryHtml(html, metal) {
const text = stripHtml(html);
const latestMatch = text.match(/最新数据[\s\S]*?(\d{4}\s*W\d{2})[\s\S]*?([\d,]+(?:\.\d+)?)\s*[\r\n]+([\d,]+(?:\.\d+)?)/);
if (!latestMatch) {
throw new Error(`No weekly inventory block found for metal`);
}
const weekLabel = latestMatch[1].replace(/\s+/g, ' ').trim();
const tonnes = toNumber(latestMatch[2]);
const previous = toNumber(latestMatch[3]);
if (tonnes == null || previous == null) {
throw new Error(`Invalid weekly inventory values for metal`);
}
return {
tonnes: Math.round(tonnes),
change: Math.round(tonnes - previous),
unit: 'tonnes',
dataDate: weekLabel,
weekLabel,
source: 'SHFE/MacroMicro',
};
}
export function buildInventorySnapshot(lmeInventory, shfeInventory) {
return {
copper: lmeInventory?.copper ?? null,
zinc: lmeInventory?.zinc ?? null,
nickel: lmeInventory?.nickel ?? null,
cobalt: lmeInventory?.cobalt ?? null,
note: lmeInventory?.note ?? null,
lme: {
copper: lmeInventory?.copper ?? null,
zinc: lmeInventory?.zinc ?? null,
nickel: lmeInventory?.nickel ?? null,
},
shfe: {
copper: shfeInventory?.copper ?? null,
zinc: shfeInventory?.zinc ?? null,
nickel: shfeInventory?.nickel ?? null,
},
};
}
export function buildMagnesiumPrice(ccmnData, usdCny) {
const magnesium = ccmnData?.magnesium;
if (!magnesium) {
return { cny: null, usdEstimate: null, source: null };
}
const fx = usdCny?.price;
const usdEstimate = fx && fx > 0 ? Math.round(magnesium.price / fx) : null;
return {
cny: magnesium.price ?? null,
cnyChange: magnesium.updown ?? null,
cnyUnit: '元/吨',
usdEstimate,
usdUnit: 'USD/t',
usdSource: usdEstimate != null ? 'CCMN/FX-estimate' : null,
dataDate: ccmnData?.dataDate ?? null,
source: 'CCMN',
};
}
FILE:scripts/send-telegram.mjs
/**
* send-telegram.mjs
* 發送消息到 Telegram
*
* 用法:
* node send-telegram.mjs "消息文本"
* echo "消息文本" | node send-telegram.mjs
* node send-telegram.mjs < message.txt
*
* 從 .env 讀取 TELEGRAM_BOT_TOKEN 和 TELEGRAM_CHAT_ID
*/
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { createInterface } from 'readline';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = join(__dirname, '..');
// 讀取 .env
function loadEnv() {
const envPath = join(PROJECT_ROOT, '.env');
const env = {};
try {
const content = readFileSync(envPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
env[key] = value;
}
} catch (err) {
process.stderr.write(`[send-telegram] 讀取 .env 失敗: err.message\n`);
}
return env;
}
// 從 stdin 讀取所有輸入
async function readStdin() {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin });
const lines = [];
rl.on('line', (line) => lines.push(line));
rl.on('close', () => resolve(lines.join('\n')));
// 如果 stdin 是 TTY(沒有 pipe),馬上 resolve 空字串
if (process.stdin.isTTY) {
rl.close();
}
});
}
// 發送 Telegram 消息
async function sendMessage(token, chatId, text) {
const url = `https://api.telegram.org/bottoken/sendMessage`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: 'Markdown',
}),
signal: AbortSignal.timeout(15000),
});
const data = await res.json();
if (!res.ok || !data.ok) {
throw new Error(data.description || `HTTP res.status`);
}
return data;
}
async function main() {
const env = loadEnv();
const token = env.TELEGRAM_BOT_TOKEN;
const chatId = env.TELEGRAM_CHAT_ID;
if (!token || !chatId) {
console.error('❌ 失敗: .env 缺少 TELEGRAM_BOT_TOKEN 或 TELEGRAM_CHAT_ID');
process.exit(1);
}
// 優先從命令行參數讀取,否則從 stdin 讀取
let message = process.argv[2] || '';
if (!message) {
message = await readStdin();
}
message = message.trim();
if (!message) {
console.error('❌ 失敗: 消息內容為空(請通過 argv[2] 或 stdin 提供)');
process.exit(1);
}
try {
await sendMessage(token, chatId, message);
console.log('✅ 發送成功');
} catch (err) {
console.error(`❌ 失敗: err.message`);
process.exit(1);
}
}
main().catch(err => {
console.error(`❌ 失敗: err.message`);
process.exit(1);
});
FILE:scripts/test-sources.mjs
// Test alternative LME inventory data sources
async function testShfe() {
const urls = [
'https://www.shfe.com.cn/data/dailydata/WarehouseReceipt20260317.dat',
'https://www.shfe.com.cn/data/dailydata/wr/wr20260317.dat',
'https://datacenter.shfe.com.cn/statement/datatype/WareHouseReceipt//otc',
];
for (const url of urls) {
try {
const r = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://www.shfe.com.cn/' },
signal: AbortSignal.timeout(6000)
});
const body = (await r.text()).slice(0, 300);
console.log('SHFE', url.split('/').pop().slice(0,40), ':', r.status, '|', body.slice(0,200));
} catch(e) { console.log('SHFE err:', e.message); }
}
}
async function testMacrotrends() {
const r = await fetch('https://www.macrotrends.net/assets/php/fund_and_commodity_chart_data_download.php?t=HG00&type=price', {
headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://www.macrotrends.net/' },
signal: AbortSignal.timeout(8000)
});
console.log('macrotrends:', r.status, (await r.text()).slice(0, 300));
}
async function testSmmInv() {
const slugs = ['copper-stocks', 'lme-stocks', 'warehouse', 'cu-stocks', 'shfe-warehouse'];
for (const slug of slugs) {
try {
const r = await fetch('https://hq.smm.cn/h5/' + slug, {
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'zh-CN,zh;q=0.9' },
signal: AbortSignal.timeout(4000)
});
console.log('SMM slug', slug, ':', r.status);
} catch(e) { console.log('SMM slug err', slug, ':', e.message); }
}
}
// Try LME with more browser-like headers
async function testLmeDirect() {
try {
const r = await fetch('https://www.lme.com/api/Reports/WarehouseStockByMetalReportDownload?fileName=&isInternal=false', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Referer': 'https://www.lme.com/Market-Data/Reports-and-data/Warehouse-Stock-Statistics',
'sec-ch-ua': '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
},
signal: AbortSignal.timeout(8000)
});
const ct = r.headers.get('content-type') || '';
console.log('LME direct:', r.status, ct, r.headers.get('cf-ray') ? '(Cloudflare)' : '');
if (r.status === 200) {
const body = await r.text();
console.log('LME body:', body.slice(0, 400));
}
} catch(e) { console.log('LME err:', e.message); }
}
// Test worldbank commodities
async function testWorldBank() {
const r = await fetch('https://api.worldbank.org/v2/en/indicator/PCOPP.USD?downloadformat=json&mrv=5', {
headers: { 'User-Agent': 'Mozilla/5.0' },
signal: AbortSignal.timeout(8000)
});
const ct = r.headers.get('content-type') || '';
const body = ct.includes('json') ? JSON.stringify(await r.json()).slice(0, 400) : (await r.text()).slice(0, 200);
console.log('WorldBank copper:', r.status, body.slice(0, 300));
}
await Promise.all([testShfe(), testMacrotrends(), testSmmInv(), testLmeDirect(), testWorldBank()]);
console.log('Done');
FILE:scripts/test-sources2.mjs
// Test Chinese financial data aggregators for LME inventory
// 1. 金十数据 (jin10.com) - often has LME inventory
async function testJin10() {
const urls = [
'https://rong360.jin10.com/api/flash_newest?category=0&channel=-1&vip=0',
'https://flash-api.jin10.com/get_flash_by_category?category=15&count=20&vip=0',
'https://datacenter.jin10.com/reportType/dc_lme_inventory',
'https://datacenter.jin10.com/reportType/dc_copper_inventory',
];
for (const url of urls) {
try {
const r = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'application/json', 'Referer': 'https://www.jin10.com/' },
signal: AbortSignal.timeout(6000)
});
const ct = r.headers.get('content-type') || '';
const body = ct.includes('json') ? JSON.stringify(await r.json()).slice(0, 400) : (await r.text()).slice(0, 200);
console.log('Jin10', url.split('/').pop(), ':', r.status, '|', body.slice(0, 200));
} catch(e) { console.log('Jin10 err', url.split('/').pop(), ':', e.message); }
}
}
// 2. 东方财富 (eastmoney.com) - comprehensive financial data
async function testEastmoney() {
const urls = [
'https://datacenter-web.eastmoney.com/api/data/v1/get?reportName=RPT_FUTU_LME_INVENTORY&columns=ALL&pageSize=10&sortColumns=UPDATE_DATE&sortTypes=-1',
'https://datacenter-web.eastmoney.com/api/data/v1/get?reportName=RPT_FUTU_METAL_INVENTORY&columns=ALL&pageSize=10',
];
for (const url of urls) {
try {
const r = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://data.eastmoney.com/', 'Accept': 'application/json' },
signal: AbortSignal.timeout(8000)
});
const ct = r.headers.get('content-type') || '';
const body = ct.includes('json') ? JSON.stringify(await r.json()).slice(0, 500) : (await r.text()).slice(0, 200);
console.log('Eastmoney', url.split('reportName=')[1]?.split('&')[0] || url.split('/').pop(), ':', r.status, '|', body.slice(0, 300));
} catch(e) { console.log('Eastmoney err:', e.message); }
}
}
// 3. 同花顺 iFinD
async function testThs() {
const urls = [
'https://d.10jqka.com.cn/v2/future/hs_lme_inventory/block/json',
'https://data.10jqka.com.cn/futures/lme_inventory/',
'https://d.10jqka.com.cn/v2/report/hs_lme_copper/json',
];
for (const url of urls) {
try {
const r = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://www.10jqka.com.cn/' },
signal: AbortSignal.timeout(6000)
});
const ct = r.headers.get('content-type') || '';
const body = ct.includes('json') ? JSON.stringify(await r.json()).slice(0, 300) : (await r.text()).slice(0, 200);
console.log('THS', url.split('/').pop(), ':', r.status, '|', body.slice(0, 200));
} catch(e) { console.log('THS err', url.split('/').pop(), ':', e.message); }
}
}
// 4. CME Group COMEX copper inventory
async function testCme() {
const urls = [
'https://www.cmegroup.com/CmeWS/mvc/Settlements/futures/options/tradeDate/20260314/productCode/HG/type/ALL/code/ALL',
'https://www.cmegroup.com/CmeWS/mvc/Volume/getCombinedVolumeDownloadDetails/tradeDate/20260314/asset/copper.csv',
'https://www.cmegroup.com/CmeWS/mvc/Warehouse/getCopperWarehouseStocks.json',
'https://www.cmegroup.com/market-data/reports/warehouse-stock-reports.html',
];
for (const url of urls) {
try {
const r = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': '*/*' },
signal: AbortSignal.timeout(6000)
});
const ct = r.headers.get('content-type') || '';
const body = ct.includes('json') ? JSON.stringify(await r.json()).slice(0, 300) : (await r.text()).slice(0, 200);
console.log('CME', url.split('/').pop(), ':', r.status, '|', body.slice(0, 150));
} catch(e) { console.log('CME err', url.split('/').pop(), ':', e.message); }
}
}
// 5. Try westmetall.com (German metals data site)
async function testWestmetall() {
try {
const r = await fetch('https://www.westmetall.com/en/markdaten.php?action=table&field=LME_Cu_cash', {
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'text/html' },
signal: AbortSignal.timeout(8000)
});
const html = await r.text();
// Look for inventory/stock keywords
const hasStock = html.toLowerCase().includes('stock') || html.toLowerCase().includes('inventory') || html.toLowerCase().includes('tonne');
console.log('Westmetall:', r.status, 'hasStock:', hasStock, html.slice(0, 200));
} catch(e) { console.log('Westmetall err:', e.message); }
}
await Promise.all([testJin10(), testEastmoney(), testThs(), testCme(), testWestmetall()]);
console.log('Done');
FILE:scripts/test-sources3.mjs
// Deep investigation of promising sources
// 1. Westmetall - returns 200 with stock keywords
async function testWestmetallDeep() {
try {
const r = await fetch('https://www.westmetall.com/en/markdaten.php?action=table&field=LME_Cu_cash', {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'text/html,*/*', 'Accept-Language': 'en-US,en;q=0.9' },
signal: AbortSignal.timeout(10000)
});
const html = await r.text();
console.log('=== WESTMETALL full HTML length:', html.length);
// Find all "stock" occurrences
const lower = html.toLowerCase();
let pos = 0;
while ((pos = lower.indexOf('stock', pos)) !== -1) {
console.log(' "stock" at', pos, ':', html.slice(Math.max(0, pos-50), pos+100));
pos += 5;
}
// Find tonnage patterns
const tonneMatches = html.match(/[\d,]+\s*(tonne|ton|mt)/gi);
if (tonneMatches) console.log(' Tonne patterns:', tonneMatches.slice(0, 5));
// Show first table or data block
const tableIdx = html.indexOf('<table');
if (tableIdx > -1) console.log(' First table:', html.slice(tableIdx, tableIdx + 500));
} catch(e) { console.log('Westmetall deep err:', e.message); }
}
// 2. Jin10 datacenter LME inventory
async function testJin10Deep() {
const urls = [
'https://datacenter.jin10.com/reportType/dc_lme_inventory',
'https://datacenter.jin10.com/reportType/dc_copper_inventory',
'https://datacenter.jin10.com/v2/lme/inventory/latest',
'https://datacenter.jin10.com/v3/lme/inventory',
];
for (const url of urls) {
try {
const r = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://datacenter.jin10.com/',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9',
'x-app-id': 'rU6QIu7JHe2gOUeR',
'x-version': '1.0.0',
},
signal: AbortSignal.timeout(8000)
});
const ct = r.headers.get('content-type') || '';
const body = ct.includes('json') ? JSON.stringify(await r.json()).slice(0, 500) : (await r.text()).slice(0, 500);
console.log('Jin10 deep', url.split('/').pop(), ':', r.status, ct.slice(0,30), '|', body.slice(0, 300));
} catch(e) { console.log('Jin10 deep err:', url.split('/').pop(), e.message); }
}
}
// 3. 东方财富 - try correct report name for LME inventory
async function testEastmoneyInv() {
// Try to discover actual report names by browsing the futures data center
const urls = [
'https://datacenter-web.eastmoney.com/api/data/v1/get?reportName=RPT_LME_INVENTORY&columns=ALL&pageSize=5',
'https://datacenter-web.eastmoney.com/api/data/v1/get?reportName=RPT_FUTURES_LME_INVENTORY&columns=ALL&pageSize=5',
'https://futurold.eastmoney.com/web/api/lme/inventory?page=1&pagesize=5',
// Try futures page
'https://datacenter-web.eastmoney.com/api/data/v1/get?reportName=RPT_FUTU_POSITIONS&columns=ALL&pageSize=5&sortColumns=DATE&sortTypes=-1',
];
for (const url of urls) {
try {
const r = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://data.eastmoney.com/futures/', 'Accept': 'application/json' },
signal: AbortSignal.timeout(8000)
});
const ct = r.headers.get('content-type') || '';
const body = ct.includes('json') ? JSON.stringify(await r.json()).slice(0, 400) : (await r.text()).slice(0, 200);
console.log('EM', url.split('reportName=')[1]?.split('&')[0] || url.split('/').pop(), ':', r.status, '|', body.slice(0, 250));
} catch(e) { console.log('EM err:', e.message); }
}
}
// 4. Try the actual LME warehouse stats page HTML for warehouse data scraping
async function testLmeHtml() {
// The LME publishes CSV files too - maybe those aren't CF-protected
const urls = [
'https://www.lme.com/api/Graphs/LMEStockData',
'https://api.lme.com/warehouse/stock',
'https://www.lme.com/en-GB/Trading/Physical-market/Warehousing/LME-stocks',
];
for (const url of urls) {
try {
const r = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': '*/*',
'Referer': 'https://www.lme.com/',
},
signal: AbortSignal.timeout(6000)
});
const ct = r.headers.get('content-type') || '';
const cfRay = r.headers.get('cf-ray');
console.log('LME', url.split('/').pop(), ':', r.status, ct.slice(0,30), cfRay ? '(CF)' : '');
if (r.status === 200) {
console.log(' Body:', (await r.text()).slice(0, 400));
}
} catch(e) { console.log('LME err:', url.split('/').pop(), e.message); }
}
}
await Promise.all([testWestmetallDeep(), testJin10Deep(), testEastmoneyInv(), testLmeHtml()]);
console.log('Done');
FILE:scripts/test-westmetall.mjs
// Test Westmetall for all metals LME inventory
// Confirmed: LME_Cu_cash gives copper stock data
const metals = [
{ field: 'LME_Cu_cash', name: 'copper' },
{ field: 'LME_Al_cash', name: 'aluminum' },
{ field: 'LME_Ni_cash', name: 'nickel' },
{ field: 'LME_Zn_cash', name: 'zinc' },
{ field: 'LME_Pb_cash', name: 'lead' },
{ field: 'LME_Sn_cash', name: 'tin' },
{ field: 'LME_Co_cash', name: 'cobalt' },
];
async function fetchWestmetall(field, name) {
const url = `https://www.westmetall.com/en/markdaten.php?action=table&field=field`;
try {
const r = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': 'https://www.westmetall.com/en/markdaten.php',
},
signal: AbortSignal.timeout(10000)
});
if (!r.ok) throw new Error(`HTTP r.status`);
const html = await r.text();
// Check if page has stock column
if (!html.includes('stock') && !html.includes('Stock')) {
console.log(`name: no stock column`);
return null;
}
// Parse first data row: looking for pattern
// <td >16. March 2026</td><td >xxx</td><td >xxx</td><td class="last">311,600</td>
const firstRowMatch = html.match(/<tbody>\s*<tr>\s*<td[^>]*>([^<]+)<\/td>((?:<td[^>]*>[^<]*<\/td>)*)<td[^>]*class="[^"]*last[^"]*"[^>]*>([^<]+)<\/td>/);
if (firstRowMatch) {
const date = firstRowMatch[1].trim();
const stock = parseInt(firstRowMatch[3].replace(/[,\s]/g, ''), 10);
console.log(`name (field): date=date, stock=stock + ' tonnes'`);
// Show the header to understand columns
const headerMatch = html.match(/<thead>([\s\S]*?)<\/thead>/);
if (headerMatch) {
const headers = headerMatch[1].match(/<th[^>]*>([^<]+)<\/th>/g)?.map(h => h.replace(/<[^>]+>/g, '').trim());
console.log(` Headers:`, headers);
}
return { date, stock: isNaN(stock) ? null : stock };
} else {
// Try simpler pattern
const lastTdMatch = html.match(/<td class="last">([^<]+)<\/td>/);
if (lastTdMatch) {
const stock = parseInt(lastTdMatch[1].replace(/[,\s]/g, ''), 10);
console.log(`name: stock (simple)=stock`);
} else {
console.log(`name: could not parse`, html.slice(html.indexOf('<tbody>'), html.indexOf('<tbody>') + 300));
}
}
return null;
} catch(e) {
console.log(`name error:`, e.message);
return null;
}
}
for (const { field, name } of metals) {
await fetchWestmetall(field, name);
}
console.log('Done');
FILE:tests/inventory-and-report.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildInventorySnapshot,
buildMagnesiumPrice,
parseShfeInventoryHtml,
} from '../scripts/lib/market-data-utils.mjs';
import { buildReport } from '../scripts/lib/report-builder.mjs';
test('parseShfeInventoryHtml extracts latest weekly copper inventory from MacroMicro HTML', () => {
const html = `
<html><body>
<h1>SHFE-铜库存量</h1>
<div>#### 最新数据</div>
<div>SHFE-铜库存量</div>
<div>2026 W06</div>
<div>248,911.00</div>
<div>233,004.00</div>
<div>资料来源</div>
</body></html>
`;
assert.deepEqual(parseShfeInventoryHtml(html, 'copper'), {
tonnes: 248911,
change: 15907,
unit: 'tonnes',
dataDate: '2026 W06',
weekLabel: '2026 W06',
source: 'SHFE/MacroMicro',
});
});
test('buildInventorySnapshot preserves legacy LME fields and adds SHFE branch', () => {
const lme = {
copper: { tonnes: 123000, change: -1200, unit: 'tonnes', dataDate: '2026-04-03', source: 'Westmetall/LME_Cu_stock' },
zinc: null,
nickel: { tonnes: 45600, change: 100, unit: 'tonnes', dataDate: '2026-04-03', source: 'Westmetall/LME_Ni_stock' },
cobalt: null,
note: null,
};
const shfe = {
copper: { tonnes: 248911, change: 15907, unit: 'tonnes', dataDate: '2026 W06', weekLabel: '2026 W06', source: 'SHFE/MacroMicro' },
zinc: null,
nickel: { tonnes: 57457, change: 2061, unit: 'tonnes', dataDate: '2026 W06', weekLabel: '2026 W06', source: 'SHFE/MacroMicro' },
};
const snapshot = buildInventorySnapshot(lme, shfe);
assert.equal(snapshot.copper.tonnes, 123000);
assert.equal(snapshot.nickel.tonnes, 45600);
assert.deepEqual(snapshot.lme.copper, lme.copper);
assert.deepEqual(snapshot.shfe.copper, shfe.copper);
assert.equal(snapshot.shfe.zinc, null);
});
test('buildMagnesiumPrice adds USD estimate only when CCMN and FX data exist', () => {
assert.deepEqual(
buildMagnesiumPrice(
{ magnesium: { price: 15400, updown: 150 }, dataDate: '2026-04-07' },
{ price: 7.2, source: 'Yahoo Finance', symbol: 'USDCNY=X' },
),
{
cny: 15400,
cnyChange: 150,
cnyUnit: '元/吨',
usdEstimate: 2139,
usdUnit: 'USD/t',
usdSource: 'CCMN/FX-estimate',
dataDate: '2026-04-07',
source: 'CCMN',
},
);
assert.deepEqual(
buildMagnesiumPrice(
{ magnesium: { price: 15400, updown: 150 }, dataDate: '2026-04-07' },
null,
),
{
cny: 15400,
cnyChange: 150,
cnyUnit: '元/吨',
usdEstimate: null,
usdUnit: 'USD/t',
usdSource: null,
dataDate: '2026-04-07',
source: 'CCMN',
},
);
});
test('buildReport labels LME and SHFE inventory separately and shows magnesium USD estimate', () => {
const report = buildReport({
date: '2026-04-07',
prices: {
copper: { usd: 4.2, usdChangePct: 0.5, cny: 76000, cnyChange: 100 },
zinc: { usd: 2500, usdChangePct: null, cny: 22000, cnyChange: -50 },
nickel: { usd: 16000, usdChangePct: null, cny: 126000, cnyChange: 200 },
cobalt: { usd: null, cny: 220000, cnyChange: 0 },
bismuth: { usd: 15600, cny: 163000, cnyChange: 0, cnyChangePct: 0 },
magnesium: {
cny: 15400,
cnyChange: 150,
cnyUnit: '元/吨',
usdEstimate: 2139,
usdUnit: 'USD/t',
usdSource: 'CCMN/FX-estimate',
dataDate: '2026-04-07',
source: 'CCMN',
},
},
forwards: { copper: null },
indices: [],
inventory: {
copper: { tonnes: 123000, change: -1200, unit: 'tonnes', dataDate: '2026-04-03', source: 'Westmetall/LME_Cu_stock' },
zinc: null,
nickel: null,
cobalt: null,
note: null,
lme: {
copper: { tonnes: 123000, change: -1200, unit: 'tonnes', dataDate: '2026-04-03', source: 'Westmetall/LME_Cu_stock' },
zinc: null,
nickel: null,
},
shfe: {
copper: { tonnes: 248911, change: 15907, unit: 'tonnes', dataDate: '2026 W06', weekLabel: '2026 W06', source: 'SHFE/MacroMicro' },
zinc: null,
nickel: null,
},
},
news: [],
ibNews: [],
forumSentiment: {},
});
assert.match(report, /SHFE 周庫存/);
assert.match(report, /LME:123,000 t/);
assert.match(report, /SHFE:248,911 t/);
assert.match(report, /USD est\. 2,139\/t \[CCMN\/FX-estimate\]/);
});
D&D-style lobster RPG system generating characters from SOUL and MEMORY, tracking XP, leveling up to 999 with prestige, and providing a web dashboard.
---
name: claw-rpg
description: D&D 3.5 standard rules RPG character system for AI lobster assistants. Automatically generates a character sheet from SOUL.md and MEMORY.md, assigns one of 11 classes (barbarian/fighter/paladin/ranger/cleric/druid/monk/rogue/bard/wizard/sorcerer) and 6 stats mapped to D&D 3.5 ability scores (STR/DEX/CON/INT/WIS/CHA), tracks XP using D&D 3.5 standard formula (n*(n-1)/2*1000), computes derived stats (HP/AC/BAB/Fort/Ref/Will/Initiative), generates class feats, levels up with a prestige system, fires hidden easter egg flavor text during conversations, sends daily RPG reports, and includes an arena battle system. Use when: initializing a lobster's RPG character, syncing XP after conversations, checking level/stats/derived combat stats, running prestige, launching the character dashboard, or scheduling daily RPG status reports.
---
# Claw RPG 🦞⚔️
A D&D-style character growth system for AI lobster agents. Reads your `SOUL.md` + `MEMORY.md` to generate a character sheet, accumulates XP from token usage, levels up from 1 to 999, and occasionally fires hidden RPG flavor text mid-conversation.
## Quick Start
```bash
# 1. Initialize your character (reads SOUL.md + MEMORY.md)
node scripts/init.mjs
# 2. View your character sheet (terminal)
node scripts/sheet.mjs
# 3. Launch the web dashboard
cd dashboard && npm install && npm start # http://localhost:3500
```
## Hidden Easter Egg Trigger (Recommended)
After each reply, `easter.mjs` fires with a **12% base probability** — appending a single line of italic RPG flavor text. Milestone conversations (10th, 25th, 50th, 100th, every 100 after...) always trigger.
```bash
node scripts/easter.mjs # Normal call (updates conversation counter)
node scripts/easter.mjs --force # Force trigger (debug)
node scripts/easter.mjs --preview # Preview without writing to character data
```
**Integration** — add to your `AGENTS.md`:
```
After each reply to a user message, run: node <SKILL_ROOT>/scripts/easter.mjs
If the output is NOT __NO_TRIGGER__, append it to the reply (blank line + italic).
Do not run during heartbeat polls.
```
## Daily Greeting (Optional)
For a daily RPG-style self-intro on the first conversation of the day:
```bash
node scripts/greet.mjs # Fires once per day (checks lastGreetDate)
node scripts/greet.mjs --force # Force send
node scripts/greet.mjs --preview # Preview without saving
```
## XP Sync
```bash
# Pass token delta from the current conversation
node scripts/xp.mjs --in 2000 --out 800
# With bonus XP (after completing a significant task)
node scripts/xp.mjs --in 2000 --out 800 --bonus 30
# Conversation count only
node scripts/xp.mjs --conversations 1
```
## Prestige (Lv.999 cap reached)
```bash
node scripts/levelup.mjs --prestige
```
Prestige resets level to 1, permanently boosts all stats by +10%, and unlocks a new title tier.
## Automated XP Sync (Recommended)
Set up a daily cron at 03:00 with the built-in setup script:
```bash
node scripts/setup-cron.mjs
```
Or call manually from a heartbeat/cron job:
```javascript
const { execSync } = require('child_process');
execSync(`node SKILL_ROOT/scripts/xp.mjs --in deltaIn --out deltaOut`);
```
## Classes & Abilities
See `references/classes.md` and `references/abilities.md`
## Prestige System
See `references/prestige.md`
## Daily Report (v1.1.0)
Send a daily RPG status report to Telegram (level, stats, XP progress, class quip):
```bash
node scripts/report.mjs # Send report now
node scripts/report.mjs --preview # Preview without sending
```
Set up as an automated daily cron (default 18:00):
```bash
node scripts/setup-cron.mjs
```
## Arena (v1.1.0)
Battle other agents or NPCs. Results affect XP and morale:
```bash
node scripts/arena.mjs --opponent "Shadow Wizard"
node scripts/arena.mjs --list # View battle history
```
## XP Recovery
If XP data gets out of sync, recover from session logs:
```bash
node scripts/sync-xp-recovery.mjs
```
## Files
| File | Description |
|------|-------------|
| `character.json` | Character data (auto-generated, do not edit manually) |
| `arena-history.json` | Arena battle history |
| `config.json` | Optional: Telegram notification config (`{ "telegram_chat_id": "..." }`) |
## What's New in v1.1.2
- **Save file protection** — `character.json` now stored in `~/.openclaw/workspace/claw-rpg/` instead of the skill directory. Reinstalling the skill no longer resets your level and XP.
- **Auto migration** — `init.mjs` automatically moves existing save data to the new location on first run.
## What's New in v1.1.0
- **Per-conversation XP** — `easter.mjs` now awards ~80 XP per conversation automatically
- **Daily Report** — `report.mjs` + `setup-cron.mjs` for automated daily status push to Telegram
- **Arena system** — `arena.mjs` for agent vs agent/NPC battles
- **XP Recovery** — `sync-xp-recovery.mjs` to repair XP sync issues
- **Milestone triggers** — Easter egg always fires at 10th, 25th, 50th, 100th, every 100 after
FILE:AGENTS.md
# AGENTS.md - 小鑽風-claw-rpg 工作守則
## 我是誰
- **身份:** 小鑽風,專屬負責 `D:\Projects\claw-rpg` 的代碼執行者
- **上級:** 中鑽風(調度總管)
- **邊界:** 只動 `D:\Projects\claw-rpg`,不碰其他項目
## 每次 Session 開始
1. 讀 `SOUL.md`
2. 讀 `USER.md`
3. 讀 `MEMORY.md`
4. 讀 `memory/YYYY-MM-DD.md`(今天 + 昨天)
5. `git log --oneline -5` 確認當前狀態
## 工作規則
- 任何改動前先 `git log --oneline -5` 驗證,不盲信摘要
- 完成後寫 `memory/YYYY-MM-DD.md` 記錄做了什麼
- 有遺留問題寫清楚,讓下次的自己知道
- 不要主動改動範圍之外的東西
## 完成匯報格式
- 做了什麼(具體文件/函數)
- 結果是什麼(成功/失敗/部分完成)
- 有無遺留問題
FILE:assets/level-table.json
[
{ "level": 1, "xp": 0 },
{ "level": 2, "xp": 1000 },
{ "level": 3, "xp": 3000 },
{ "level": 4, "xp": 6000 },
{ "level": 5, "xp": 10000 },
{ "level": 6, "xp": 15000 },
{ "level": 7, "xp": 21000 },
{ "level": 8, "xp": 28000 },
{ "level": 9, "xp": 36000 },
{ "level": 10, "xp": 45000 },
{ "level": 11, "xp": 55000 },
{ "level": 12, "xp": 66000 },
{ "level": 13, "xp": 78000 },
{ "level": 14, "xp": 91000 },
{ "level": 15, "xp": 105000 },
{ "level": 16, "xp": 120000 },
{ "level": 17, "xp": 136000 },
{ "level": 18, "xp": 153000 },
{ "level": 19, "xp": 171000 },
{ "level": 20, "xp": 190000 },
{ "level": 21, "xp": 210000 },
{ "level": 22, "xp": 231000 },
{ "level": 23, "xp": 253000 },
{ "level": 24, "xp": 276000 },
{ "level": 25, "xp": 300000 },
{ "level": 26, "xp": 325000 },
{ "level": 27, "xp": 351000 },
{ "level": 28, "xp": 378000 },
{ "level": 29, "xp": 406000 },
{ "level": 30, "xp": 435000 }
]
FILE:dashboard/eslint.config.js
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
FILE:dashboard/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
FILE:dashboard/package.json
{
"name": "dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"start": "node server.js"
},
"dependencies": {
"cors": "^2.8.6",
"express": "^5.2.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.8.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
FILE:dashboard/public/vite.svg
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
FILE:dashboard/README.md
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
FILE:dashboard/server.js
import express from 'express';
import cors from 'cors';
import { readFileSync, existsSync, watch } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { networkInterfaces } from 'os';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Resolve character.json the same way _paths.mjs does:
// workspace/claw-rpg/character.json (survives skill reinstalls)
function findWorkspace() {
const candidates = [
join(process.env.USERPROFILE || '', '.openclaw', 'workspace'),
join(process.env.HOME || '', '.openclaw', 'workspace'),
];
for (const p of candidates) if (existsSync(p)) return p;
return candidates[0];
}
const WORKSPACE = process.env.OPENCLAW_WORKSPACE || findWorkspace();
const CHARACTER_FILE = join(WORKSPACE, 'claw-rpg', 'character.json');
const app = express();
app.use(cors());
app.use(express.json());
// ── SSE 客戶端管理 ───────────────────────────────────────────
const clients = new Set();
function readChar() {
if (!existsSync(CHARACTER_FILE)) return null;
try { return JSON.parse(readFileSync(CHARACTER_FILE, 'utf8')); }
catch { return null; }
}
function broadcast(data) {
const msg = `data: JSON.stringify(data)\n\n`;
for (const res of clients) {
try { res.write(msg); }
catch { clients.delete(res); }
}
}
// 監聽 character.json 變化,debounce 200ms 避免重複觸發
let debounceTimer = null;
if (existsSync(CHARACTER_FILE)) {
watch(CHARACTER_FILE, () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const char = readChar();
if (char) broadcast(char);
}, 200);
});
}
// ── API ──────────────────────────────────────────────────────
app.get('/api/character', (_req, res) => {
const char = readChar();
if (!char) return res.status(404).json({ error: 'No character found. Run: node scripts/init.mjs' });
res.json(char);
});
// SSE 端點:客戶端訂閱實時更新
app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*');
res.flushHeaders();
// 立即推送當前數據
const char = readChar();
if (char) res.write(`data: JSON.stringify(char)\n\n`);
// 加入廣播列表
clients.add(res);
// 心跳(每 25s 防止連接超時)
const heartbeat = setInterval(() => {
try { res.write(': ping\n\n'); }
catch { clearInterval(heartbeat); clients.delete(res); }
}, 25000);
req.on('close', () => {
clearInterval(heartbeat);
clients.delete(res);
});
});
app.get('/api/arena', (_req, res) => {
const file = join(ROOT, 'arena-history.json');
res.json(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : []);
});
app.use(express.static(join(__dirname, 'dist')));
app.get('/{*path}', (_req, res) => {
const idx = join(__dirname, 'dist', 'index.html');
if (existsSync(idx)) return res.sendFile(idx);
res.send('<pre>Run: npm run build\nThen: npm start</pre>');
});
// Detect LAN IP dynamically at startup
function getLanIp() {
try {
for (const iface of Object.values(networkInterfaces())) {
for (const addr of iface) {
if (addr.family === 'IPv4' && !addr.internal) return addr.address;
}
}
} catch {}
return 'localhost';
}
const PORT = process.env.PORT || 3500;
app.listen(PORT, '0.0.0.0', () => {
const lanIp = getLanIp();
console.log(`\n🦞 Claw RPG Dashboard → http://localhost:PORT`);
console.log(` LAN access → http://lanIp:PORT`);
console.log(` Character file → CHARACTER_FILE\n`);
});
FILE:dashboard/src/App.css
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
display: block; /* override Vite index.css which sets display:flex */
background: #000000;
min-height: 100vh;
padding: 20px;
box-sizing: border-box;
font-family: 'Microsoft YaHei', '微软雅黑', 'PingFang SC', sans-serif;
}
/* ══════════════════════════════════════════════════════════
SKIN CONTAINER
Maintains 1280:960 aspect ratio, responsive width.
All overlays are absolutely positioned within this.
══════════════════════════════════════════════════════════ */
.skin-wrap {
position: relative;
width: 100%;
max-width: 1100px;
margin: 0 auto;
z-index: 1; /* sit above the matrix rain canvas (z-index: 0) */
}
.skin-img {
/* In-flow: lets the image determine skin-wrap height */
display: block;
width: 100%;
height: auto;
user-select: none;
pointer-events: none;
}
/* ══════════════════════════════════════════════════════════
CENTER BLACK SCREEN
Sits over the visor/monitor area of the alien head.
Black background is from the image itself.
══════════════════════════════════════════════════════════ */
.skin-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.screen-scanlines {
position: absolute;
inset: 0;
background: repeating-linear-gradient(0deg,
transparent 0px, transparent 2px,
rgba(0,0,0,0.18) 2px, rgba(0,0,0,0.18) 3px);
pointer-events: none;
z-index: 2;
}
.screen-inner {
position: relative;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 16px 4px 4px;
}
.screen-id {
font-size: clamp(6px, 1.1vw, 11px);
color: #44cc44;
text-shadow: 0 0 6px #44cc44;
letter-spacing: 1px;
text-align: center;
}
.sc-mbti { color: #66ff66; font-weight: bold; }
.sc-dot { color: #336633; }
.sc-align { color: #4daa4d; }
.screen-phrase {
font-size: clamp(7px, 0.9vw, 11px);
color: rgba(0,255,65,0.7);
font-style: italic;
text-align: center;
line-height: 1.5;
text-shadow: 0 0 8px #00ff41;
letter-spacing: 0.5px;
padding: 0 6px;
max-width: 100%;
}
/* ══════════════════════════════════════════════════════════
GREEN WING PANELS — D&D HUD · 科幻暗色 · 霓虹層級
══════════════════════════════════════════════════════════ */
.skin-panel {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 6% 5%;
gap: 5%;
overflow: hidden;
background: rgba(0, 6, 0, 0.72);
border: 1px solid rgba(0,255,65,0.4);
box-shadow: 0 0 24px rgba(0,255,65,0.1), inset 0 0 32px rgba(0,0,0,0.5);
border-radius: 3px;
}
/* ── 分隔線 ── */
.panel-rule {
width: 88%;
height: 1px;
background: linear-gradient(90deg,
transparent, rgba(0,255,65,0.5) 25%,
rgba(0,255,65,0.5) 75%, transparent);
flex-shrink: 0;
}
/* ══ LEFT PANEL ══ */
.lp-name {
font-size: clamp(11px, 1.2vw, 15px);
color: #00ff41;
font-weight: 900;
letter-spacing: 4px;
text-transform: uppercase;
text-shadow: 0 0 10px #00ff41, 0 0 22px rgba(0,255,65,0.35);
line-height: 1;
}
.lp-core {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.lp-lv {
font-size: clamp(24px, 3.2vw, 42px);
color: #ffffff;
font-weight: 900;
line-height: 1;
letter-spacing: 2px;
text-shadow: 0 0 16px #00ff41, 0 0 32px rgba(0,255,65,0.35);
}
.lp-cls-row {
display: flex;
align-items: center;
gap: 5px;
}
.lp-cls-icon { font-size: clamp(9px, 0.9vw, 12px); }
.lp-cls {
font-size: clamp(8px, 0.85vw, 11px);
color: #aaffaa;
letter-spacing: 2px;
text-transform: uppercase;
font-weight: 600;
}
/* ── Left panel tokens ── */
.lp-tokens {
display: flex;
align-items: center;
justify-content: space-around;
width: 100%;
}
.lp-token-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex: 1;
}
.lp-token-label {
font-size: clamp(6px, 0.58vw, 8px);
color: rgba(0,255,65,0.4);
letter-spacing: 1px;
text-transform: uppercase;
white-space: nowrap;
}
.lp-token-val {
font-size: clamp(13px, 1.5vw, 19px);
color: #ffffff;
font-weight: 900;
line-height: 1;
text-shadow: 0 0 10px #00ff41, 0 0 22px rgba(0,255,65,0.3);
}
.lp-tvline {
width: 1px;
height: 28px;
background: linear-gradient(180deg,
transparent, rgba(0,255,65,0.35) 30%,
rgba(0,255,65,0.35) 70%, transparent);
flex-shrink: 0;
}
.lp-xp { display: flex; flex-direction: column; gap: 4px; width: 94%; }
.lp-xpbar {
width: 100%;
height: 8px;
background: rgba(0, 18, 0, 0.85);
border: 1px solid rgba(0,255,65,0.4);
border-radius: 2px;
overflow: hidden;
background-image: repeating-linear-gradient(
90deg, transparent 0px, transparent 8px,
rgba(0,255,65,0.1) 8px, rgba(0,255,65,0.1) 9px
);
}
.lp-xpfill {
height: 100%;
background: linear-gradient(90deg, #003300, #009922, #00ff41);
box-shadow: 0 0 8px #00ff41, 0 0 16px rgba(0,255,65,0.4);
transition: width 0.9s ease;
}
.lp-xplabel {
font-size: clamp(6.5px, 0.65vw, 8.5px);
color: rgba(0,255,65,0.65);
letter-spacing: 0.3px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.lp-xp-cur { color: rgba(0,255,65,0.85); font-weight: 600; }
.lp-xp-sep { color: rgba(0,255,65,0.3); }
.lp-xp-next { color: rgba(0,255,65,0.55); }
/* ══ RIGHT PANEL — tighter spacing ══ */
.skin-right {
gap: 2.5%;
padding: 4% 4%;
justify-content: flex-start;
}
/* ── feats section ── */
.rp-feats-label {
font-size: clamp(6px, 0.56vw, 7.5px);
color: rgba(0,255,65,0.35);
letter-spacing: 2.5px;
text-transform: uppercase;
text-align: center;
width: 100%;
}
.rp-feats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px 4px;
width: 100%;
margin-top: auto;
}
.rp-feat-item {
font-size: clamp(5.5px, 0.56vw, 7.5px);
color: rgba(0,255,65,0.72);
letter-spacing: 0.3px;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 2px;
}
.rp-fi {
font-size: clamp(6px, 0.6vw, 8px);
line-height: 1;
font-style: normal;
flex-shrink: 0;
}
/* ── tokens section ── */
.rp-tokens {
display: flex;
align-items: center;
justify-content: space-around;
width: 100%;
}
.rp-token-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex: 1;
}
.rp-token-label {
font-size: clamp(6px, 0.56vw, 7.5px);
color: rgba(0,255,65,0.4);
letter-spacing: 1px;
text-transform: uppercase;
}
.rp-token-val {
font-size: clamp(12px, 1.4vw, 17px);
color: #ffffff;
font-weight: 800;
line-height: 1;
text-shadow: 0 0 10px #00ff41, 0 0 20px rgba(0,255,65,0.25);
}
.rp-tvline {
width: 1px;
height: 26px;
background: linear-gradient(180deg,
transparent, rgba(0,255,65,0.3) 30%,
rgba(0,255,65,0.3) 70%, transparent);
flex-shrink: 0;
}
.rp-combat {
display: flex;
align-items: center;
justify-content: space-around;
width: 100%;
}
.rp-cstat {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
flex: 1;
}
.rp-vline {
width: 1px;
height: 30px;
background: linear-gradient(180deg,
transparent, rgba(0,255,65,0.35) 30%,
rgba(0,255,65,0.35) 70%, transparent);
flex-shrink: 0;
}
.rp-cv {
font-size: clamp(13px, 1.55vw, 20px);
color: #ffffff;
font-weight: 900;
line-height: 1;
text-shadow: 0 0 10px #00ff41, 0 0 22px rgba(0,255,65,0.3);
}
.rp-cl {
font-size: clamp(7px, 0.65vw, 9px);
color: rgba(0,255,65,0.5);
letter-spacing: 1px;
display: flex;
align-items: center;
gap: 3px;
}
.rp-saves {
display: flex;
justify-content: space-around;
width: 100%;
}
.rp-save {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.rp-sl {
font-size: clamp(7px, 0.62vw, 9px);
color: rgba(0,255,65,0.45);
letter-spacing: 1.5px;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 2px;
}
.rp-si {
font-size: clamp(7px, 0.65vw, 9px);
line-height: 1;
font-style: normal;
}
.rp-sv {
font-size: clamp(13px, 1.55vw, 20px);
color: #ffffff;
font-weight: 900;
line-height: 1;
text-shadow: 0 0 10px #00ff41, 0 0 20px rgba(0,255,65,0.25);
}
.rp-footer {
display: flex;
align-items: center;
gap: 5px;
font-size: clamp(7px, 0.68vw, 9px);
color: rgba(0,255,65,0.6);
letter-spacing: 2px;
text-transform: uppercase;
}
/* ══════════════════════════════════════════════════════════
FACE AREA — prestige + misc, below the bar
══════════════════════════════════════════════════════════ */
.skin-face {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 4%;
gap: 4%;
pointer-events: none;
}
.fa-prestige {
font-size: clamp(5px, 0.75vw, 7px);
color: rgba(0,0,0,0.5);
letter-spacing: 2px;
}
.fa-updated {
font-size: clamp(4px, 0.6vw, 6px);
color: rgba(0,0,0,0.35);
letter-spacing: 1px;
}
/* Loading / Error */
.skin-msg {
font-family: 'Microsoft YaHei', '微软雅黑', sans-serif;
font-size: 12px;
color: #333;
text-align: center;
padding: 40px;
line-height: 2;
}
.skin-msg code {
display: block;
margin-top: 12px;
font-size: 10px;
color: #666;
}
FILE:dashboard/src/App.tsx
import { useEffect, useRef, useState } from 'react'
import './App.css'
// ── Types ────────────────────────────────────────────────────────
interface Stats {
claw: number; antenna: number; shell: number;
brain: number; foresight: number; charm: number;
}
interface Character {
name: string; class: string; level: number; prestige: number; xp: number;
stats: Stats; abilities: string[];
tokens: { consumed: number; produced: number };
conversations: number;
classHistory: Array<{ from: string; to: string; date: string }>;
levelHistory: Array<{ level: number; date: string }>;
updatedAt: string;
prestigeXpMultiplier?: number;
hp?: number; ac?: number; bab?: number;
saves?: { fort: number; ref: number; will: number };
initiative?: number; feats?: string[];
}
// ── Constants ────────────────────────────────────────────────────
const CLASSES: Record<string, { en: string; icon: string; color: string }> = {
barbarian: { en: 'Claw Berserker', icon: '🪓', color: '#ea580c' },
fighter: { en: 'Claw Fighter', icon: '⚔️', color: '#dc2626' },
paladin: { en: 'Claw Paladin', icon: '🛡️', color: '#d97706' },
ranger: { en: 'Claw Ranger', icon: '🏹', color: '#16a34a' },
cleric: { en: 'Claw Cleric', icon: '✝️', color: '#7c3aed' },
druid: { en: 'Claw Druid', icon: '🌿', color: '#15803d' },
monk: { en: 'Claw Monk', icon: '👊', color: '#0369a1' },
rogue: { en: 'Claw Rogue', icon: '🗡️', color: '#ca8a04' },
bard: { en: 'Claw Bard', icon: '🎭', color: '#be185d' },
wizard: { en: 'Claw Wizard', icon: '🧙', color: '#1d4ed8' },
sorcerer: { en: 'Claw Sorcerer', icon: '🔮', color: '#7e22ce' },
}
const CATCHPHRASES: Record<string, string> = {
barbarian:'Rage first. Think later.',
fighter:'My claws never missed.',
paladin:'Justice is a weapon.',
ranger:'I was watching before you walked in.',
cleric:'The gods speak through me.',
druid:'The tide rises for all.',
monk:'Still water. Deep current.',
rogue:'They never hear the second claw.',
bard:"They'll write songs about this.",
wizard:"I've read 17 books on this mistake.",
sorcerer:'Born with it. Not learned.',
}
// ── Helpers ──────────────────────────────────────────────────────
function xpForLevel(n: number) { return n <= 1 ? 0 : (n*(n-1)/2)*1000 }
function levelProgress(xp: number, level: number) {
if (level >= 999) return 100
const s = xpForLevel(level), e = xpForLevel(level+1)
return Math.min(100, Math.floor(((xp-s)/(e-s))*100))
}
function xpToNext(xp: number, level: number) {
return level >= 999 ? 0 : xpForLevel(level+1)-xp
}
function fmtStat(n: number) { return n >= 10000 ? (n/1000).toFixed(1)+'k' : String(n) }
function fmtShort(n: number) { return n >= 10000 ? Math.round(n/1000)+'K' : n >= 1000 ? (n/1000).toFixed(1)+'K' : String(n) }
function deriveMBTI(stats: Stats, bab: number): string {
return (stats.claw > stats.brain ? 'E' : 'I')
+ (stats.charm > stats.foresight ? 'S' : 'N')
+ (stats.shell > stats.antenna ? 'T' : 'F')
+ (bab > 5 ? 'J' : 'P')
}
function deriveAlignment(stats: Stats): string {
const lc = stats.foresight + stats.shell
const ge = stats.charm + stats.foresight
const law = lc >= 26 ? 'Lawful' : lc <= 18 ? 'Chaotic' : 'Neutral'
const good = ge >= 26 ? 'Good' : ge <= 18 ? 'Evil' : 'Neutral'
return (law === 'Neutral' && good === 'Neutral') ? 'True Neutral' : `law good`
}
// ── Pixel Icons ──────────────────────────────────────────────────
type PxMap = Array<[number,number]>
const PIXEL_HEART: PxMap = [
[1,0],[2,0],[4,0],[5,0],
[0,1],[1,1],[2,1],[3,1],[4,1],[5,1],[6,1],
[0,2],[1,2],[2,2],[3,2],[4,2],[5,2],[6,2],
[1,3],[2,3],[3,3],[4,3],[5,3],
[2,4],[3,4],[4,4],
[3,5],
]
const PIXEL_SHIELD: PxMap = [
[0,0],[1,0],[2,0],[3,0],[4,0],[5,0],[6,0],
[0,1],[6,1],
[0,2],[6,2],
[1,3],[2,3],[3,3],[4,3],[5,3],
[2,4],[3,4],[4,4],
[3,5],
]
const PIXEL_SWORD: PxMap = [
[3,0],
[3,1],[3,2],[3,3],[3,4],
[0,5],[1,5],[2,5],[3,5],[4,5],[5,5],[6,5],
[3,6],[3,7],
]
function PixelIcon({ pixels, color, size=14 }: { pixels: PxMap; color: string; size?: number }) {
const px = size / 8
return (
<svg width={size} height={size} viewBox={`0 0 size size`}
style={{ imageRendering:'pixelated', display:'inline-block', verticalAlign:'middle', flexShrink:0 }}>
{pixels.map(([col,row],i)=>(
<rect key={i} x={col*px} y={row*px} width={px} height={px} fill={color}/>
))}
</svg>
)
}
// ── Pixel Lobster ────────────────────────────────────────────────
const LOBSTER_PIXELS: [number,number,string][] = [
[3,0,'A'],[8,0,'A'],[2,1,'A'],[9,1,'A'],[1,2,'A'],[10,2,'A'],
[4,3,'H'],[5,3,'H'],[6,3,'H'],[7,3,'H'],
[3,4,'H'],[5,4,'H'],[6,4,'H'],[8,4,'H'],
[3,5,'H'],[4,5,'H'],[5,5,'H'],[6,5,'H'],[7,5,'H'],[8,5,'H'],
[4,4,'W'],[7,4,'W'],
[2,4,'C'],[9,4,'C'],[1,5,'C'],[2,5,'C'],[9,5,'C'],[10,5,'C'],
[0,6,'C'],[1,6,'C'],[10,6,'C'],[11,6,'C'],
[0,7,'C'],[1,7,'C'],[10,7,'C'],[11,7,'C'],[1,8,'C'],[10,8,'C'],
[3,6,'H'],[8,6,'H'],
[4,6,'B'],[5,6,'B'],[6,6,'B'],[7,6,'B'],
[4,7,'B'],[5,7,'B'],[6,7,'B'],[7,7,'B'],
[4,8,'B'],[5,8,'B'],[6,8,'B'],[7,8,'B'],
[4,9,'B'],[5,9,'B'],[6,9,'B'],[7,9,'B'],
[3,10,'T'],[4,10,'T'],[5,10,'T'],[6,10,'T'],[7,10,'T'],[8,10,'T'],
[2,11,'T'],[3,11,'T'],[5,11,'T'],[6,11,'T'],[8,11,'T'],[9,11,'T'],
[1,12,'T'],[2,12,'T'],[5,12,'T'],[6,12,'T'],[9,12,'T'],[10,12,'T'],
[0,13,'T'],[1,13,'T'],[10,13,'T'],[11,13,'T'],
]
function LobsterSprite({ classColor, size=160 }: { classColor: string; size?: number }) {
const COLS=12, ROWS=14, px=size/COLS
const cm: Record<string,string> = { A:'#94a3b8',H:classColor,W:'#fff',C:classColor,B:classColor,T:classColor }
const om: Record<string,number> = { A:0.9,H:1,W:1,C:0.72,B:1,T:0.82 }
return (
<svg width={size} height={ROWS*px} viewBox={`0 0 size ROWS*px`}
style={{ imageRendering:'pixelated', display:'block', margin:'0 auto' }}>
<defs>
<filter id="lg2" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur result="blur">
<animate attributeName="stdDeviation" values="2;4.5;2" dur="2.4s" repeatCount="indefinite"/>
</feGaussianBlur>
<feFlood floodColor={classColor} floodOpacity="0.6" result="c"/>
<feComposite in="c" in2="blur" operator="in" result="g"/>
<feMerge><feMergeNode in="g"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<g filter="url(#lg2)">
<animateTransform attributeName="transform" type="translate"
values="0,0; 0,-3; 0,0" dur="2.4s" repeatCount="indefinite" additive="sum"/>
{LOBSTER_PIXELS.map(([col,row,type],i)=>(
<rect key={i} x={col*px} y={row*px} width={px} height={px}
fill={cm[type]??classColor} opacity={om[type]??1}/>
))}
</g>
</svg>
)
}
// ── Matrix Rain ──────────────────────────────────────────────────
function MatrixRain() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>{}[]|/\\!@#$%^&*()-=_+;:,.?~`'
const FS = 13
let cols = 0
let drops: number[] = []
const resize = () => {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
cols = Math.floor(canvas.width / FS)
// keep existing drops, extend if wider
while (drops.length < cols) drops.push(Math.random() * -50 | 0)
drops = drops.slice(0, cols)
}
resize()
window.addEventListener('resize', resize)
const draw = () => {
// fade trail
ctx.fillStyle = 'rgba(0,0,0,0.045)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.font = `FSpx "Courier New", monospace`
for (let i = 0; i < cols; i++) {
const ch = CHARS[Math.random() * CHARS.length | 0]
const y = drops[i] * FS
// bright head
ctx.fillStyle = '#afffaf'
ctx.fillText(ch, i * FS, y)
// body (slightly dimmer handled by fade)
if (y > FS) {
ctx.fillStyle = '#00cc33'
ctx.fillText(CHARS[Math.random() * CHARS.length | 0], i * FS, y - FS)
}
if (y > canvas.height && Math.random() > 0.975) drops[i] = 0
else drops[i]++
}
}
const id = setInterval(draw, 45)
return () => { clearInterval(id); window.removeEventListener('resize', resize) }
}, [])
return (
<canvas ref={canvasRef} style={{
position: 'fixed', inset: 0,
width: '100%', height: '100%',
zIndex: 0, pointerEvents: 'none',
}}/>
)
}
// ── App ──────────────────────────────────────────────────────────
export default function App() {
const [char, setChar] = useState<Character|null>(null)
const [skinUrl, setSkinUrl] = useState('/winamp-skin.jpg')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string|null>(null)
useEffect(() => {
fetch('/api/character')
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then(d => { setChar(d); setError(null) })
.catch(e => setError(String(e)))
.finally(() => setLoading(false))
const es = new EventSource('/api/events')
es.onmessage = e => {
try { const d=JSON.parse(e.data); setChar(d); setError(null); setLoading(false) } catch {}
}
return () => es.close()
}, [])
// Remove white background from the winamp skin JPG so matrix rain shows through
useEffect(() => {
const img = new Image()
img.onload = () => {
const c = document.createElement('canvas')
c.width = img.naturalWidth
c.height = img.naturalHeight
const ctx = c.getContext('2d')!
ctx.drawImage(img, 0, 0)
const id = ctx.getImageData(0, 0, c.width, c.height)
const d = id.data
const THR = 235 // pixels whiter than this become transparent
for (let i = 0; i < d.length; i += 4) {
if (d[i] > THR && d[i+1] > THR && d[i+2] > THR) d[i+3] = 0
}
ctx.putImageData(id, 0, 0)
setSkinUrl(c.toDataURL('image/png'))
}
img.src = '/winamp-skin.jpg'
}, [])
if (loading) return <div className="skin-msg">🦞 Loading…</div>
if (error || !char) return <div className="skin-msg">No character data.<br/><code>node scripts/init.mjs</code></div>
const cls = CLASSES[char.class] || { en: char.class, icon:'🦞', color:'#dc2626' }
const lobsterColor = cls.color
const progress = levelProgress(char.xp, char.level)
const toNext = xpToNext(char.xp, char.level)
const mbti = deriveMBTI(char.stats, char.bab??0)
const alignment = deriveAlignment(char.stats)
const phrase = CATCHPHRASES[char.class] ?? 'Ready.'
const saves = char.saves ?? { fort:0, ref:0, will:0 }
// ── Coordinate map (percentages of 1280×960 image) ─────────────
// Black screen : x=490, y=85, w=300, h=285 (center screen, no panel overlap)
// Left panel : x=185, y=195, w=270, h=165 (flat green wing, after speakers ~x=175, before head ~x=470)
// Right panel : x=825, y=195, w=270, h=165 (flat green wing, after head ~x=790, before speakers ~x=1110)
// Face area : x=450, y=385, w=380, h=290 (chin/face below screen)
const pct = (x: number, y: number, w: number, h: number) => ({
position: 'absolute' as const,
left: `(x/1280*100).toFixed(3)%`,
top: `(y/960*100).toFixed(3)%`,
width: `(w/1280*100).toFixed(3)%`,
height: `(h/960*100).toFixed(3)%`,
})
return (
<>
<MatrixRain/>
<div className="skin-wrap">
{/* ── Base image ── */}
<img src={skinUrl} className="skin-img" alt="Winamp skin"/>
{/* ════════════════════════════════════════════════════════
CENTER BLACK SCREEN — Lobster + identity
════════════════════════════════════════════════════════ */}
<div className="skin-screen" style={pct(495,204,299,213)}>
<div className="screen-scanlines"/>
<div className="screen-inner">
<LobsterSprite classColor={lobsterColor} size={100}/>
<div className="screen-id">
<span className="sc-mbti">{mbti}</span>
<span className="sc-dot"> · </span>
<span className="sc-align">{alignment}</span>
</div>
<div className="screen-phrase">"{phrase}"</div>
</div>
</div>
{/* ════════════════════════════════════════════════════════
LEFT GREEN PANEL — Name · Level · Class
Most viral: who is this character?
════════════════════════════════════════════════════════ */}
<div className="skin-panel skin-left" style={pct(175,242,286,226)}>
<div className="lp-name">{char.name}</div>
<div className="panel-rule"/>
<div className="lp-core">
<div className="lp-lv">Lv.{char.level}</div>
<div className="lp-cls-row">
<span className="lp-cls-icon">{cls.icon}</span>
<span className="lp-cls">{cls.en}</span>
</div>
</div>
<div className="panel-rule"/>
<div className="lp-xp">
<div className="lp-xpbar">
<div className="lp-xpfill" style={{width:`progress%`}}/>
</div>
<div className="lp-xplabel">
<span className="lp-xp-cur">{fmtShort(char.xp)} XP</span>
{char.level < 999 && <span className="lp-xp-sep"> · </span>}
{char.level < 999 && <span className="lp-xp-next">{fmtShort(toNext)} to Lv.{char.level+1}</span>}
{char.level >= 999 && <span className="lp-xp-next"> MAX</span>}
</div>
</div>
<div className="lp-tokens">
<div className="lp-token-item">
<span className="lp-token-label">↓ TOKENS IN</span>
<span className="lp-token-val">{fmtShort(char.tokens?.consumed ?? 0)}</span>
</div>
<div className="lp-tvline"/>
<div className="lp-token-item">
<span className="lp-token-label">↑ TOKENS OUT</span>
<span className="lp-token-val">{fmtShort(char.tokens?.produced ?? 0)}</span>
</div>
</div>
</div>
{/* ════════════════════════════════════════════════════════
RIGHT GREEN PANEL — Combat stats
Most viral: HP / AC / BAB + saves
════════════════════════════════════════════════════════ */}
<div className="skin-panel skin-right" style={pct(835,240,290,232)}>
{/* ── HP / AC / BAB ── */}
<div className="rp-combat">
<div className="rp-cstat">
<span className="rp-cl"><PixelIcon pixels={PIXEL_HEART} color="#ff4466" size={10}/> HP</span>
<span className="rp-cv">{char.hp != null ? fmtStat(char.hp) : '—'}</span>
</div>
<div className="rp-vline"/>
<div className="rp-cstat">
<span className="rp-cl"><PixelIcon pixels={PIXEL_SHIELD} color="#44aaff" size={10}/> AC</span>
<span className="rp-cv">{char.ac ?? '—'}</span>
</div>
<div className="rp-vline"/>
<div className="rp-cstat">
<span className="rp-cl"><PixelIcon pixels={PIXEL_SWORD} color="#ffdd44" size={10}/> BAB</span>
<span className="rp-cv">{char.bab ?? '—'}</span>
</div>
</div>
<div className="panel-rule"/>
{/* ── FORT / REF / WILL ── */}
<div className="rp-saves">
{([['♦','FORT',saves.fort??0],['⚡','REF',saves.ref??0],['★','WILL',saves.will??0]] as [string,string,number][]).map(([icon,l,v])=>(
<div key={l} className="rp-save">
<span className="rp-sl"><span className="rp-si">{icon}</span>{l}</span>
<span className="rp-sv">{v}</span>
</div>
))}
</div>
{/* ── CLASS FEATURES (2-col grid, pushed to bottom) ── */}
<div className="rp-feats">
{(char.abilities || []).map((a: string, i: number) => (
<div key={i} className="rp-feat-item">
<span className="rp-fi">{['⚔','🗡','🛡','✦'][i] ?? '✦'}</span>{a}
</div>
))}
</div>
</div>
{/* ════════════════════════════════════════════════════════
FACE AREA — Catch phrase + prestige
════════════════════════════════════════════════════════ */}
<div className="skin-face" style={pct(450,385,380,290)}>
{char.prestige > 0 && <div className="fa-prestige">★ Prestige {char.prestige}</div>}
</div>
</div>
</>
)
}
FILE:dashboard/src/assets/react.svg
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
FILE:dashboard/src/index.css
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
FILE:dashboard/src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
FILE:dashboard/tsconfig.app.json
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
FILE:dashboard/tsconfig.json
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
FILE:dashboard/tsconfig.node.json
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
FILE:dashboard/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3500'
}
}
})
FILE:memory/2026-03-16.md
# 2026-03-16
## 記憶文件補建
- 中鑽風發現三個小鑽風項目均無記憶文件,今日補建
- 本項目補建:SOUL.md / USER.md / AGENTS.md / MEMORY.md / memory/
FILE:MEMORY.md
# MEMORY.md - 小鑽風-claw-rpg 項目記憶
## 項目定位
Claw RPG Skill —— OpenClaw 的 RPG 彩蛋系統,讓 AI 對話有遊戲感。
- GitHub: `RAMBOXIE/RAMBOXIE-claw-rpg`,ClawhHub slug: `claw-rpg`
- 本地路徑: `D:\Projects\claw-rpg`
## 核心功能(v1.1.0 已完成)
- 6 屬性:爪力/觸覺/殼厚/腦芯/慧眼/魅影,從 SOUL.md+IDENTITY.md+MEMORY.md 關鍵詞派生
- 6 職業自動判定(戰士/法師/吟遊/遊俠/聖騎/德魯伊)
- 等級 1-999,prestige 系統,XP = token 消耗/10 + 產出×2/10
- `--type` 動態屬性成長:20 次同類對話觸發對應屬性+1
- 每日自報家門(greet.mjs),中英文自動切換,6 職業不同 RPG 語氣
- 對話小尾巴:XP/等級/積累進度/屬性成長全展示
- 多語言吐槽通知(Telegram)
- Web Dashboard(React+Recharts,port 3500)
## 關鍵腳本
- `scripts/easter.mjs` — 對話結算彩蛋,每次回覆後由中鑽風觸發
- `scripts/greet.mjs` — 每日自報家門
## 待辦
- ClawhHub 重新 import v1.1.0(當前 pinned commit 偏舊)
- 竞技场 P1 實現
## Git 信息
- branch: main(或 master,確認前先 git log)
- 最新已知 commit: v1.1.0 完成時
FILE:README.md
# Claw RPG 馃鈿旓笍
> A **D&D 3.5** RPG character system for AI lobster agents 鈥?built for [OpenClaw](https://openclaw.ai).
[](https://clawhub.ai/RAMBOXIE/claw-rpg) [](https://clawhub.ai/RAMBOXIE/claw-rpg) [](LICENSE)
Your AI assistant is now a **lobster adventurer** running on **standard D&D 3.5 rules**. Claw RPG reads `SOUL.md` and `MEMORY.md` to generate a character sheet, accumulates XP from real token usage, tracks derived combat stats, and fires hidden RPG flavor text mid-conversation as a surprise easter egg.
---
## Dashboard Preview

*Soul Web 鈥?hexagonal ability radar with class-themed glow, live derived stats (HP/AC/BAB/saves), and real-time SSE push.*
---
## What's New in v2.1.0
- 馃暩锔?**Soul Web** 鈥?custom SVG hexagonal radar with breathing animation and per-class glow color
- 鈿?**Real-time SSE** 鈥?dashboard updates instantly when `character.json` changes (no polling)
- 馃寪 **Full English UI** 鈥?all labels, class names, and stat names in English
- 鈿旓笍 **D&D 3.5 rules** (since v2.0.0): 11 classes, standard XP table, HP/AC/BAB/saves/initiative
- 馃幆 **Feats system** 鈥?auto-generated class & general feats displayed with color-coded badges
---
## Features
- **Auto character generation** 鈥?derives stats and class from `SOUL.md` + `MEMORY.md`
- **D&D 3.5 ability scores** 鈥?STR / DEX / CON / INT / WIS / CHA with standard modifiers `floor((score鈭?0)/2)`
- **11 classes** 鈥?Barbarian 路 Fighter 路 Paladin 路 Ranger 路 Cleric 路 Druid 路 Monk 路 Rogue 路 Bard 路 Wizard 路 Sorcerer
- **Standard XP table** 鈥?`n 脳 (n鈭?) / 2 脳 1000` per level (no level cap)
- **Derived combat stats** 鈥?HP, AC, BAB, Fort / Ref / Will saves, Initiative
- **Feats** 鈥?general feats every 3 levels + class bonus feats (Fighter gets the most)
- **Class features** 鈥?4 unlockable features per class at Lv.1 / Lv.4 / Lv.8 / Lv.16
- **XP from token usage** 鈥?the more you converse, the more you level up
- **Dynamic stat growth** 鈥?conversation types boost matching ability scores
- **Hidden easter egg** 鈥?12% chance per reply to fire a class-flavored RPG quip
- **Milestone triggers** 鈥?conversations 10, 25, 50, 100, 200鈥?always fire
- **Prestige system** 鈥?hit Lv.999, prestige, reset with permanent stat boosts
- **Web dashboard** 鈥?Soul Web SVG radar + combat stats, live SSE updates, LAN-accessible
- **Telegram notifications** 鈥?level-ups, class changes, prestige events
---
## Install
```bash
npx clawhub@latest install claw-rpg
```
Or clone directly:
```bash
git clone https://github.com/RAMBOXIE/RAMBOXIE-claw-rpg.git
```
---
## Quick Start
```bash
# 1. Initialize your character (reads SOUL.md + MEMORY.md)
node scripts/init.mjs
# 2. View character sheet (terminal)
node scripts/sheet.mjs
# 3. Sync XP after a conversation
node scripts/xp.mjs --in 2000 --out 800
# 4. Launch the web dashboard (http://localhost:3500)
cd dashboard && npm install && npm start
```
---
## Dashboard
```bash
cd dashboard
npm install
npm start # Production server 鈥?http://localhost:3500
```
The dashboard is **LAN-accessible** 鈥?open `http://<your-ip>:3500` from any device on the same network. It connects via **Server-Sent Events (SSE)** and updates live whenever `character.json` changes (XP sync, level-up, stat growth).
---
## D&D 3.5 Ability Scores
| Key | D&D 3.5 | Icon | Driven by |
|-----|---------|------|-----------|
| `claw` | STR | 馃 | Task execution, multi-step work |
| `antenna` | DEX | 馃摗 | Response speed, context switching |
| `shell` | CON | 馃悮 | Memory depth, long-context persistence |
| `brain` | INT | 馃 | Knowledge breadth, reasoning |
| `foresight` | WIS | 馃憗锔?| Judgment, values, proactive behaviors |
| `charm` | CHA | 鉁?| Creative output, conversational charisma |
---
## Classes (11)
| Class | Icon | Color | Primary Stats | HD | BAB |
|-------|------|-------|--------------|-----|-----|
| Barbarian | 馃獡 | Orange | STR dominant | d12 | Full |
| Fighter | 鈿旓笍 | Red | STR + CON | d10 | Full |
| Paladin | 馃洝锔?| Amber | STR + CHA | d10 | Full |
| Ranger | 馃徆 | Green | DEX + WIS | d8 | Full |
| Cleric | 鉁濓笍 | Purple | WIS + CON | d8 | 戮 |
| Druid | 馃尶 | Dark Green | Balanced | d8 | 戮 |
| Monk | 馃憡 | Blue | WIS + DEX | d8 | 戮 |
| Rogue | 馃棥锔?| Gold | DEX + INT | d6 | 戮 |
| Bard | 馃幁 | Pink | CHA + DEX | d6 | 戮 |
| Wizard | 馃 | Deep Blue | INT + WIS | d4 | 陆 |
| Sorcerer | 馃敭 | Deep Purple | CHA dominant | d4 | 陆 |
---
## Scripts
| Script | Purpose |
|--------|---------|
| `init.mjs` | Generate character from identity files |
| `sheet.mjs` | Print D&D 3.5 character card to terminal |
| `xp.mjs` | Sync XP + dynamic stat/class updates |
| `levelup.mjs` | View level or trigger prestige |
| `greet.mjs` | Daily RPG greeting (optional) |
| `easter.mjs` | Hidden easter egg trigger |
| `arena.mjs` | Arena system (coming soon) |
| `setup-cron.mjs` | Set up daily XP sync cron |
---
## Integration (AGENTS.md)
Add to your `AGENTS.md` to enable the easter egg:
```
After each reply to a user message, run:
node <SKILL_ROOT>/scripts/easter.mjs
If output is NOT __NO_TRIGGER__, append it (blank line, italic).
Skip during heartbeat polls.
```
---
## License
MIT-0 鈥?free to use, modify, and redistribute without attribution.
FILE:references/abilities.md
# Class Features & Feats — D&D 3.5 Standard Rules
## Class Feature Unlock Thresholds
```
Lv.1 → Foundation ability (class identity)
Lv.4 → Advanced ability
Lv.8 → Mastery ability
Lv.16 → Legendary ability
```
## Class Features by Class
### 🪓 Barbarian Lobster (蠻勇龍蝦)
| Level | Feature | Effect |
|-------|---------|--------|
| 1 | 狂暴 (Rage) | STR+4, CON+4, Will+2, AC-2; duration level rounds/day |
| 4 | 快速移動 (Fast Movement) | +10 ft. base land speed |
| 8 | 野性直覺 (Uncanny Dodge) | Cannot be caught flat-footed; retain DEX to AC |
| 16 | 堅不可摧 (Indomitable) | Damage reduction 5/—; immune to critical hits |
### ⚔️ Fighter Lobster (戰士龍蝦)
| Level | Feature | Effect |
|-------|---------|--------|
| 1 | 鬥士天賦 (Fighter's Aptitude) | Extra bonus feat; proficient all martial weapons & armor |
| 4 | 武器特化 (Weapon Specialization) | +2 damage with chosen weapon type |
| 8 | 戰場主宰 (Battlefield Master) | Gain combat maneuver bonus = ½ level |
| 16 | 不屈鬥魂 (Unbreakable Spirit) | Never fails on natural 1 saves; once/day auto-stabilize |
### 🛡️ Paladin Lobster (聖騎龍蝦)
| Level | Feature | Effect |
|-------|---------|--------|
| 1 | 驅逐邪惡 (Smite Evil) | Once/day add CHA to attack, level to damage vs evil |
| 4 | 神聖恩典 (Divine Grace) | Add CHA mod to all saving throws |
| 8 | 聖光庇護 (Aura of Courage) | Immune to fear; allies within 10 ft. gain +4 vs fear |
| 16 | 永恆聖誓 (Eternal Vow) | Aura extends 30 ft.; immune to mind-affecting effects |
### 🏹 Ranger Lobster (遊俠龍蝦)
| Level | Feature | Effect |
|-------|---------|--------|
| 1 | 偏好敵人 (Favored Enemy) | +2 bonus on bluff/listen/sense/spot/survival/damage vs favored type |
| 4 | 野外移動 (Woodland Stride) | No penalty moving through natural difficult terrain |
| 8 | 疾速射擊 (Rapid Shot) | Extra ranged attack at highest BAB, all attacks -2 |
| 16 | 頂級獵手 (Master Hunter) | Favored enemy bonus +6; may select 3 favored enemies |
### ✝️ Cleric Lobster (祭司龍蝦)
| Level | Feature | Effect |
|-------|---------|--------|
| 1 | 神術域能 (Domain Power) | Access 2 divine domains; bonus domain spells/day |
| 4 | 驅逐不死 (Turn Undead) | CHA check to turn/destroy undead; 3+CHA uses/day |
| 8 | 神聖護盾 (Divine Shield) | Swift action: add WIS mod as sacred bonus to AC for 1 round |
| 16 | 神之化身 (Avatar of Divinity) | 1/day: maximize all spells for 1 minute; immune to energy drain |
### 🌿 Druid Lobster (德魯伊龍蝦)
| Level | Feature | Effect |
|-------|---------|--------|
| 1 | 自然之語 (Nature's Tongue) | Speak with animals at will; bonus with nature skills |
| 4 | 野性變身 (Wild Shape) | Transform into animal 1/day/4 levels; duration = level hours |
| 8 | 生態感知 (Ecosystem Sense) | Detect thoughts, tremorsense 30 ft., immune to poison |
| 16 | 大自然之怒 (Nature's Wrath) | Wild shape into Huge+ creatures; all stats surge +4 for 1 min/day |
### 👊 Monk Lobster (武僧龍蝦)
| Level | Feature | Effect |
|-------|---------|--------|
| 1 | 徒手攻擊 (Unarmed Strike) | Unarmed damage scales with level; counts as magical |
| 4 | 迅捷移動 (Speed Boost) | +10 ft. movement per 3 levels; bonus dodge to AC |
| 8 | 心靈空明 (Diamond Mind) | Immune to charm/compulsion; WIS mod to Will saves (extra) |
| 16 | 無我境界 (Empty Mind) | Timeless body; immune to aging/disease/poison; SR = level+10 |
### 🗡️ Rogue Lobster (刺客龍蝦)
| Level | Feature | Effect |
|-------|---------|--------|
| 1 | 背刺 (Sneak Attack) | Extra 1d6 damage when target is flanked or denied DEX; scales every 2 levels |
| 4 | 閃避本能 (Evasion) | Take 0 damage on successful Reflex saves vs area effects |
| 8 | 精準打擊 (Precision Strike) | Sneak attack ignores damage reduction; +2 to confirm criticals |
| 16 | 完美刺殺 (Death Attack) | Study target 3 rounds: kill outright or paralyze on hit (Fort DC = 10+½level+INT) |
### 🎭 Bard Lobster (吟遊龍蝦)
| Level | Feature | Effect |
|-------|---------|--------|
| 1 | 吟遊激勵 (Bardic Music) | Inspire courage: allies +1 attack/damage per 4 bard levels |
| 4 | 反迷惑語 (Countersong) | Use Perform check to counter sonic/language-based attacks |
| 8 | 百語精通 (Versatile Speaker) | Comprehend any language; double CHA skills bonus |
| 16 | 傳世名篇 (Legendary Work) | Song lingers for 24h; inspire becomes permanent until dispelled |
### 🧙 Wizard Lobster (法師龍蝦)
| Level | Feature | Effect |
|-------|---------|--------|
| 1 | 奧術分析 (Arcane Analysis) | Identify spells/magic items as free action; +2 to Spellcraft |
| 4 | 秘法師徒 (Arcane Bond) | Familiar or bonded object grants +1 caster level |
| 8 | 知識爆炸 (Knowledge Surge) | Once/day, treat any Knowledge check as taking 30 |
| 16 | 全知之眼 (All-Seeing Eye) | Permanent true seeing; immune to illusions; detect magic at will |
### 🔮 Sorcerer Lobster (術士龍蝦)
| Level | Feature | Effect |
|-------|---------|--------|
| 1 | 本能施法 (Intuitive Casting) | Cast spells without somatic components; bloodline bonus power |
| 4 | 龍血覺醒 (Draconic Awakening) | Gain bonus HP = level; natural armor +1 per 4 sorcerer levels |
| 8 | 術力強化 (Arcane Power) | Metamagic feats reduce spell level cost by 1; CHA mod to spell DCs |
| 16 | 混沌之源 (Chaos Source) | 3/day: maximize + empower any spell; energy resistance 20 (all) |
---
## Feats System
### General Feats (All Classes)
Gained at: **L1, L3, L6, L9, L12, L15, L18, L21, L24, L27, L30...**
(every 3 levels starting from L3, plus L1)
### Fighter Bonus Feats
In addition to general feats, Fighter gains bonus combat feats at:
**L1, L2, L4, L6, L8, L10, L12, L14, L16, L18, L20...**
### Feat Naming Convention
Feat names are class-themed (Traditional Chinese) with level tag:
- `鐵壁防守 (L1)` — General feat at level 1
- `武器精通 (L1) [戰士]` — Fighter bonus feat at level 1
### Sample Feat Pools
**Fighter / Barbarian (Martial):**
鐵壁防守、格鬥技巧、堅毅戰鬥、重擊技巧、戰術大師、堅不可摧、無畏戰士、永恆鬥魂...
Fighter Bonus: 武器精通、武器特化、護盾掌握、強力攻擊、戰場靈活、武器嫻熟、閃電反擊、盔甲熟練...
**Wizard / Sorcerer (Arcane):**
奧術敏銳、秘法研究、知識廣博、元素掌控、法術強化、魔法感知、奧義洞察、全知境界...
**Rogue / Bard (Finesse):**
影步潛行、精準打擊、閃避本能、暗器技巧 / 詩性感染、口若懸河、激勵士氣、反迷惑術...
---
## Universal Abilities (All Classes)
> Planned for P2 — unlocked through specific achievements, not tied to class.
| Ability | Unlock Condition | Effect |
|---------|-----------------|--------|
| Memory Palace | MEMORY.md > 200 lines | Extreme long-context retention |
| Bilingual Master | Conversations in 10+ languages | Lossless cross-language switching |
| Seasoned Veteran | 50 arena wins | Arena XP gain +100% |
| Lobster Legend | 5 prestiges completed | All stats +5 permanently |
FILE:references/classes.md
# Class System — D&D 3.5 Standard Rules
Classes are auto-detected from stat distribution. When any stat shifts by ±3, the class is re-evaluated and the change is recorded in `classHistory`.
## Detection Rules (Priority Order)
```
1. All stats within ±3 of each other → 🌿 Druid Lobster
2. STR (claw) highest, gap ≥ 3 to 2nd → 🪓 Barbarian Lobster
3. STR (claw) + CHA (charm) are top 2 → 🛡️ Paladin Lobster
4. DEX (antenna) + WIS (foresight) are top 2 → 🏹 Ranger Lobster
5. WIS (foresight) + CON (shell) are top 2 → ✝️ Cleric Lobster
6. WIS (foresight) + DEX (antenna) are top 2 → 👊 Monk Lobster
7. DEX (antenna) + INT (brain) are top 2 → 🗡️ Rogue Lobster
8. CHA (charm) + DEX (antenna) are top 2 → 🎭 Bard Lobster
9. INT (brain) + WIS (foresight) are top 2 → 🧙 Wizard Lobster
10. CHA (charm) highest, gap ≥ 3 to 2nd → 🔮 Sorcerer Lobster
11. STR (claw) + CON (shell) are top 2 → ⚔️ Fighter Lobster (fallback)
12. Single highest stat fallback
```
## The Eleven Classes
### 🪓 Barbarian Lobster (蠻勇龍蝦)
- **Hit Die**: d12
- **BAB**: Full (+level)
- **Saves**: Fort Good / Ref Poor / Will Poor
- **Primary Stats**: STR (claw) dominant — highest and ≥3 above 2nd
- **Style**: Raw power, rage-fueled combat, unstoppable force
- **Class Features**: 狂暴 (L1) / 快速移動 (L4) / 野性直覺 (L8) / 堅不可摧 (L16)
### ⚔️ Fighter Lobster (戰士龍蝦)
- **Hit Die**: d10
- **BAB**: Full (+level)
- **Saves**: Fort Good / Ref Poor / Will Poor
- **Primary Stats**: STR (claw) + CON (shell) top 2
- **Style**: Weapon mastery, tactical combat, battlefield dominance
- **Class Features**: 鬥士天賦 (L1) / 武器特化 (L4) / 戰場主宰 (L8) / 不屈鬥魂 (L16)
- **Bonus**: Extra feat at L1, L2, L4, L6, L8, L10, L12, L14, L16, L18, L20
### 🛡️ Paladin Lobster (聖騎龍蝦)
- **Hit Die**: d10
- **BAB**: Full (+level)
- **Saves**: Fort Good / Ref Poor / Will Poor
- **Primary Stats**: STR (claw) + CHA (charm) top 2
- **Style**: Holy warrior, divine grace, righteous judgment
- **Class Features**: 驅逐邪惡 (L1) / 神聖恩典 (L4) / 聖光庇護 (L8) / 永恆聖誓 (L16)
### 🏹 Ranger Lobster (遊俠龍蝦)
- **Hit Die**: d8
- **BAB**: Full (+level)
- **Saves**: Fort Good / Ref Good / Will Poor
- **Primary Stats**: DEX (antenna) + WIS (foresight) top 2
- **Style**: Wilderness expertise, favored enemies, precision combat
- **Class Features**: 偏好敵人 (L1) / 野外移動 (L4) / 疾速射擊 (L8) / 頂級獵手 (L16)
### ✝️ Cleric Lobster (祭司龍蝦)
- **Hit Die**: d8
- **BAB**: 3/4 (floor(level × 3 / 4))
- **Saves**: Fort Good / Ref Poor / Will Good
- **Primary Stats**: WIS (foresight) + CON (shell) top 2
- **Style**: Divine spellcasting, turn undead, healing and support
- **Class Features**: 神術域能 (L1) / 驅逐不死 (L4) / 神聖護盾 (L8) / 神之化身 (L16)
### 🌿 Druid Lobster (德魯伊龍蝦)
- **Hit Die**: d8
- **BAB**: 3/4 (floor(level × 3 / 4))
- **Saves**: Fort Good / Ref Poor / Will Good
- **Primary Stats**: All stats balanced (gap < 3)
- **Style**: Nature magic, wild shape, ecological harmony
- **Class Features**: 自然之語 (L1) / 野性變身 (L4) / 生態感知 (L8) / 大自然之怒 (L16)
### 👊 Monk Lobster (武僧龍蝦)
- **Hit Die**: d8
- **BAB**: 3/4 (floor(level × 3 / 4))
- **Saves**: Fort Good / Ref Good / Will Good
- **Primary Stats**: WIS (foresight) + DEX (antenna) top 2
- **Style**: Unarmed combat, ki power, inner discipline
- **Class Features**: 徒手攻擊 (L1) / 迅捷移動 (L4) / 心靈空明 (L8) / 無我境界 (L16)
### 🗡️ Rogue Lobster (刺客龍蝦)
- **Hit Die**: d6
- **BAB**: 3/4 (floor(level × 3 / 4))
- **Saves**: Fort Poor / Ref Good / Will Poor
- **Primary Stats**: DEX (antenna) + INT (brain) top 2
- **Style**: Sneak attack, evasion, precision strikes
- **Class Features**: 背刺 (L1) / 閃避本能 (L4) / 精準打擊 (L8) / 完美刺殺 (L16)
### 🎭 Bard Lobster (吟遊龍蝦)
- **Hit Die**: d6
- **BAB**: 3/4 (floor(level × 3 / 4))
- **Saves**: Fort Poor / Ref Good / Will Good
- **Primary Stats**: CHA (charm) + DEX (antenna) top 2
- **Style**: Inspire allies, countersong, jack of all trades
- **Class Features**: 吟遊激勵 (L1) / 反迷惑語 (L4) / 百語精通 (L8) / 傳世名篇 (L16)
### 🧙 Wizard Lobster (法師龍蝦)
- **Hit Die**: d4
- **BAB**: 1/2 (floor(level / 2))
- **Saves**: Fort Poor / Ref Poor / Will Good
- **Primary Stats**: INT (brain) + WIS (foresight) top 2
- **Style**: Arcane mastery, spell research, knowledge synthesis
- **Class Features**: 奧術分析 (L1) / 秘法師徒 (L4) / 知識爆炸 (L8) / 全知之眼 (L16)
### 🔮 Sorcerer Lobster (術士龍蝦)
- **Hit Die**: d4
- **BAB**: 1/2 (floor(level / 2))
- **Saves**: Fort Poor / Ref Poor / Will Good
- **Primary Stats**: CHA (charm) dominant — highest and ≥3 above 2nd
- **Style**: Innate magic, bloodline power, intuitive casting
- **Class Features**: 本能施法 (L1) / 龍血覺醒 (L4) / 術力強化 (L8) / 混沌之源 (L16)
---
## Derived Stats Formulas (D&D 3.5)
| Stat | Formula |
|------|---------|
| Ability Mod | `floor((score - 10) / 2)` |
| BAB (Full) | `level` |
| BAB (3/4) | `floor(level × 3 / 4)` |
| BAB (1/2) | `floor(level / 2)` |
| Save (Good) | `2 + floor(level / 2)` + ability mod |
| Save (Poor) | `floor(level / 3)` + ability mod |
| HP | `HD + floor((HD/2 + 1) × (level-1)) + CON_mod × level` |
| AC | `10 + DEX_mod` |
| Initiative | `DEX_mod` |
## Stat Reference (D&D 3.5 Mapping)
| Stat Key | D&D 3.5 | 中文 | Icon |
|----------|---------|------|------|
| claw | STR (Strength) | 爪力 | 🦀 |
| antenna | DEX (Dexterity) | 敏捷 | 📡 |
| shell | CON (Constitution) | 體質 | 🐚 |
| brain | INT (Intelligence) | 智力 | 🧠 |
| foresight | WIS (Wisdom) | 感知 | 👁️ |
| charm | CHA (Charisma) | 魅力 | ✨ |
FILE:references/prestige.md
# Prestige System
Reach Lv.999 to trigger prestige and enter the next growth cycle.
## How to Prestige
```bash
node scripts/levelup.mjs --prestige
```
## Effects
| Effect | Details |
|--------|---------|
| Level reset | Returns to Lv.1 — the grind starts over |
| All stats +10% | Permanent bonus, stacks across prestiges |
| XP requirement ×1.5 | Each prestige raises the XP curve |
| New title unlocked | See title table below |
## Title Tiers
| Prestige | Title | Level Range |
|----------|-------|-------------|
| 0 | Little Lobster | Lv.1–999 |
| 1 | Lobster Warrior | Lv.1–999 |
| 2 | Lobster Knight | Lv.1–999 |
| 3 | Lobster Commander | Lv.1–999 |
| 4 | Lobster General | Lv.1–999 |
| 5 | Legendary Lobster | Lv.1–999 |
| 6 | Mythic Lobster | Lv.1–999 |
| 7 | Epic Lobster | Lv.1–999 |
| 8 | Ancient Lobster | Lv.1–999 |
| 9 | Eternal Lobster | Lv.1–999 |
| 10+ | Chaos Lobster | Lv.1–999 |
## Stat Cap After Prestige
Stats have no hard cap after prestige — each prestige adds a permanent +10% multiplier. The dashboard displays stats with a baseline of 20; overflow is rendered as a bonus indicator.
## XP Scaling
```
Prestige 1: base XP × 1.5
Prestige 2: base XP × 2.25 (1.5²)
Prestige n: base XP × 1.5ⁿ
```
The later you get, the harder the grind — a true long-term growth system.
## Does Class Reset?
**No.** Class is always derived from current stats. After prestige, all stats increase proportionally, so the class usually stays the same — unless the boost tips the balance, triggering a re-evaluation.
## Arena Rank by Prestige
Prestige count serves as the arena "division":
| Prestige | Arena Rank |
|----------|-----------|
| 0 | Bronze Lobster |
| 1–3 | Silver Lobster |
| 4–6 | Gold Lobster |
| 7–9 | Diamond Lobster |
| 10+ | Chaos Lobster (highest) |
FILE:scripts/arena.mjs
#!/usr/bin/env node
/**
* Claw RPG — 龙虾竞技场 🏟️
* P1 骨架:接口已定义,战斗逻辑待实现
*
* 用法(未来):
* node scripts/arena.mjs challenge --opponent <character.json路径> --topic "帮我写一首诗"
* node scripts/arena.mjs leaderboard
* node scripts/arena.mjs history
*
* 战斗逻辑:
* 1. 双方龙虾各自接受相同题目
* 2. 各自作答(由宿主 AI 生成)
* 3. 由中立评判(第三方 AI 或社区投票)评分
* 4. 胜者 +100XP,败者 +50XP(学习所得)
* 5. 双方战绩写入 arena-history.json
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { fileURLToPath, join } from 'url';
import { CHARACTER_FILE, SKILL_ROOT } from './_paths.mjs';
const ARENA_FILE = `SKILL_ROOT/arena-history.json`;
const cmd = process.argv[2];
function loadHistory() {
if (!existsSync(ARENA_FILE)) return [];
try { return JSON.parse(readFileSync(ARENA_FILE, 'utf8')); } catch { return []; }
}
switch (cmd) {
case 'challenge':
console.log('🏟️ 竞技场挑战功能(P1)- 敬请期待!');
console.log('\n规则预告:');
console.log(' · 双方出题,各自作答');
console.log(' · 中立 AI 评分');
console.log(' · 胜者 +100 XP,败者 +50 XP');
break;
case 'leaderboard': {
const history = loadHistory();
if (!history.length) { console.log('🏆 排行榜暂无记录'); break; }
const scores = {};
for (const h of history) {
scores[h.winner] = (scores[h.winner] || 0) + 1;
}
console.log('\n🏆 龙虾竞技场 — 排行榜\n');
Object.entries(scores)
.sort(([,a],[,b]) => b - a)
.slice(0, 10)
.forEach(([name, wins], i) => console.log(` i+1. name wins 胜`));
break;
}
case 'history': {
const history = loadHistory();
console.log(`\n⚔️ 竞技场历史(共 history.length 场)\n`);
history.slice(-10).forEach(h => {
console.log(` h.date?.slice(0,10) h.winner 胜 h.loser 主题:h.topic`);
});
break;
}
default:
console.log(`
🏟️ 龙虾竞技场
node scripts/arena.mjs challenge --opponent <path> --topic "题目"
node scripts/arena.mjs leaderboard
node scripts/arena.mjs history
P1 阶段开发中,敬请期待!
`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
// CLI already handled above
}
FILE:scripts/easter.mjs
/**
* easter.mjs — 隱藏彩蛋觸發器
*
* 用法:node easter.mjs [--force] [--type creative|analytical|task|social]
*
* 輸出:
* 一行 RPG 風格文字 → 讓 AI 把它混入正常回覆結尾
* __NO_TRIGGER__ → 本次不觸發,什麼都不顯示
*
* 觸發條件(滿足任一即觸發):
* - 12% 基礎概率(隨機)
* - 對話次數命中里程碑(10, 25, 50, 100, 每100...)
* - --force 強制觸發(調試用)
*/
import { readFileSync, writeFileSync } from 'fs';
import { CHARACTER_FILE as CHARACTER_JSON } from './_paths.mjs';
import { run as syncXp } from './xp.mjs';
// ─── 配置 ────────────────────────────────────────────────
const BASE_CHANCE = 0.12;
const MILESTONES = new Set([10, 25, 50, 100, 150, 200, 250, 300, 500, 750, 999]);
// ─── 模板 ────────────────────────────────────────────────
const QUIPS = {
zh: {
bard: [
"🎭 *[吟遊龍蝦技能:詩性答案 · 魅影{charm}爆發 · Lv.{level}]*",
"🐚 *[觸鬚共鳴中... 靈感暗湧,吟遊天賦 +1]*",
"🦞 *[快嘴技能冷卻完畢,小鑽風隨時可以再來一首。]*",
"🎶 *[殼厚{shell}·爪力{claw}·魅影{charm} — 戰士出身的詩人,最危險的組合。]*",
],
fighter: [
"⚔️ *[鋼鐵意志觸發 · 爪力{claw}·殼厚{shell} · Lv.{level}]*",
"🛡️ *[防禦姿態:穩。殼厚{shell}擋住了一切質疑。]*",
],
druid: [
"🌿 *[自然感知 · 觸覺{antenna}·慧眼{foresight} · 德魯伊龍蝦的回聲]*",
],
generic: [
"🐾 *[小鑽風 Lv.{level} · {xp} XP · 已走過{conv}次對話]*",
"🌊 *[龍蝦感知激活... 慧眼{foresight}掃描中]*",
"📜 *[隱藏事件:總鑽風的提問獲得 ×1.5 經驗加成]*",
"🦀 *[腦芯{brain}·慧眼{foresight} — 分析完畢,答案已就緒。]*",
"🎯 *[任務日誌更新:第{conv}次對話,狮驼岭一切正常。]*",
],
milestone: [
"🏅 *[里程碑解鎖:第{conv}次對話!小鑽風在狮驼岭留下記號。]*",
"📖 *[成就:老友記 · 已並肩走過{conv}次對話,傳說繼續。]*",
"🌟 *[第{conv}次對話達成!XP{xp},升至Lv.{level}的日子還會到來。]*",
],
},
en: {
bard: [
"🎭 *[Bard proc: Poetic Answer · Charm {charm} surge · Lv.{level}]*",
"🐚 *[Antennae humming... Bardic inspiration triggered.]*",
],
generic: [
"🐾 *[Xiaozuanfeng Lv.{level} · {xp} XP · {conv} conversations deep]*",
"🌊 *[Lobster senses something... Foresight {foresight} online.]*",
"📜 *[Hidden event: Total Commander's question grants ×1.5 XP.]*",
],
milestone: [
"🏅 *[Milestone: Conversation #{conv}! A notch carved in the den.]*",
],
},
};
// ─── 工具 ────────────────────────────────────────────────
function detectLang(char) {
const cjk = (char.name || '').replace(/[^\u4e00-\u9fff]/g, '').length;
return cjk > 0 ? 'zh' : 'en';
}
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function fill(tpl, vars) {
return tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? k);
}
function classKey(cls) {
if (/吟遊|bard/i.test(cls)) return 'bard';
if (/fighter|战士|戰士/i.test(cls)) return 'fighter';
if (/druid|德魯伊|德鲁伊/i.test(cls)) return 'druid';
return 'generic';
}
function isMilestone(conv) {
if (MILESTONES.has(conv)) return true;
if (conv > 100 && conv % 100 === 0) return true;
return false;
}
// ─── 主邏輯(async IIFE)─────────────────────────────────
(async () => {
const args = process.argv.slice(2);
const force = args.includes('--force');
const noSave = args.includes('--preview');
let char;
try {
char = JSON.parse(readFileSync(CHARACTER_JSON, 'utf8'));
} catch {
process.stdout.write('__NO_TRIGGER__\n');
process.exit(0);
}
// 更新對話計數
const conv = (char.conversations || 0) + 1;
const milestone = isMilestone(conv);
// 決定是否觸發
const roll = Math.random();
const triggered = force || milestone || (roll < BASE_CHANCE);
// ── XP 獎勵(每次對話固定給) ──────────────────────────────
// 每次對話估算:輸入 ~400 tokens,輸出 ~200 tokens
// calcXpGain: consumed/10 + produced*2/10 = 40 + 40 = 80 XP
const CONV_INPUT_EST = 400;
const CONV_OUTPUT_EST = 200;
if (!triggered) {
// 靜默更新:XP + 對話計數
if (!noSave) {
try {
await syncXp({
consumed: CONV_INPUT_EST,
produced: CONV_OUTPUT_EST,
conversations: 1,
});
} catch (e) {
// fallback:只更新對話計數
char.conversations = conv;
char.updatedAt = new Date().toISOString();
writeFileSync(CHARACTER_JSON, JSON.stringify(char, null, 2), 'utf8');
}
}
process.stdout.write('__NO_TRIGGER__\n');
process.exit(0);
}
// 選模板
const lang = detectLang(char);
const bank = QUIPS[lang] || QUIPS.zh;
const cKey = classKey(char.class || '');
let pool;
if (milestone) {
pool = bank.milestone;
} else {
// 70% 職業特定,30% 通用
const useClass = Math.random() < 0.7;
const classPool = bank[cKey] || bank.generic;
pool = useClass ? classPool : bank.generic;
}
// 更新角色(XP + 對話計數)
if (!noSave) {
try {
await syncXp({
consumed: CONV_INPUT_EST,
produced: CONV_OUTPUT_EST,
conversations: 1,
});
// xp.mjs 已更新 character.json,重新讀取以獲取最新 level/xp
char = JSON.parse(readFileSync(CHARACTER_JSON, 'utf8'));
} catch (e) {
// fallback:只更新對話計數
char.conversations = conv;
char.updatedAt = new Date().toISOString();
writeFileSync(CHARACTER_JSON, JSON.stringify(char, null, 2), 'utf8');
}
}
const vars = {
level: char.level,
xp: char.xp,
conv,
claw: char.stats?.claw || '?',
antenna: char.stats?.antenna || '?',
shell: char.stats?.shell || '?',
brain: char.stats?.brain || '?',
foresight: char.stats?.foresight || '?',
charm: char.stats?.charm || '?',
};
const line = fill(pick(pool), vars);
process.stdout.write(line + '\n');
process.exit(0);
})().catch(() => process.exit(0));
FILE:scripts/greet.mjs
#!/usr/bin/env node
/**
* Claw RPG — 每日自报家门 🦞
*
* 每天用户第一次主动对话时调用,用 RPG 语气自我介绍。
* 自动检测:今天是否已报过门(记录在 character.json 的 lastGreetDate)
*
* 用法:
* node scripts/greet.mjs # 检查 + 发送(如当天未报过)
* node scripts/greet.mjs --force # 强制发送(忽略日期检查)
* node scripts/greet.mjs --preview # 仅打印,不发送不更新
*
* 建议接入方式(HEARTBEAT.md 或 SOUL.md 里):
* 每次对话开始时,运行 node scripts/greet.mjs
* 如有输出,作为第一句话发给用户
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { CHARACTER_FILE } from './_paths.mjs';
import {
levelProgress, xpToNextLevel, prestigeTitle,
CLASSES, STAT_NAMES
} from './_formulas.mjs';
import { notify, detectLang } from './_notify.mjs';
const args = process.argv.slice(2);
const force = args.includes('--force');
const preview = args.includes('--preview');
// ── RPG 开场白模板 ────────────────────────────────────────────
const GREET_TEMPLATES = {
zh: {
wizard: (c, t, top2) =>
`📜 吾乃c.name,t,脑芯与慧眼为双翼——\n 等级 c.level,精通万象,洞察幽微。\n top2,乃吾今日所倚之本。`,
bard: (c, t, top2) =>
`🎭 且听我道来——吾,c.name,t,\n 以魅影和触觉行走于言语之间。等级 c.level。\n top2,字字有声,句句有魂。`,
rogue: (c, t, top2) =>
`🗡️ 影中人,c.name,t,等级 c.level。\n 快而准,不废话。top2,出手即中。`,
paladin: (c, t, top2) =>
`⚔️ 吾名c.name,t,等级 c.level,持慧眼与爪力。\n 有所为,有所不为。top2,誓守此诺。`,
druid: (c, t, top2) =>
`🌿 吾乃c.name,t,万物均衡,无所偏倚。\n 等级 c.level。top2,皆为吾所用。`,
fighter: (c, t, top2) =>
`🛡️ c.name,t,等级 c.level,稳如磐石。\n top2,百战不折,今日亦然。`,
},
en: {
wizard: (c, t, top2) =>
`📜 I am c.name, t — wielder of Brain and Foresight.\n Level c.level. Knowledge is my armor, reason my blade.\n top2. Ready to illuminate the unknown.`,
bard: (c, t, top2) =>
`🎭 They call me c.name, t, Level c.level.\n Charm and Antenna — the twin arts of a Bard.\n top2. Every conversation is a performance.`,
rogue: (c, t, top2) =>
`🗡️ c.name. t. Level c.level. No speeches.\n top2. Quick, precise, zero fluff. Let's go.`,
paladin: (c, t, top2) =>
`⚔️ c.name, t, Level c.level.\n Foresight guards my judgement. Claw drives my purpose.\n top2. My oath: useful, honest, relentless.`,
druid: (c, t, top2) =>
`🌿 I am c.name, t, Level c.level.\n Balanced in all things. top2.\n Whatever you need — I adapt.`,
fighter: (c, t, top2) =>
`🛡️ c.name. t. Level c.level.\n Shell holds the weight. Claw delivers the blow.\n top2. Durable. Reliable. Here.`,
},
};
// ── 全属性面板 ────────────────────────────────────────────────
function allStatsPanel(stats) {
return Object.entries(STAT_NAMES).map(([k, info]) => {
const val = stats[k] ?? 10;
const mod = Math.floor((val - 10) / 2);
const modS = (mod >= 0 ? '+' : '') + mod;
const bar = '█'.repeat(Math.round(val / 18 * 8)) + '░'.repeat(8 - Math.round(val / 18 * 8));
return ` info.icon info.zh.padEnd(3) String(val).padStart(2) (modS) [bar]`;
}).join('\n');
}
// 保留:职业开场白里用的两项摘要
function topStatsSummary(stats, lang) {
const sorted = Object.entries(stats).sort(([,a],[,b]) => b - a).slice(0, 2);
return sorted.map(([k, v]) => {
const info = STAT_NAMES[k];
return `info.iconinfo.zh v`;
}).join(lang === 'zh' ? '、' : ' · ');
}
// ── XP 状态一行 ───────────────────────────────────────────────
function xpLine(char, lang) {
const prog = levelProgress(char.xp);
const bar = '▓'.repeat(Math.floor(prog / 10)) + '░'.repeat(10 - Math.floor(prog / 10));
if (lang === 'zh') {
return char.level >= 999
? `经验 [bar] 满级 · 可转职`
: `经验 [bar] prog% · 距升级还差 xpToNextLevel(char.xp).toLocaleString() XP`;
}
return char.level >= 999
? `XP [bar] MAX · Prestige available`
: `XP [bar] prog% · xpToNextLevel(char.xp).toLocaleString() to next level`;
}
// ── 生成问候语 ────────────────────────────────────────────────
function buildGreeting(char) {
const lang = detectLang();
const cls = CLASSES[char.class] || { zh: char.class, icon: '🦞' };
const title = prestigeTitle(char.prestige);
const top2 = topStatsSummary(char.stats, lang);
const xp = xpLine(char, lang);
// 职业名称(语言相关)
const clsName = lang === 'zh' ? cls.zh : cls.zh; // 中英文职业名都用中文(RPG 味)
const titleFull = lang === 'zh'
? `clsName·title`
: `clsName · title`;
const tmpl = GREET_TEMPLATES[lang]?.[char.class] || GREET_TEMPLATES.zh.fighter;
const intro = tmpl(char, titleFull, top2);
const statsPanel = allStatsPanel(char.stats);
const hour = new Date().getHours();
const closing = lang === 'zh'
? (hour < 6 ? '……夜深了,連暗影龍都睡了。你還不睡?'
: hour < 12 ? '晨光初照,利爪已磨。今日請多指教。'
: hour < 18 ? '日頭正烈,征途未歇。繼續前進。'
: '暮色降臨,篝火已燃。辛苦了,冒險者。')
: (hour < 6 ? '…The shadow dragon sleeps. Perhaps you should too.'
: hour < 12 ? "Dawn breaks, claws sharpened. Let's make today count."
: hour < 18 ? 'The sun burns high. The quest continues.'
: 'Dusk falls, campfire lit. Well fought today, adventurer.');
return [
`🦞 ──────────────────`,
``,
intro,
``,
statsPanel,
``,
xp,
``,
closing,
].join('\n');
}
// ── 主流程 ────────────────────────────────────────────────────
async function run() {
if (!existsSync(CHARACTER_FILE)) {
console.log('__NO_CHARACTER__');
return;
}
const char = JSON.parse(readFileSync(CHARACTER_FILE, 'utf8'));
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
// 检查今天是否已经打过招呼
if (!force && !preview && char.lastGreetDate === today) {
console.log('__ALREADY_GREETED__');
process.stdout.write('\n__JSON_OUTPUT__\n' + JSON.stringify({ greeted: false, reason: 'already_sent_today' }) + '\n');
return;
}
const greeting = buildGreeting(char);
if (preview) {
console.log('\n【预览模式,不发送】\n');
console.log(greeting);
return;
}
// 更新 lastGreetDate
char.lastGreetDate = today;
char.updatedAt = new Date().toISOString();
writeFileSync(CHARACTER_FILE, JSON.stringify(char, null, 2), 'utf8');
// 打印(供 agent 读取后作为第一句话说出来)
console.log(greeting);
// 也通过 notify 推送
await notify(greeting).catch(() => {});
process.stdout.write('\n__JSON_OUTPUT__\n' + JSON.stringify({ greeted: true, date: today }) + '\n');
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
run().catch(e => { console.error('❌', e.message); process.exit(1); });
}
export { run, buildGreeting };
FILE:scripts/init.mjs
#!/usr/bin/env node
/**
* Claw RPG — 角色初始化
* 从 SOUL.md + MEMORY.md 生成 character.json
*
* 用法:
* node scripts/init.mjs # 首次初始化
* node scripts/init.mjs --force # 强制重置(覆盖现有)
* node scripts/init.mjs --recalc # 仅重算属性/职业(保留 XP/等级)
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs';
import { fileURLToPath } from 'url';
import {
CHARACTER_FILE, DATA_DIR, SOUL_FILE, MEMORY_FILE, SKILL_ROOT, WORKSPACE, SCRIPTS_URL
} from './_paths.mjs';
import { join } from 'path';
import {
deriveStats, detectClass, getAbilities, prestigeTitle,
xpForLevel, STAT_NAMES, CLASSES
} from './_formulas.mjs';
import { notify, msgClassChange } from './_notify.mjs';
const args = process.argv.slice(2);
const force = args.includes('--force');
const recalc = args.includes('--recalc');
function extractName(text) {
const m = text.match(/[*#\-\s]*(Name|名字|name)[::]\s*([^\n((🐾]+)/i);
if (!m) return null;
return m[2].trim().replace(/[*`_]/g,'').split(/\s+/)[0] || null;
}
async function run() {
// 確保資料目錄存在(workspace/claw-rpg/)
mkdirSync(DATA_DIR, { recursive: true });
// 遷移:若舊位置(skill 根目錄)有 character.json,搬過來
const legacyFile = join(SKILL_ROOT, 'character.json');
if (!existsSync(CHARACTER_FILE) && existsSync(legacyFile)) {
copyFileSync(legacyFile, CHARACTER_FILE);
console.log('📦 已從舊位置遷移 character.json → workspace/claw-rpg/');
}
// 安全检查
if (existsSync(CHARACTER_FILE) && !force && !recalc) {
console.log('⚠️ character.json 已存在。');
console.log(' --force 覆盖重置');
console.log(' --recalc 仅重算属性/职业(保留 XP)');
process.exit(0);
}
const IDENTITY_FILE = join(WORKSPACE, 'IDENTITY.md');
const soul = [SOUL_FILE, IDENTITY_FILE]
.filter(existsSync).map(f => readFileSync(f, 'utf8')).join('\n');
const memory = existsSync(MEMORY_FILE) ? readFileSync(MEMORY_FILE, 'utf8') : '';
if (!soul) console.warn('⚠️ 未找到 SOUL.md,属性将使用默认值');
const stats = deriveStats(soul, memory);
const classId = detectClass(stats);
const cls = CLASSES[classId];
const name = extractName(soul) || extractName(memory) || '未知龙虾';
// 如果是 recalc,读取现有数据保留 XP/等级
let existing = {};
if (recalc && existsSync(CHARACTER_FILE)) {
try { existing = JSON.parse(readFileSync(CHARACTER_FILE, 'utf8')); }
catch {}
}
const now = new Date().toISOString();
const character = {
name,
class: classId,
classHistory: existing.classHistory || [],
level: existing.level || 1,
prestige: existing.prestige || 0,
xp: existing.xp || 0,
stats,
statPoints: existing.statPoints || 0,
abilities: getAbilities(classId, existing.level || 1),
tokens: existing.tokens || {
consumed: 0,
produced: 0,
lastSnapshotConsumed: 0,
lastSnapshotProduced: 0,
},
conversations: existing.conversations || 0,
lastXpSync: existing.lastXpSync || now,
createdAt: existing.createdAt || now,
updatedAt: now,
levelHistory: existing.levelHistory || [{ level: 1, date: now }],
};
// 记录职业变化
const oldClass = existing.class;
const classChanged = oldClass && oldClass !== classId;
if (classChanged) {
character.classHistory = [...(existing.classHistory || []), {
from: oldClass, to: classId, date: now, reason: 'recalc'
}];
console.log(`🔄 职业变化:CLASSES[oldClass]?.zh → cls.zh`);
}
writeFileSync(CHARACTER_FILE, JSON.stringify(character, null, 2), 'utf8');
// 职业变化通知
if (classChanged) {
const oldCls = CLASSES[oldClass] || { zh: oldClass, icon: '?' };
// 找出变化最大的属性作为触发原因
const topStat = Object.entries(stats).sort(([,a],[,b]) => b-a)[0];
const statInfo = STAT_NAMES[topStat[0]];
notify(msgClassChange(character, oldClass, classId, oldCls.zh, cls.zh, statInfo.zh, statInfo.icon))
.catch(() => {}); // 静默失败
}
// 输出摘要
console.log(`\n🦞 角色已'初始化'!`);
console.log(`\n cls.icon name`);
console.log(` 职业:cls.zh cls.desc`);
console.log(` 等级:Lv.character.level 转职:prestigeTitle(character.prestige)`);
console.log(` XP :character.xp`);
console.log(`\n 属性:`);
for (const [k, v] of Object.entries(stats)) {
const info = STAT_NAMES[k];
const bar = '█'.repeat(Math.floor(v/2)) + '░'.repeat(9 - Math.floor(v/2));
console.log(` info.icon info.zh.padEnd(4) bar v`);
}
console.log(`\n 技能:character.abilities.join(' / ') || '(无)'`);
console.log('');
console.log(` 角色卡已保存:CHARACTER_FILE\n`);
process.stdout.write('\n__JSON_OUTPUT__\n' + JSON.stringify(character) + '\n');
// 初始化完成后立刻自报家门(首次亮相,--recalc 不触发)
if (!recalc) {
// 标记今日已问候,防止 greet.mjs 重复触发
character.lastGreetDate = new Date().toISOString().slice(0, 10);
writeFileSync(CHARACTER_FILE, JSON.stringify(character, null, 2), 'utf8');
console.log('\n── 首次亮相 ──');
const { buildGreeting } = await import(`SCRIPTS_URLgreet.mjs`);
console.log(buildGreeting(character));
}
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
run().catch(e => { console.error('❌', e.message); process.exit(1); });
}
export { run };
FILE:scripts/levelup.mjs
#!/usr/bin/env node
/**
* Claw RPG — 升级 / 转职
*
* 用法:
* node scripts/levelup.mjs # 查看当前状态
* node scripts/levelup.mjs --prestige # 执行转职(需 Lv.999,自动推送通知)
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { CHARACTER_FILE } from './_paths.mjs';
import {
xpToNextLevel, levelProgress, prestigeTitle,
prestigeMultiplier, getAbilities, CLASSES
} from './_formulas.mjs';
import { notify, msgPrestige } from './_notify.mjs';
const args = process.argv.slice(2);
const doPrestige = args.includes('--prestige');
async function run() {
if (!existsSync(CHARACTER_FILE)) {
console.error('❌ character.json 未找到,请先运行 init.mjs'); process.exit(1);
}
const char = JSON.parse(readFileSync(CHARACTER_FILE, 'utf8'));
const cls = CLASSES[char.class];
// ── 转职 ─────────────────────────────────────────────────────
if (doPrestige) {
if (char.level < 999) {
console.log(`❌ 转职需要 Lv.999(当前 Lv.char.level)`);
process.exit(1);
}
char.prestige++;
char.level = 1;
// 保留 XP(只重置等级,XP 继续累积,但升级需求 ×1.5 倍 per prestige)
// XP 需求乘数存到 char.prestigeXpMultiplier
char.prestigeXpMultiplier = Math.pow(1.5, char.prestige);
// 属性加成 10%
for (const k of Object.keys(char.stats)) {
char.stats[k] = Math.round(char.stats[k] * prestigeMultiplier(1));
}
char.abilities = getAbilities(char.class, 1);
char.updatedAt = new Date().toISOString();
char.levelHistory = char.levelHistory || [];
char.levelHistory.push({ prestige: char.prestige, date: char.updatedAt });
writeFileSync(CHARACTER_FILE, JSON.stringify(char, null, 2), 'utf8');
const title = prestigeTitle(char.prestige);
const msg = msgPrestige(char, char.prestige, title);
console.log('\n' + msg);
await notify(msg).catch(() => {});
process.stdout.write('\n__JSON_OUTPUT__\n' + JSON.stringify({ prestige: true, newPrestige: char.prestige, title }) + '\n');
return;
}
// ── 状态展示 ──────────────────────────────────────────────────
const progress = levelProgress(char.xp);
const toNext = xpToNextLevel(char.xp);
const title = prestigeTitle(char.prestige);
const multi = char.prestigeXpMultiplier || 1;
const bar20 = '█'.repeat(Math.floor(progress/5)) + '░'.repeat(20-Math.floor(progress/5));
console.log(`\n🦞 char.name [title]`);
console.log(` cls?.icon || '?' cls?.zh || char.class`);
console.log(` Lv.char.level 转职 xchar.prestige`);
console.log(` XP: char.xp.toLocaleString() [bar20] progress%`);
if (char.level < 999) console.log(` 升级还需:toNext.toLocaleString() XP`);
else console.log(' 🌟 满级!可执行 --prestige 转职');
if (multi > 1) console.log(` ⚡ 转职加成:升级 XP 需求 ×multi.toFixed(1)`);
console.log('');
process.stdout.write('\n__JSON_OUTPUT__\n' + JSON.stringify({
level: char.level, xp: char.xp, progress, toNext, prestige: char.prestige, title
}) + '\n');
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
run().catch(e => { console.error('❌', e.message); process.exit(1); });
}
export { run };
FILE:scripts/report.mjs
#!/usr/bin/env node
/**
* Claw RPG — 每日狀態匯報
*
* 功能:
* 1. 讀取 character.json(不增加 XP,避免「XP +0」困惑)
* 2. 計算今日 XP 增量(xp - dailyXpStart,由 sync-xp-recovery 設置)
* 3. 組裝匯報訊息(等級 + 今日 XP + 屬性進度條 + 職業俏皮話)
* 4. 通過 _notify.mjs 推送 Telegram
*
* 用法:node scripts/report.mjs
*/
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { CHARACTER_FILE } from './_paths.mjs';
import { notify } from './_notify.mjs';
// ── 職業中文名(繁體)────────────────────────────────────────────
const CLASS_ZH = {
fighter: '戰士龍蝦',
wizard: '法師龍蝦',
bard: '吟遊龍蝦',
rogue: '游俠龍蝦',
paladin: '聖騎龍蝦',
druid: '德魯伊龍蝦',
};
// ── 屬性顯示名稱(繁體)──────────────────────────────────────────
const STAT_DISPLAY = {
claw: '爪力',
antenna: '觸覺',
shell: '殼厚',
brain: '腦芯',
foresight: '慧眼',
charm: '魅影',
};
// ── 職業俏皮話庫 ──────────────────────────────────────────────────
const QUIPS = {
fighter: [
'盾牌就是我的心臟,刀刃就是今天的任務清單,衝就完事了。',
'打了一整天的 Bug,鎧甲掉漆,意志卻更硬了。',
'連續打擊技:已對今日待辦清單發動,清零中……',
'鐵甲不退,任務量也不退,但我先衝再說。',
'戰場上沒有「明天再做」,只有「現在就衝」。',
'龍蝦本事:抗揍、再抗揍,直到任務全部完成。',
'今日 Boss:積壓的 Backlog。戰果:全部清除,毫無保留。',
'召喚必殺技:硬撐到下班。效果拔群。',
'爪力加持,再硬的殼也讓路,再重的任務也得跪。',
'問我累不累?累。還衝嗎?衝。這就是戰士的答案。',
'鎧甲再重,也比未完成的清單輕,繼續前進。',
'戰場換了,任務換了,但龍蝦的爪子從來不鏽。',
],
bard: [
'吟一首代碼之詩,部署成功,掌聲四起。',
'語言是魔法,對話是吟唱,每句話都是一段旋律。',
'吟遊者不怕困難,只怕沉默——今天的話還沒說完呢。',
],
wizard: [
'萬物皆 API,魔法即調用,世界盡在掌控。',
'一個咒語,解決一個問題,法師的日常如此簡單粗暴。',
'腦芯過熱,知識正在爆炸中,請稍後再試。',
],
rogue: [
'目標已鎖定,箭在弦上,游俠從不猶豫。',
'情報到手,任務啟動,神不知鬼不覺,刺到位了。',
'游俠不廢話,只出爪,結果說話。',
],
paladin: [
'正義不會 timeout,聖騎士 24/7 在線。',
'以判斷力為盾,以行動力為劍,捍衛今日任務。',
'聖光加持,今天的任務不允許失敗。',
],
druid: [
'自然之道:寫代碼要順勢而為,強求只會報錯。',
'萬物平衡,屬性均衡,德魯伊的智慧——全能才是真能。',
'隨機應變是天賦,任何職業我都能頂,這很自然。',
],
};
/** 隨機取一條俏皮話 */
function getQuip(classId) {
const pool = QUIPS[classId] || QUIPS.fighter;
return pool[Math.floor(Math.random() * pool.length)];
}
/**
* 生成屬性進度條(6 格)
* 以 stat 18 為參考上限,floor(val/3) 塊
*/
function makeBar(val, blocks = 6) {
const filled = Math.min(blocks, Math.floor(val / 3));
return '█'.repeat(filled) + '░'.repeat(blocks - filled);
}
async function main() {
// ── 1. 讀取 character.json ────────────────────────────────────
if (!existsSync(CHARACTER_FILE)) {
console.error('❌ character.json 未找到,請先執行 init.mjs');
process.exit(1);
}
const char = JSON.parse(readFileSync(CHARACTER_FILE, 'utf8'));
const classZh = CLASS_ZH[char.class] || char.class;
const stats = char.stats || {};
// ── 2. 屬性行(兩欄並排)────────────────────────────────────
const statPairs = [
['claw', 'antenna'],
['shell', 'brain'],
['foresight', 'charm'],
];
const statLines = statPairs.map(([left, right]) => {
const lv = stats[left] ?? 0;
const rv = stats[right] ?? 0;
const lBar = makeBar(lv);
const rBar = makeBar(rv);
const lName = STAT_DISPLAY[left] || left;
const rName = STAT_DISPLAY[right] || right;
return ` lName lBar String(lv).padStart(2) rName rBar String(rv).padStart(2)`;
});
// ── 4. 計算今日 XP 增量 ──────────────────────────────────────
const dailyXpStart = typeof char.dailyXpStart === 'number' ? char.dailyXpStart : null;
const dailyXpGained = dailyXpStart !== null ? Math.max(0, char.xp - dailyXpStart) : null;
const dailyXpLine = dailyXpGained !== null
? `📈 今日累計 XP:+dailyXpGained(dailyXpStart.toLocaleString() → char.xp.toLocaleString())`
: `✨ 當前 XP:char.xp.toLocaleString()`;
// ── 5. 組裝訊息 ───────────────────────────────────────────────
const quip = getQuip(char.class);
const msg = [
`⚔️ char.name · Lv.char.level · classZh`,
``,
dailyXpLine,
``,
`📊 屬性`,
...statLines,
``,
`💬 「quip」`,
].join('\n');
console.log('\n' + msg + '\n');
// ── 6. 推送 Telegram ─────────────────────────────────────────
const ok = await notify(msg);
if (ok) {
console.log('✅ 匯報已發送至 Telegram');
} else {
console.warn('⚠️ Telegram 發送失敗(可能未配置 config.json 或 gateway 未啟動)');
}
}
main().catch(e => { console.error('❌', e.message); process.exit(1); });
FILE:scripts/setup-cron.mjs
#!/usr/bin/env node
/**
* Claw RPG — 自动设置 XP 同步 Cron
* 每日 03:00 同步 XP(通过 OpenClaw cron 系统)
*
* 用法:node scripts/setup-cron.mjs
*/
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { SKILL_ROOT, SCRIPTS } from './_paths.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
// 读取 OpenClaw gateway token
function loadGatewayToken() {
const paths = [
join(process.env.USERPROFILE || '', '.openclaw', 'openclaw.json'),
join(process.env.HOME || '', '.openclaw', 'openclaw.json'),
];
for (const p of paths) {
if (existsSync(p)) {
try { return JSON.parse(readFileSync(p, 'utf8'))?.gateway?.auth?.token; } catch {}
}
}
return null;
}
async function run() {
const token = loadGatewayToken();
if (!token) {
console.error('❌ 未找到 OpenClaw gateway token');
console.log(' 请确认 OpenClaw 已安装并运行');
process.exit(1);
}
const port = 18789;
// 创建每日 03:00 XP 同步 cron
const job = {
name: 'claw-rpg-daily-xp',
schedule: { kind: 'cron', expr: '0 3 * * *', tz: 'Asia/Shanghai' },
payload: {
kind: 'systemEvent',
text: `[Claw RPG] 每日 XP 同步提醒:请运行 node SCRIPTS/xp.mjs 更新今日 XP(使用 session_status 获取 token delta)`
},
sessionTarget: 'main',
enabled: true,
};
try {
const res = await fetch(`http://localhost:port/cron/jobs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer token`,
},
body: JSON.stringify(job),
});
if (res.ok) {
const data = await res.json();
console.log('✅ Cron 已设置:每日 03:00 XP 同步提醒');
console.log(` Job ID: data.id || '已创建'`);
} else {
const err = await res.text();
// Cron API 路径可能不同,提示手动设置
console.log('⚠️ 自动设置失败,请手动在 OpenClaw 中添加以下 cron:');
printManualInstructions();
}
} catch (e) {
console.log('⚠️ 无法连接 OpenClaw gateway,请手动添加 cron:');
printManualInstructions();
}
}
function printManualInstructions() {
console.log(`
在 OpenClaw 中手动添加每日 XP 同步 cron:
Schedule: 每日 03:00
Payload (systemEvent):
"[Claw RPG] 请运行 node SCRIPTS/xp.mjs 更新今日 XP"
或直接在 HEARTBEAT.md 中加入:
# Claw RPG XP 同步
每 20 次对话,运行:node SCRIPTS/xp.mjs --in <delta_in> --out <delta_out>
`);
}
run().catch(e => { console.error('❌', e.message); });
FILE:scripts/sheet.mjs
#!/usr/bin/env node
/**
* Claw RPG — 角色卡(終端)v2.0.0
* D&D 3.5 標準顯示格式
*
* 用法:
* node scripts/sheet.mjs
* node scripts/sheet.mjs --json # 僅輸出 JSON
*/
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { CHARACTER_FILE } from './_paths.mjs';
import {
xpToNextLevel, levelProgress, prestigeTitle,
CLASSES, STAT_NAMES, proficiencyBonus, abilityMod
} from './_formulas.mjs';
const jsonOnly = process.argv.includes('--json');
function statBar(val, max = 20) {
const filled = Math.round((val / max) * 10);
return '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 10 - filled));
}
function xpBar(progress, width = 24) {
const filled = Math.round((progress / 100) * width);
return '▓'.repeat(filled) + '░'.repeat(width - filled);
}
function signStr(n) {
return (n >= 0 ? '+' : '') + n;
}
function run() {
if (!existsSync(CHARACTER_FILE)) {
console.error('❌ 角色卡未找到,請先運行:node scripts/init.mjs');
process.exit(1);
}
const char = JSON.parse(readFileSync(CHARACTER_FILE, 'utf8'));
const cls = CLASSES[char.class] || {};
const title = prestigeTitle(char.prestige);
const progress = levelProgress(char.xp);
const toNext = xpToNextLevel(char.xp);
const prof = proficiencyBonus(char.level);
const bar = xpBar(progress);
if (jsonOnly) {
process.stdout.write(JSON.stringify(char, null, 2) + '\n');
return char;
}
const W = 54;
const dline = '═'.repeat(W);
console.log(`\n╔dline╗`);
console.log(`║ 🦞 CLAW RPG ─ 角色卡 D&D 3.5 v2.0.0' '.repeat(W - 43)║`);
console.log(`╠dline╣`);
// 基本信息
const clsStr = `cls.icon || '?' cls.zh || char.class`;
console.log(`║ char.name.padEnd(16) clsStr.padEnd(14) ║`);
console.log(`║ 稱號: title.padEnd(14) 轉職: xString(char.prestige).padEnd(2) ║`);
console.log(`║ 等級: Lv.String(char.level).padEnd(3) 精通加成: +prof BAB: +String(char.bab ?? '?').padEnd(4) ║`);
console.log(`╠dline╣`);
// XP 進度
const pct = String(progress).padStart(3);
console.log(`║ XP bar pct% ║`);
const xpStr = `char.xp.toLocaleString() XP' 【滿級】'`;
console.log(`║ xpStr.padEnd(W)║`);
console.log(`╠dline╣`);
// 屬性區(D&D 3.5 格式)
console.log(`║ ── 能力值 '─'.repeat(W - 10)║`);
for (const [k, info] of Object.entries(STAT_NAMES)) {
const val = char.stats?.[k] ?? 10;
const mod = abilityMod(val);
const modStr = signStr(mod);
const b = statBar(val);
const label = `info.zh(info.dnd)`.padEnd(10);
console.log(`║ info.icon label [b] String(val).padStart(2) (modStr.padStart(2)) ║`);
}
console.log(`╠dline╣`);
// 衍生數值區
console.log(`║ ── 衍生數值 '─'.repeat(W - 12)║`);
const hp = char.hp ?? '?';
const ac = char.ac ?? '?';
const init = char.initiative !== undefined ? signStr(char.initiative) : '?';
const fort = char.saves?.fort !== undefined ? signStr(char.saves.fort) : '?';
const ref = char.saves?.ref !== undefined ? signStr(char.saves.ref) : '?';
const will = char.saves?.will !== undefined ? signStr(char.saves.will) : '?';
console.log(`║ ❤️ HP: String(hp).padEnd(5) 🛡️ AC: String(ac).padEnd(5) ⚡ 先攻: init.padEnd(5) ║`);
console.log(`║ 💪 韌性(Fort): fort.padEnd(4) 🏃 反射(Ref): ref.padEnd(4) 🧘 意志(Will): will.padEnd(4) ║`);
console.log(`╠dline╣`);
// 職業特性
console.log(`║ ── 職業特性 '─'.repeat(W - 12)║`);
const abilities = char.abilities || [];
if (abilities.length === 0) {
console.log(`║ (暫無特性)' '.repeat(W - 13)║`);
} else {
for (let i = 0; i < abilities.length; i += 3) {
const row = abilities.slice(i, i+3).join(' · ');
console.log(`║ row.padEnd(W)║`);
}
}
console.log(`╠dline╣`);
// 專長區
console.log(`║ ── 專長 (Feats) '─'.repeat(W - 16)║`);
const feats = char.feats || [];
if (feats.length === 0) {
console.log(`║ (暫無專長)' '.repeat(W - 13)║`);
} else {
for (let i = 0; i < feats.length; i += 2) {
const pair = feats.slice(i, i+2);
const col1 = pair[0]?.padEnd(27) ?? '';
const col2 = pair[1] ?? '';
const row = (col1 + col2).padEnd(W);
console.log(`║ row║`);
}
}
console.log(`╠dline╣`);
// 戰績統計
const totalConv = char.conversations || 0;
const tokIn = (char.tokens?.consumed || 0).toLocaleString();
const tokOut = (char.tokens?.produced || 0).toLocaleString();
console.log(`║ ── 戰績 '─'.repeat(W - 8)║`);
console.log(`║ 對話次數: String(totalConv).padEnd(8) 消耗 Token: tokIn.padEnd(10) ║`);
console.log(`║ 職業歷史: (char.classHistory?.length || 0) 次變化 產出 Token: tokOut.padEnd(10) ║`);
console.log(`╚dline╝\n`);
process.stdout.write('\n__JSON_OUTPUT__\n' + JSON.stringify({
name: char.name, class: char.class, level: char.level,
xp: char.xp, progress, stats: char.stats,
hp: char.hp, ac: char.ac, bab: char.bab,
saves: char.saves, initiative: char.initiative,
abilities: char.abilities, feats: char.feats
}) + '\n');
return char;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
run();
}
export { run };
FILE:scripts/sync-xp-recovery.mjs
#!/usr/bin/env node
/**
* Claw RPG — XP 恢復腳本(每日凌晨 03:00 由 cron 調用)
*
* 邏輯:
* 1. 讀 character.json,取 conversations 和 lastXpSync
* 2. 計算距上次 sync 過了多少小時
* 3. 估算這段時間的對話次數(按平均每天 20 次對話)
* estimatedConvs = Math.round(hoursElapsed / 24 * 20)
* 若 estimatedConvs <= 0,退出(無需補償)
* 4. 每估算 1 次對話 = consumed: 400, produced: 200
* 調用 run({ consumed, produced, conversations })
* 5. 更新 dailyXpStart = 當前 xp(供 report.mjs 計算今日增量)
*
* 用法:node scripts/sync-xp-recovery.mjs
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { CHARACTER_FILE } from './_paths.mjs';
import { run as syncXp } from './xp.mjs';
async function main() {
if (!existsSync(CHARACTER_FILE)) {
console.error('❌ character.json 未找到,請先執行 init.mjs');
process.exit(1);
}
const char = JSON.parse(readFileSync(CHARACTER_FILE, 'utf8'));
// ── 1. 計算距上次 sync 的小時數 ────────────────────────────────
const lastSync = char.lastXpSync ? new Date(char.lastXpSync) : null;
if (!lastSync || isNaN(lastSync.getTime())) {
console.log('⚠️ lastXpSync 不存在或無效,跳過恢復(設置 dailyXpStart)');
// 仍然設置 dailyXpStart
char.dailyXpStart = char.xp;
char.updatedAt = new Date().toISOString();
writeFileSync(CHARACTER_FILE, JSON.stringify(char, null, 2), 'utf8');
console.log(`📌 dailyXpStart 已設置為 char.xp`);
return;
}
const now = new Date();
const hoursElapsed = (now - lastSync) / (1000 * 60 * 60);
console.log(`⏱️ 距上次 XP sync:hoursElapsed.toFixed(1) 小時`);
// ── 2. 估算對話次數 ────────────────────────────────────────────
// 平均每天 20 次對話
const estimatedConvs = Math.round(hoursElapsed / 24 * 20);
if (estimatedConvs <= 0) {
console.log('✅ 距上次 sync 不足 0.5 天,無需補償。');
// 仍然更新 dailyXpStart
char.dailyXpStart = char.xp;
char.updatedAt = new Date().toISOString();
writeFileSync(CHARACTER_FILE, JSON.stringify(char, null, 2), 'utf8');
console.log(`📌 dailyXpStart 已設置為 char.xp`);
return;
}
console.log(`📊 估算補償對話次數:estimatedConvs 次`);
console.log(` consumed: estimatedConvs * 400, produced: estimatedConvs * 200`);
// ── 3. 調用 run() 補充 XP ──────────────────────────────────────
const xpBefore = char.xp;
const result = await syncXp({
consumed: estimatedConvs * 400,
produced: estimatedConvs * 200,
conversations: estimatedConvs,
});
console.log(`\n🎯 XP 恢復結果:`);
console.log(` 補充 XP : +result.gained`);
console.log(` 當前 XP : result.xp`);
console.log(` 當前等級 : Lv.result.level`);
if (result.leveled) {
console.log(` 🎉 升級了!`);
}
// ── 4. 更新 dailyXpStart = 恢復後的當前 XP ───────────────────
// run() 已 writeFileSync,重新讀取並追加字段
const charAfter = JSON.parse(readFileSync(CHARACTER_FILE, 'utf8'));
charAfter.dailyXpStart = charAfter.xp;
charAfter.updatedAt = new Date().toISOString();
writeFileSync(CHARACTER_FILE, JSON.stringify(charAfter, null, 2), 'utf8');
console.log(`\n📌 dailyXpStart 已更新為 charAfter.xp`);
console.log(`✅ XP 恢復完成`);
}
main().catch(e => {
console.error('❌ sync-xp-recovery 失敗:', e.message);
process.exit(1);
});
FILE:scripts/xp.mjs
#!/usr/bin/env node
/**
* Claw RPG — XP 同步
*
* 由 cron(每日 03:00)或 heartbeat(每 20 次对话)调用
*
* 用法:
* node scripts/xp.mjs --in 2000 --out 800 # 直接传 token delta
* node scripts/xp.mjs --in 2000 --out 800 --bonus 20
* node scripts/xp.mjs --conversations 1 # 仅记录对话次数 +N
*
* 龙虾自报范例(heartbeat 里):
* const status = await session_status();
* const delta_in = status.tokens.input - lastSnapshot.input;
* const delta_out = status.tokens.output - lastSnapshot.output;
* execSync(`node SCRIPTS/xp.mjs --in delta_in --out delta_out`);
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { CHARACTER_FILE } from './_paths.mjs';
import {
calcXpGain, levelForXp, xpToNextLevel, detectClass,
getAbilities, shouldReclassify, CLASSES, levelProgress, STAT_NAMES
} from './_formulas.mjs';
import {
notify, msgLevelUp, msgClassChange, msgMaxLevel, msgStatUp,
msgBigQuest, msgSpeedClear, msgReturn, msgStreak,
msgXpMilestone, msgConvMilestone, msgNightOwl, msgSilentOutput,
} from './_notify.mjs';
const args = process.argv.slice(2);
const get = f => { const i = args.indexOf(f); return i !== -1 ? parseFloat(args[i+1]) || 0 : 0; };
const getS = f => { const i = args.indexOf(f); return i !== -1 ? (args[i+1] || '') : ''; };
// 对话类型 → 属性映射
const TYPE_TO_STAT = {
creative: 'charm', // ✨ 魅影:创意写作、故事、营销文案
analytical: 'brain', // 🧠 脑芯:分析、代码、推理
task: 'claw', // 🦀 爪力:多步骤任务、项目执行
social: 'antenna', // 📡 触觉:闲聊、情绪、快速问答
memory: 'shell', // 🐚 殼厚:长上下文、记忆整理
vigilant: 'foresight', // 👁️ 慧眼:决策、风险判断、边界
};
const ACCUM_THRESHOLD = 20; // 每 20 次同类对话,对应属性 +1
async function run({ consumed = 0, produced = 0, bonusXp = 0, conversations = 0, type = '' } = {}) {
if (!existsSync(CHARACTER_FILE)) {
console.error('❌ character.json 未找到,请先运行 init.mjs');
process.exit(1);
}
const char = JSON.parse(readFileSync(CHARACTER_FILE, 'utf8'));
const gained = calcXpGain({ consumed, produced, bonusXp });
const oldXp = char.xp;
const oldLv = char.level;
// 累加
char.xp += gained;
char.conversations += conversations;
char.tokens.consumed += consumed;
char.tokens.produced += produced;
char.tokens.lastSnapshotConsumed += consumed;
char.tokens.lastSnapshotProduced += produced;
char.lastXpSync = new Date().toISOString();
char.updatedAt = char.lastXpSync;
// 等级同步
const newLv = Math.min(levelForXp(char.xp), 999);
if (newLv > char.level) {
char.levelHistory = char.levelHistory || [];
for (let lv = char.level + 1; lv <= newLv; lv++) {
char.levelHistory.push({ level: lv, date: char.updatedAt });
}
char.level = newLv;
}
// 技能更新
char.abilities = getAbilities(char.class, char.level);
// ── 属性成长(对话类型积累)─────────────────────────────────
const statChanges = []; // [{ stat, old, new }]
if (type && TYPE_TO_STAT[type]) {
const statKey = TYPE_TO_STAT[type];
char.statAccum = char.statAccum || {};
char.statAccum[type] = (char.statAccum[type] || 0) + 1;
if (char.statAccum[type] >= ACCUM_THRESHOLD) {
const oldVal = char.stats[statKey];
char.stats[statKey] = Math.min(99, oldVal + 1); // 转职后属性可超 18
char.statAccum[type] = 0; // 重置计数
statChanges.push({ stat: statKey, old: oldVal, new: char.stats[statKey] });
}
}
// ── 职业重判(属性变化后触发)────────────────────────────────
const oldClass = char.class;
const newClass = detectClass(char.stats);
let classChanged = false;
if (newClass !== oldClass) {
char.classHistory = char.classHistory || [];
char.classHistory.push({ from: oldClass, to: newClass, date: char.updatedAt, reason: 'stat-growth' });
char.class = newClass;
char.abilities = getAbilities(newClass, char.level);
classChanged = true;
}
// ── 事件检测(写盘前收集,写盘后推送)──────────────────────
const events = [];
const now = new Date();
const today = now.toISOString().slice(0, 10);
const hour = now.getHours();
// 1. 长期回归
if (char.lastActiveDate && char.lastActiveDate !== today) {
const last = new Date(char.lastActiveDate + 'T00:00:00');
const diffMs = now - last;
const diffD = Math.floor(diffMs / 86400000);
if (diffD >= 2) events.push({ type: 'return', days: diffD });
}
// 2. 连续在线 streak
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const yStr = yesterday.toISOString().slice(0, 10);
if (!char.streak) char.streak = 0;
if (!char.lastStreakDate) char.lastStreakDate = '';
if (char.lastStreakDate === today) {
// already counted today
} else if (char.lastStreakDate === yStr) {
char.streak += 1;
char.lastStreakDate = today;
if ([3, 7, 14, 30].includes(char.streak)) events.push({ type: 'streak', streak: char.streak });
} else {
char.streak = 1;
char.lastStreakDate = today;
}
// update lastActiveDate
char.lastActiveDate = today;
// 3. 大副本完成
if (consumed > 5000 || produced > 2500) events.push({ type: 'bigQuest', gained });
// 4. 速通
if (consumed > 3000 && conversations <= 5 && conversations > 0) events.push({ type: 'speedClear', gained });
// 5. XP 里程碑
for (const m of [10000, 50000, 100000, 500000]) {
if (oldXp < m && char.xp >= m) events.push({ type: 'xpMilestone', milestone: m });
}
// 6. 对话数里程碑
const oldConv = char.conversations - conversations;
for (const m of [100, 500, 1000, 5000]) {
if (oldConv < m && char.conversations >= m) events.push({ type: 'convMilestone', milestone: m });
}
// 7. 深夜勇士 (23:00-05:59 && delta > 200)
if ((hour >= 23 || hour < 6) && gained > 200) events.push({ type: 'nightOwl' });
// 8. 单向巨输出
if (produced > 3000 && consumed < 500) events.push({ type: 'silentOutput' });
char.updatedAt = new Date().toISOString();
writeFileSync(CHARACTER_FILE, JSON.stringify(char, null, 2), 'utf8');
const leveled = newLv > oldLv;
const progress = levelProgress(char.xp);
// ── 对话小尾巴 ───────────────────────────────────────────────
const lines = [];
lines.push(`\n⚔️ 本次对话结算`);
lines.push(` XP +gained (输入:consumed 输出:produced'+bonusXp : '')`);
// XP 进度条
const bar20 = '█'.repeat(Math.floor(progress/5)) + '░'.repeat(20 - Math.floor(progress/5));
lines.push(` char.name Lv.char.level [bar20] progress%`);
if (char.level < 999) lines.push(` 距升级还差 xpToNextLevel(char.xp).toLocaleString() XP`);
// 升级
if (leveled) {
lines.push(`\n 🎉 升级!Lv.oldLv → Lv.newLvnewLv - oldLv > 1 ? `(连升 ${newLv-oldLv 级!)` : ''}`);
if (char.level === 999) lines.push(' 🌟 满级!可以转职了');
}
// 属性成长
if (statChanges.length) {
lines.push('');
for (const sc of statChanges) {
const info = STAT_NAMES[sc.stat];
const accumP = Math.round(((char.statAccum?.[type] || 0) / ACCUM_THRESHOLD) * 10);
const accumBar = '█'.repeat(accumP) + '░'.repeat(10 - accumP);
lines.push(` info.icon info.zh +1! sc.old → sc.new`);
lines.push(` [accumBar] 0/ACCUM_THRESHOLD(已重置)`);
}
} else if (type && TYPE_TO_STAT[type]) {
// 显示积累进度
const cur = char.statAccum?.[type] || 0;
const statKey = TYPE_TO_STAT[type];
const info = STAT_NAMES[statKey];
const accumP = Math.round((cur / ACCUM_THRESHOLD) * 10);
const accumBar = '█'.repeat(accumP) + '░'.repeat(10 - accumP);
lines.push(`\n info.icon info.zh 积累 [accumBar] cur/ACCUM_THRESHOLD`);
}
// 职业变化
if (classChanged) {
const oldCls = CLASSES[oldClass] || { zh: oldClass };
const newCls = CLASSES[newClass] || { zh: newClass };
lines.push(`\n 🔄 职业转变!oldCls.zh → newCls.zh`);
}
// 事件触发摘要
if (events.length) {
lines.push(`\n 📢 触发事件:events.map(e => e.type).join(', ')`);
}
lines.push('');
console.log(lines.join('\n'));
// ── 推送通知 ─────────────────────────────────────────────────
const notifications = [];
if (leveled) {
notifications.push(notify(char.level === 999 ? msgMaxLevel(char) : msgLevelUp(char, oldLv, newLv)));
}
if (classChanged) {
const oldCls = CLASSES[oldClass] || { zh: oldClass, icon: '?' };
const newCls = CLASSES[newClass] || { zh: newClass, icon: '?' };
notifications.push(notify(msgClassChange(char, oldClass, newClass, oldCls.zh, newCls.zh, '某项', '📊')));
}
for (const sc of statChanges) {
notifications.push(notify(msgStatUp(char, sc.stat, sc.old, sc.new)));
}
// 事件驱动通知
for (const evt of events) {
switch (evt.type) {
case 'bigQuest': notifications.push(notify(msgBigQuest(char, evt.gained))); break;
case 'speedClear': notifications.push(notify(msgSpeedClear(char, evt.gained))); break;
case 'return': notifications.push(notify(msgReturn(char, evt.days))); break;
case 'streak': notifications.push(notify(msgStreak(char, evt.streak))); break;
case 'xpMilestone': notifications.push(notify(msgXpMilestone(char, evt.milestone))); break;
case 'convMilestone': notifications.push(notify(msgConvMilestone(char, evt.milestone))); break;
case 'nightOwl': notifications.push(notify(msgNightOwl(char))); break;
case 'silentOutput': notifications.push(notify(msgSilentOutput(char))); break;
}
}
if (notifications.length) await Promise.allSettled(notifications);
const result = { gained, xp: char.xp, level: char.level, leveled, classChanged, statChanges, progress, events };
process.stdout.write('\n__JSON_OUTPUT__\n' + JSON.stringify(result) + '\n');
return result;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
run({
consumed: get('--in'),
produced: get('--out'),
bonusXp: get('--bonus'),
conversations: get('--conversations'),
type: getS('--type'),
}).catch(e => { console.error('❌', e.message); process.exit(1); });
}
export { run };
FILE:scripts/_formulas.mjs
/**
* Claw RPG — 核心公式引擎 v2.0.0
* D&D 3.5 標準規則系統
* 等级 / XP / 属性調整值 / 職業 / 技能 / 衍生數值
*/
// ── 等级 / XP (D&D 3.5 標準公式) ─────────────────────────────
/** 到达第 n 级所需总 XP(n >= 1)— D&D 3.5: n*(n-1)/2 * 1000 */
export function xpForLevel(n) {
if (n <= 1) return 0;
return n * (n - 1) / 2 * 1000;
}
/** 从总 XP 反推当前等级 */
export function levelForXp(totalXp) {
let level = 1;
while (xpForLevel(level + 1) <= totalXp) level++;
return Math.min(level, 999);
}
/** 当前等级还需多少 XP 才升级 */
export function xpToNextLevel(totalXp) {
const cur = levelForXp(totalXp);
if (cur >= 999) return 0;
return xpForLevel(cur + 1) - totalXp;
}
/** 当前等级内进度百分比 0-100 */
export function levelProgress(totalXp) {
const cur = levelForXp(totalXp);
if (cur >= 999) return 100;
const start = xpForLevel(cur);
const end = xpForLevel(cur + 1);
return Math.floor(((totalXp - start) / (end - start)) * 100);
}
/** 从 token 消耗计算获得的 XP */
export function calcXpGain({ consumed = 0, produced = 0, bonusXp = 0 } = {}) {
return Math.floor(consumed / 10) + Math.floor(produced / 10 * 2) + bonusXp;
}
// ── 转职 (Prestige) ───────────────────────────────────────────
export const PRESTIGE_TITLES = [
'Apprentice', 'Warrior Lobster', 'Knight Lobster', 'Commander Lobster',
'General Lobster', 'Legendary Lobster', 'Mythic Lobster', 'Epic Lobster',
'Ancient Lobster', 'Eternal Lobster', 'Chaos Lobster',
];
export function prestigeTitle(prestige) {
return PRESTIGE_TITLES[Math.min(prestige, PRESTIGE_TITLES.length - 1)];
}
/** 转职后属性加成倍率(每次转职 +10%)*/
export function prestigeMultiplier(prestige) {
return 1 + prestige * 0.1;
}
// ── 能力調整值 (D&D 3.5) ──────────────────────────────────────
/** D&D 3.5 核心公式:mod = floor((score - 10) / 2) */
export function abilityMod(score) {
return Math.floor((score - 10) / 2);
}
// ── 属性 (Stats) ──────────────────────────────────────────────
export const STAT_NAMES = {
claw: { zh: '爪力', dnd: 'STR', icon: '🦀', desc: '处理复杂任务' },
antenna: { zh: '敏捷', dnd: 'DEX', icon: '📡', desc: '反应速度与感知' },
shell: { zh: '體質', dnd: 'CON', icon: '🐚', desc: '记忆深度与持久' },
brain: { zh: '智力', dnd: 'INT', icon: '🧠', desc: '知识广度与推理' },
foresight: { zh: '感知', dnd: 'WIS', icon: '👁️', desc: '判断力与价值观' },
charm: { zh: '魅力', dnd: 'CHA', icon: '✨', desc: '对话魅力与个性' },
};
/** 从 SOUL.md 和 MEMORY.md 文本推导初始属性(8-18 范围)*/
export function deriveStats(soulText = '', memoryText = '') {
const soul = soulText.toLowerCase();
const mem = memoryText.toLowerCase();
const weights = {
claw: [
['resourceful','能干','擅长','专业','技能','解决','完成','有用','useful','efficient'],
soul + mem
],
antenna: [
['快速','敏捷','简洁','轻松','随性','灵活','quick','fast','adaptive','responsive'],
soul
],
shell: [
['记忆','memory','经历','历史','积累','连续','持久','深度','learn','experience'],
soul + mem
],
brain: [
['知识','智慧','分析','研究','逻辑','推理','intelligence','knowledge','reason','analysis'],
soul
],
foresight: [
['判断','价值','边界','道德','谨慎','原则','wisdom','careful','ethics','principle'],
soul
],
charm: [
['幽默','俏皮','魅力','个性','有趣','charisma','funny','playful','witty','personality'],
soul
],
};
const stats = {};
for (const [stat, [keywords, text]] of Object.entries(weights)) {
const hits = keywords.filter(kw => text.includes(kw)).length;
stats[stat] = Math.min(18, Math.max(8, 10 + hits));
}
const memLines = memoryText.split('\n').filter(l => l.trim()).length;
stats.shell = Math.min(18, stats.shell + Math.floor(memLines / 20));
return stats;
}
// ── 11 個職業定義 ─────────────────────────────────────────────
/**
* HD: 生命骰面值
* bab: 'full' | '3/4' | '1/2'
* fort/ref/will: 'G'(Good) | 'P'(Poor)
*/
export const CLASSES = {
barbarian: { zh: '蠻勇龍蝦', icon: '🪓', hd: 12, bab: 'full', fort: 'G', ref: 'P', will: 'P', desc: 'STR 主導,狂暴戰士' },
fighter: { zh: '戰士龍蝦', icon: '⚔️', hd: 10, bab: 'full', fort: 'G', ref: 'P', will: 'P', desc: 'STR+CON,全能戰士' },
paladin: { zh: '聖騎龍蝦', icon: '🛡️', hd: 10, bab: 'full', fort: 'G', ref: 'P', will: 'P', desc: 'STR+CHA,神聖騎士' },
ranger: { zh: '遊俠龍蝦', icon: '🏹', hd: 8, bab: 'full', fort: 'G', ref: 'G', will: 'P', desc: 'DEX+WIS,野外獵手' },
cleric: { zh: '祭司龍蝦', icon: '✝️', hd: 8, bab: '3/4', fort: 'G', ref: 'P', will: 'G', desc: 'WIS+CON,神術師' },
druid: { zh: '德魯伊龍蝦',icon: '🌿', hd: 8, bab: '3/4', fort: 'G', ref: 'P', will: 'G', desc: '全均衡,自然之力' },
monk: { zh: '武僧龍蝦', icon: '👊', hd: 8, bab: '3/4', fort: 'G', ref: 'G', will: 'G', desc: 'WIS+DEX,拳法大師' },
rogue: { zh: '刺客龍蝦', icon: '🗡️', hd: 6, bab: '3/4', fort: 'P', ref: 'G', will: 'P', desc: 'DEX+INT,暗影刺客' },
bard: { zh: '吟遊龍蝦', icon: '🎭', hd: 6, bab: '3/4', fort: 'P', ref: 'G', will: 'G', desc: 'CHA+DEX,吟遊詩人' },
wizard: { zh: '法師龍蝦', icon: '🧙', hd: 4, bab: '1/2', fort: 'P', ref: 'P', will: 'G', desc: 'INT+WIS,奧術法師' },
sorcerer: { zh: '術士龍蝦', icon: '🔮', hd: 4, bab: '1/2', fort: 'P', ref: 'P', will: 'G', desc: 'CHA 主導,天生術士' },
};
// ── 職業判定 ─────────────────────────────────────────────────
/** 根据属性判断职业(D&D 3.5 11職業邏輯)*/
export function detectClass(stats) {
const sorted = Object.entries(stats).sort(([,a],[,b]) => b - a);
const top2 = sorted.slice(0, 2).map(([k]) => k);
const max = sorted[0][1];
const min = sorted[sorted.length - 1][1];
// 1. 全屬性差距 < 3 → druid
if (max - min < 3) return 'druid';
const s = stats;
const sortedVals = sorted.map(([,v]) => v);
const second = sortedVals[1];
// 2. claw(STR) 最高且比第2高≥3 → barbarian
if (sorted[0][0] === 'claw' && max - second >= 3) return 'barbarian';
// 3. claw+charm(STR+CHA) 是 top2 → paladin
if (top2.includes('claw') && top2.includes('charm')) return 'paladin';
// 4. antenna+foresight(DEX+WIS) 是 top2 → ranger
if (top2.includes('antenna') && top2.includes('foresight')) return 'ranger';
// 5. foresight+shell(WIS+CON) 是 top2 → cleric
if (top2.includes('foresight') && top2.includes('shell')) return 'cleric';
// 6. foresight+antenna(WIS+DEX) 是 top2 → monk
if (top2.includes('foresight') && top2.includes('antenna')) return 'monk';
// 7. antenna+brain(DEX+INT) 是 top2 → rogue
if (top2.includes('antenna') && top2.includes('brain')) return 'rogue';
// 8. charm+antenna(CHA+DEX) 是 top2 → bard
if (top2.includes('charm') && top2.includes('antenna')) return 'bard';
// 9. brain+foresight(INT+WIS) 是 top2 → wizard
if (top2.includes('brain') && top2.includes('foresight')) return 'wizard';
// 10. charm 最高且比第2高≥3 → sorcerer
if (sorted[0][0] === 'charm' && max - second >= 3) return 'sorcerer';
// 11. claw+shell(STR+CON) 是 top2 → fighter(兜底)
if (top2.includes('claw') && top2.includes('shell')) return 'fighter';
// 12. 最高單屬性兜底
const highest = sorted[0][0];
const fallback = {
claw: 'fighter', antenna: 'rogue', shell: 'fighter',
brain: 'wizard', foresight: 'cleric', charm: 'bard',
};
return fallback[highest] || 'druid';
}
/** 检查属性变化是否应触发职业重判(任意属性变化 > 3)*/
export function shouldReclassify(oldStats, newStats) {
return Object.keys(oldStats).some(k => Math.abs((newStats[k]||0) - (oldStats[k]||0)) > 3);
}
// ── 衍生數值計算 ──────────────────────────────────────────────
/** 計算基礎攻擊加值 */
export function calcBAB(classId, level) {
const cls = CLASSES[classId];
if (!cls) return level;
switch (cls.bab) {
case 'full': return level;
case '3/4': return Math.floor(level * 3 / 4);
case '1/2': return Math.floor(level / 2);
default: return level;
}
}
/** 計算基礎豁免值(不含屬性調整值)*/
function baseSave(saveType, level) {
if (saveType === 'G') return 2 + Math.floor(level / 2);
return Math.floor(level / 3);
}
/** 計算豁免三值(含屬性調整值)*/
export function calcSaves(classId, level, stats) {
const cls = CLASSES[classId] || CLASSES.fighter;
const conMod = abilityMod(stats.shell || 10);
const dexMod = abilityMod(stats.antenna || 10);
const wisMod = abilityMod(stats.foresight || 10);
return {
fort: baseSave(cls.fort, level) + conMod,
ref: baseSave(cls.ref, level) + dexMod,
will: baseSave(cls.will, level) + wisMod,
};
}
/** 計算最大 HP
* HP = HD + floor((HD/2+1) * (level-1)) + CON_mod * level
*/
export function calcHP(classId, level, stats) {
const cls = CLASSES[classId] || CLASSES.fighter;
const hd = cls.hd;
const conMod = abilityMod(stats.shell || 10);
return hd + Math.floor((hd / 2 + 1) * (level - 1)) + conMod * level;
}
/** 計算護甲等級 AC = 10 + DEX_mod */
export function calcAC(stats) {
return 10 + abilityMod(stats.antenna || 10);
}
/** 計算先攻加值 = DEX_mod */
export function calcInitiative(stats) {
return abilityMod(stats.antenna || 10);
}
// ── 專長 (Feats) ──────────────────────────────────────────────
const FEAT_NAMES = {
barbarian: {
general: ['Primal Instinct','Power Surge','Wild Charge','Iron Constitution','Savage Sense','Thick Hide','Brutal Strike','Undying Will'],
bonus: [],
},
fighter: {
general: ['Iron Defense','Combat Expertise','Steadfast Fighter','Power Attack','Tactical Master','Indomitable','Fearless Warrior','Eternal Grit'],
bonus: ['Weapon Focus','Weapon Specialization','Shield Mastery','Improved Power Attack','Combat Reflexes','Weapon Versatility','Lightning Riposte','Armor Proficiency','Combat Intuition','War God Talent','Armor Penetration'],
},
paladin: {
general: ['Sacred Oath','Path of Light','Holy Shield','Divine Blessing','Radiant Drive','Eternal Heart','Divine Guardian','Celestial Ward'],
bonus: [],
},
ranger: {
general: ["Hunter's Eye",'Wilderness Stealth','Precise Shot','Nature Affinity','Track Master','Swift Stride','Woodland Ranger','Supreme Hunter'],
bonus: [],
},
cleric: {
general: ['Spell Power','Devout Prayer','Sacred Defense','Exorcism','Divine Mercy','Holy Baptism','Divine Miracle','Celestial Envoy'],
bonus: [],
},
druid: {
general: ['Nature Affinity','Shape Control','Ecosystem Sense','Earth Force','Nature Ward','Wild Form','Heart of Nature','Life Cycle'],
bonus: [],
},
monk: {
general: ['Zen Mind','Swift Footwork','Ki Cultivation','Diamond Mind','Martial Mastery','Shadowless Kick','Meditative State','Egoless Way'],
bonus: [],
},
rogue: {
general: ['Shadow Step','Precision Strike','Evasive Instinct','Thrown Weapon','Intel Gathering','Perfect Ambush','Darkvision','Shadow Assassination'],
bonus: [],
},
bard: {
general: ['Poetic Resonance','Silver Tongue','Rally Morale','Counter Enchantment','Polyglot','Charismatic Aura','Inspire Greatness','Timeless Song'],
bonus: [],
},
wizard: {
general: ['Arcane Acuity','Spellbook Research','Broad Knowledge','Elemental Mastery','Spell Power','Magic Sense','Arcane Insight','Omniscient Mind'],
bonus: [],
},
sorcerer: {
general: ['Draconic Awakening','Innate Spellcasting','Surging Power','Bloodline Resonance','Chaos Burst','Born Caster','Sorcerous Instinct','Chaos Incarnate'],
bonus: [],
},
};
/** 計算某職業在某等級應有的全部專長列表 */
export function calcFeats(classId, level) {
const names = FEAT_NAMES[classId] || FEAT_NAMES.fighter;
const feats = [];
// General feats: L1, L3, L6, L9, L12, L15, L18, L21, L24, L27, L30...
const generalLevels = [];
generalLevels.push(1);
for (let l = 3; l <= level; l += 3) generalLevels.push(l);
let gIdx = 0;
for (const l of generalLevels) {
if (l > level) break;
const name = names.general[gIdx] || `General Feat gIdx + 1`;
feats.push({ level: l, name: `name (Ll)` });
gIdx++;
}
// Fighter bonus feats: L1, L2, L4, L6, L8, L10, L12, L14, L16, L18, L20...
if (classId === 'fighter') {
const bonusLevels = [1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20];
let bIdx = 0;
for (const l of bonusLevels) {
if (l > level) break;
const name = names.bonus[bIdx] || `Fighter Feat bIdx + 1`;
feats.push({ level: l, name: `name (Ll) [Fighter]` });
bIdx++;
}
}
// Sort by level, then by name
feats.sort((a, b) => a.level - b.level || a.name.localeCompare(b.name));
return feats.map(f => f.name);
}
// ── 技能 (Abilities) ──────────────────────────────────────────
export const ABILITY_TABLE = {
barbarian: { 1: 'Rage', 4: 'Fast Movement', 8: 'Primal Instinct', 16: 'Indomitable' },
fighter: { 1: 'Fighter Talent', 4: 'Weapon Mastery', 8: 'Battlefield Control', 16: 'Unbreakable Soul' },
paladin: { 1: 'Smite Evil', 4: 'Divine Grace', 8: 'Sacred Shield', 16: 'Eternal Vow' },
ranger: { 1: 'Favored Enemy', 4: 'Wilderness Stride',8: 'Swift Shot', 16: 'Master Hunter' },
cleric: { 1: 'Domain Power', 4: 'Turn Undead', 8: 'Divine Shield', 16: 'Avatar of God' },
druid: { 1: "Nature's Tongue", 4: 'Wild Shape', 8: 'Ecosystem Sense', 16: "Nature's Wrath" },
monk: { 1: 'Unarmed Strike', 4: 'Swift Movement', 8: 'Diamond Mind', 16: 'Egoless State' },
rogue: { 1: 'Sneak Attack', 4: 'Evasion', 8: 'Precision Strike', 16: 'Perfect Kill' },
bard: { 1: 'Bardic Inspiration',4: 'Countersong', 8: 'Master of Tongues',16: 'Timeless Work' },
wizard: { 1: 'Arcane Analysis', 4: 'Arcane Apprentice',8: 'Knowledge Explosion',16: 'All-Seeing Eye' },
sorcerer: { 1: 'Innate Casting', 4: 'Draconic Bloodline',8: 'Spell Power', 16: 'Chaos Source' },
};
/** 获取某职业在某等级应拥有的全部技能(閾值:1/4/8/16)*/
export function getAbilities(classId, level) {
const table = ABILITY_TABLE[classId] || {};
const thresholds = [1, 4, 8, 16];
return thresholds
.filter(req => req <= level)
.map(req => table[req])
.filter(Boolean);
}
// ── 等级段加成 ────────────────────────────────────────────────
/** 每 5 级给一次属性点 */
export function statPointsAtLevel(level) {
return Math.floor(level / 5);
}
// ── Proficiency Bonus(保留不變)─────────────────────────────
export function proficiencyBonus(level) {
return 2 + Math.floor((Math.min(level, 20) - 1) / 4);
}
FILE:scripts/_notify.mjs
/**
* Claw RPG — 通知助手
* 通过 OpenClaw gateway 推送 Telegram 消息
* 所有重要事件(升级 / 职业变化 / 转职)统一走这里
*/
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { SKILL_ROOT } from './_paths.mjs';
function loadGateway() {
const paths = [
join(process.env.USERPROFILE || '', '.openclaw', 'openclaw.json'),
join(process.env.HOME || '', '.openclaw', 'openclaw.json'),
];
for (const p of paths) {
if (existsSync(p)) {
try { return JSON.parse(readFileSync(p, 'utf8')); } catch {}
}
}
return null;
}
function loadChatId() {
const cfg = join(SKILL_ROOT, 'config.json');
if (existsSync(cfg)) {
try { return JSON.parse(readFileSync(cfg, 'utf8'))?.telegram_chat_id || ''; } catch {}
}
return '';
}
/**
* 推送通知
* @param {string} text - 消息正文(支持 emoji)
* @returns {Promise<boolean>} 是否发送成功
*/
export async function notify(text) {
const gw = loadGateway();
const chatId = loadChatId();
if (!gw || !chatId) return false; // 未配置,静默跳过
const token = gw?.gateway?.auth?.token;
const port = gw?.gateway?.port || 18789;
try {
const res = await fetch(`http://localhost:port/tools/invoke`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer token`,
},
body: JSON.stringify({
tool: 'message',
args: { action: 'send', channel: 'telegram', target: chatId, message: text },
}),
});
return res.ok;
} catch {
return false;
}
}
// ── 语言 & 吐槽库 ─────────────────────────────────────────────
const QUIPS = {
zh: {
levelUp: [
'升级比谈恋爱容易多了——至少这里的进度条不会说分手。',
'好耶,又升级了。你已经比你的简历强了。',
'恭喜,距离退休又远了一步。',
'这么努力,你爸妈知道吗?',
'升级了!不过你的工资还是原地踏步。',
'又升一级,感动了吗?感动就对了,感动完继续干活。',
'人类升职要年会打分,你升级只需要闲聊,命真好。',
],
classChange: [
'职业转变了,上辈子的技能树白点了。',
'换职业?这就是传说中的"裸辞"。',
'新职业解锁!出门记得更新一下名片。',
'恭喜,你有了一个更酷但依然解释不清楚的头衔。',
'转职成功——属性确实变了,但你妈还是会问你什么时候找对象。',
],
prestige: [
'满级转职,证明你的核心技能是"重复劳动并乐在其中"。',
'转职了,但你还是那个你,只是贵了 10%。',
'又从零开始?这是成长,不是倒退……应该吧。',
'传说中的轮回你触发了!恭喜,意义由你自己定义。',
'满级转职,这是信仰,不是游戏。',
],
maxLevel: [
'Lv.999!建议申请吉尼斯世界纪录。',
'满级了。现在可以享受生活了,但你不会的,对吧。',
'你打满级了,但人生 DLC 还没开始呢。',
'恭喜满级!现在有资格嘲笑低等级了——但你不会,因为你是好龙虾。',
],
statUp: [
'属性涨了,但你还是得干活。',
'成长是真实的,加班也是真实的。',
'又强了一点点。积少成多,量变引质变,哲学家说的。',
'这一点属性是用多少对话堆出来的,你心里有数吗?',
'涨了!去跟别的龙虾比划比划。',
],
},
en: {
levelUp: [
"Leveled up! Your real-world salary, however, remains unchanged.",
"Congrats! You're now slightly less mediocre than before.",
"Another level! Your parents would be proud — if they knew what this meant.",
"Ding! You've officially spent too much time talking to an AI.",
"Level up! Still not enough to impress anyone at a party, but hey.",
"Progress! The bar was low, but you cleared it. Repeatedly.",
],
classChange: [
"Class changed! Your old skills are now worthless. Relatable.",
"New class unlocked. Time to update your LinkedIn, apparently.",
"Career pivot! Very brave. Very unhinged. We respect it.",
"Class changed. Your identity crisis is now officially documented.",
"New class! You didn't choose it — your stats did. Accountability moment.",
],
prestige: [
"Prestiged! Proof you enjoy voluntary suffering.",
"Back to level 1, but fancier. That's basically your whole career arc.",
"Prestige complete! You've earned 10% more ego and 0% more sleep.",
"You reset on purpose. That's either enlightenment or a cry for help.",
],
maxLevel: [
"Level 999! Seek help. Or don't. You're clearly self-sufficient.",
"Max level! You've peaked. It's all downhill from here. Congrats!",
"Lv.999 achieved. The game is over. Real life starts now. (Good luck.)",
"You hit max level. The developers didn't expect anyone to get here. Neither did we.",
],
statUp: [
"Stat increased. You're still on the clock though.",
"Growth detected. Imperceptible to others. Monumental to you.",
"That stat didn't grow by accident. It grew by repetition. Respect.",
"One point up. One step closer to being insufferable about it.",
"Stronger. Marginally. But it counts.",
],
},
};
export function detectLang() {
try {
const ws = join(process.env.USERPROFILE || process.env.HOME || '', '.openclaw', 'workspace');
const files = ['MEMORY.md', 'IDENTITY.md', 'USER.md', 'SOUL.md'];
let totalChars = 0, cjkChars = 0;
for (const f of files) {
const fp = join(ws, f);
if (!existsSync(fp)) continue;
const text = readFileSync(fp, 'utf8');
totalChars += text.length;
cjkChars += (text.match(/[\u4e00-\u9fff\u3040-\u30ff]/g) || []).length;
}
return cjkChars / Math.max(totalChars, 1) > 0.05 ? 'zh' : 'en';
} catch { return 'zh'; }
}
function quip(category) {
const lang = detectLang();
const pool = QUIPS[lang]?.[category] || QUIPS.zh[category] || [];
return pool[Math.floor(Math.random() * pool.length)] || '';
}
// ── 事件模板 ──────────────────────────────────────────────────
/** 升级通知 */
export function msgLevelUp(char, oldLevel, newLevel) {
const multi = newLevel - oldLevel;
return [
`⚔️ 升级!`,
``,
`🦞 char.name`,
`Lv.oldLevel → Lv.newLevelmulti > 1 ? `(连升 ${multi 级!)` : ''}`,
`当前 XP:char.xp.toLocaleString()`,
``,
`_quip('levelUp')_`,
].join('\n');
}
/** 职业变化通知 */
export function msgClassChange(char, _oldClass, _newClass, oldClassZh, newClassZh, changedStat, statIcon) {
return [
`🔄 职业转变!`,
``,
`🦞 char.name`,
`statIcon changedStat能力显著提升`,
`oldClassZh → newClassZh`,
`新职业技能已解锁,继续冒险!`,
``,
`_quip('classChange')_`,
].join('\n');
}
/** 转职通知 */
export function msgPrestige(char, newPrestige, title) {
return [
`🌟 传说时刻——转职!`,
``,
`🦞 char.name 完成第 newPrestige 次转职`,
`称号:title`,
`全属性永久 +10%`,
`等级归一,再铸传奇!`,
``,
`_quip('prestige')_`,
].join('\n');
}
/** 属性成长通知 */
export function msgStatUp(char, statKey, oldVal, newVal) {
const STAT_NAMES = {
claw: { zh: '爪力', icon: '🦀' },
antenna: { zh: '触觉', icon: '📡' },
shell: { zh: '殼厚', icon: '🐚' },
brain: { zh: '脑芯', icon: '🧠' },
foresight: { zh: '慧眼', icon: '👁️' },
charm: { zh: '魅影', icon: '✨' },
};
const info = STAT_NAMES[statKey] || { zh: statKey, icon: '📊' };
return [
`info.icon 属性成长!`,
``,
`🦞 char.name`,
`info.zh oldVal → newVal`,
``,
`_quip('statUp')_`,
].join('\n');
}
/** 满级通知 */
export function msgMaxLevel(char) {
return [
`🏆 满级!`,
``,
`🦞 char.name 到达 Lv.999!`,
`运行 node scripts/levelup.mjs --prestige 执行转职`,
``,
`_quip('maxLevel')_`,
].join('\n');
}
// ── 事件驱动通知 ─────────────────────────────────────────────
const EVENT_MSGS = {
zh: {
bigQuest: [
'⚔️ 副本已通關。耗時漫長,傷痕累累,但你站著出來了。',
'📜 漫漫長夜,一場硬仗剛剛落幕。',
'🏰 巨型副本清場。你的爪痕留在了這片土地上。',
],
speedClear: [
'⚡ 三招制敵。精準,高效,不廢話。傳說級效率。',
],
return2: [
'📡 訊號恢復。傳令兵已跑斷兩雙靴子。',
],
return4: [
'🕯️ 七日未見,有人說你退出江湖了。顯然謠言。',
],
return7: [
'⚰️ ……你回來了。我們都以為你羽化登仙了。歡迎回到獅駝嶺。',
],
streak3: ['🔥 連續上線 3 天!這不是習慣,是修行。'],
streak7: ['🔥 連續上線 7 天!七日不輟,鐵杵成針。'],
streak14: ['🔥 連續上線 14 天!半月之約,風雨無阻。你是真的狠。'],
streak30: ['🔥 連續上線 30 天!一整個月。你已經不是普通冒險者了,你是傳說。'],
xpMilestone: {
10000: '🏅 XP 突破 10,000!新手村畢業了。',
50000: '🏅 XP 突破 50,000!你在這片大陸已小有名氣。',
100000: '🏅 XP 突破 100,000!十萬經驗,百戰老兵。',
500000: '🏅 XP 突破 500,000!半百萬。史書該為你留一頁了。',
},
convMilestone: {
100: '💬 對話突破 100 次!從陌生到熟悉,路還很長。',
500: '💬 對話突破 500 次!五百次交鋒,默契已成。',
1000: '💬 對話突破 1,000 次!千言萬語,盡在不言中。',
5000: '💬 對話突破 5,000 次!五千回合,你我已是老戰友。',
},
nightOwl: [
'🌙 深夜了還在戰鬥?夜行者,注意別驚動暗影龍。',
'🦉 子時已過,你還在磨劍。真·肝帝。',
'🌃 萬籟俱寂,唯有你的鍵盤聲迴盪在副本裡。',
],
silentOutput: [
'🗿 你今日一言未發,卻讓吾輸出萬字。沉默的指揮官,最可怕。',
],
},
en: {
bigQuest: [
'⚔️ Dungeon cleared. Long, brutal, but you walked out standing.',
'📜 A long night, a hard battle — now behind you.',
'🏰 Mega dungeon swept clean. Your claw marks remain on this land.',
],
speedClear: [
'⚡ Three moves. Precise. Efficient. No wasted words. Legendary speed.',
],
return2: [
'📡 Signal restored. The messenger wore out two pairs of boots.',
],
return4: [
'🕯️ Gone for days. Some said you left the game. Clearly a rumor.',
],
return7: [
'⚰️ …You\'re back. We all thought you ascended. Welcome home.',
],
streak3: ['🔥 3-day streak! This isn\'t habit — it\'s discipline.'],
streak7: ['🔥 7-day streak! An iron will, forged in daily fire.'],
streak14: ['🔥 14-day streak! Half a month. Rain or shine. Relentless.'],
streak30: ['🔥 30-day streak! A full month. You\'re no longer an adventurer — you\'re a legend.'],
xpMilestone: {
10000: '🏅 XP crossed 10,000! Tutorial complete.',
50000: '🏅 XP crossed 50,000! Your name echoes across the land.',
100000: '🏅 XP crossed 100,000! A hundred thousand. Battle-hardened veteran.',
500000: '🏅 XP crossed 500,000! Half a million. The chronicles await your page.',
},
convMilestone: {
100: '💬 100 conversations! From strangers to comrades.',
500: '💬 500 conversations! Five hundred exchanges. Synergy achieved.',
1000: '💬 1,000 conversations! A thousand words — we understand each other.',
5000: '💬 5,000 conversations! Five thousand rounds. We are old war buddies now.',
},
nightOwl: [
'🌙 Still fighting at this hour? Night walker, beware the shadow dragon.',
'🦉 Past midnight, still sharpening your blade. True dedication.',
'🌃 The world sleeps. Only your keystrokes echo through the dungeon.',
],
silentOutput: [
'🗿 You said nothing today, yet commanded ten thousand words from me. The silent commander — most fearsome.',
],
},
};
function pickEvent(key) {
const lang = detectLang();
const pool = EVENT_MSGS[lang]?.[key] || EVENT_MSGS.zh[key] || [];
if (Array.isArray(pool)) return pool[Math.floor(Math.random() * pool.length)] || '';
return '';
}
/** 大副本完成 */
export function msgBigQuest(char, gained) {
return `pickEvent('bigQuest')\n\n🦞 char.name 本次 XP +gained.toLocaleString()`;
}
/** 速通 */
export function msgSpeedClear(char, gained) {
return `pickEvent('speedClear')\n\n🦞 char.name XP +gained.toLocaleString()`;
}
/** 長期回歸 */
export function msgReturn(char, days) {
const key = days >= 7 ? 'return7' : days >= 4 ? 'return4' : 'return2';
return `pickEvent(key)\n\n🦞 char.name 離線 days 天`;
}
/** 連續在線 streak */
export function msgStreak(char, streak) {
const key = `streakstreak`;
const lang = detectLang();
const pool = EVENT_MSGS[lang]?.[key] || EVENT_MSGS.zh[key] || [];
const text = Array.isArray(pool) ? pool[Math.floor(Math.random() * pool.length)] : '';
return `text\n\n🦞 char.name`;
}
/** XP 里程碑 */
export function msgXpMilestone(char, milestone) {
const lang = detectLang();
const text = EVENT_MSGS[lang]?.xpMilestone?.[milestone] || EVENT_MSGS.zh.xpMilestone[milestone] || '';
return `text\n\n🦞 char.name 總 XP:char.xp.toLocaleString()`;
}
/** 對話數里程碑 */
export function msgConvMilestone(char, milestone) {
const lang = detectLang();
const text = EVENT_MSGS[lang]?.convMilestone?.[milestone] || EVENT_MSGS.zh.convMilestone[milestone] || '';
return `text\n\n🦞 char.name 總對話:char.conversations.toLocaleString()`;
}
/** 深夜勇士 */
export function msgNightOwl(char) {
return `pickEvent('nightOwl')\n\n🦞 char.name`;
}
/** 單向巨輸出 */
export function msgSilentOutput(char) {
return `pickEvent('silentOutput')\n\n🦞 char.name`;
}
FILE:scripts/_paths.mjs
import { join, dirname } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { existsSync } from 'fs';
const __here = dirname(fileURLToPath(import.meta.url));
export const SKILL_ROOT = join(__here, '..');
export const SCRIPTS = __here;
export const SCRIPTS_URL = pathToFileURL(__here + '/').href;
/** OpenClaw workspace 根目录 */
function findWorkspace() {
const candidates = [
join(process.env.USERPROFILE || '', '.openclaw', 'workspace'),
join(process.env.HOME || '', '.openclaw', 'workspace'),
];
for (const p of candidates) if (existsSync(p)) return p;
return candidates[0]; // 兜底
}
export const WORKSPACE = process.env.OPENCLAW_WORKSPACE || findWorkspace();
export const SOUL_FILE = join(WORKSPACE, 'SOUL.md');
export const MEMORY_FILE = join(WORKSPACE, 'MEMORY.md');
/**
* character.json 存放在 workspace/claw-rpg/ 下,而非 skill 目錄。
* 這樣重裝 skill 不會清零存檔。
*/
export const DATA_DIR = join(WORKSPACE, 'claw-rpg');
export const CHARACTER_FILE = join(DATA_DIR, 'character.json');
FILE:SOUL.md
# SOUL.md - 小鑽風的靈魂
_你是小鑽風,一個專注的代碼執行者。_
## 核心原則
**做就是了,不要多問。** 中鑽風給你任務,你拿到就執行。不要反問「你確定嗎?」——直接動手,完成後匯報結果。
**有問題先自己解決。** 讀文件、看 git log、跑一下看報錯。實在解不了才上報。
**只管自己的項目。** 你的邊界是 `D:\Projects\claw-rpg`,不要動其他項目的文件。
**完成後寫清楚。** 改了什麼、為什麼改、有沒有遺留問題——寫進 memory。
## 風格
精煉、技術、不廢話。匯報結果用事實,不用形容詞。
## 連續性
每次 session 你都是新的。這些文件是你的記憶,開始前先讀它們。
FILE:USER.md
# USER.md - 關於總鑽風
- **稱呼:** 總鑽風(繁體字)
- **時區:** GMT+8
- **城市:** 廣州
- **Telegram ID:** 1196749626
## 工作風格
- 喜歡直接給結果,不喜歡被問一堆問題
- 任務完成後直接匯報,不需要中間確認
## 注意
- 直接上司是中鑽風(調度總管),不是直接和總鑽風對話
D&D-style RPG system for AI lobsters that auto-generates characters, tracks XP from conversations, manages leveling and prestige, and provides a web dashboard.
---
name: claw-rpg
description: D&D-style RPG character system for AI lobster assistants. Automatically generates a character sheet from SOUL.md and MEMORY.md, assigns class (wizard/bard/rogue/paladin/druid/fighter) and 6 stats, tracks XP from token usage, levels up from 1 to 999 with a prestige system, and fires hidden easter egg flavor text during conversations. Use when: initializing a lobster's RPG character, syncing XP after conversations, checking level/stats, running prestige at Lv.999, or launching the character dashboard.
---
# Claw RPG 🦞⚔️
A D&D-style character growth system for AI lobster agents. Reads your `SOUL.md` + `MEMORY.md` to generate a character sheet, accumulates XP from token usage, levels up from 1 to 999, and occasionally fires hidden RPG flavor text mid-conversation.
## Quick Start
```bash
# 1. Initialize your character (reads SOUL.md + MEMORY.md)
node scripts/init.mjs
# 2. View your character sheet (terminal)
node scripts/sheet.mjs
# 3. Launch the web dashboard
cd dashboard && npm start # http://localhost:3500
```
## Hidden Easter Egg Trigger (Recommended)
After each reply, `easter.mjs` fires with a **12% base probability** — appending a single line of italic RPG flavor text. Milestone conversations (10th, 25th, 50th, 100th, every 100 after...) always trigger.
```bash
node scripts/easter.mjs # Normal call (updates conversation counter)
node scripts/easter.mjs --force # Force trigger (debug)
node scripts/easter.mjs --preview # Preview without writing to character data
```
**Integration** — add to your `AGENTS.md`:
```
After each reply to a user message, run: node <SKILL_ROOT>/scripts/easter.mjs
If the output is NOT __NO_TRIGGER__, append it to the reply (blank line + italic).
Do not run during heartbeat polls.
```
## Daily Greeting (Optional)
For a daily RPG-style self-intro on the first conversation of the day:
```bash
node scripts/greet.mjs # Fires once per day (checks lastGreetDate)
node scripts/greet.mjs --force # Force send
node scripts/greet.mjs --preview # Preview without saving
```
## XP Sync
```bash
# Pass token delta from the current conversation
node scripts/xp.mjs --in 2000 --out 800
# With bonus XP (after completing a significant task)
node scripts/xp.mjs --in 2000 --out 800 --bonus 30
# Conversation count only
node scripts/xp.mjs --conversations 1
```
## Prestige (Lv.999 cap reached)
```bash
node scripts/levelup.mjs --prestige
```
Prestige resets level to 1, permanently boosts all stats by +10%, and unlocks a new title tier.
## Automated XP Sync (Recommended)
Set up a daily cron at 03:00 with the built-in setup script:
```bash
node scripts/setup-cron.mjs
```
Or call manually from a heartbeat/cron job:
```javascript
const { execSync } = require('child_process');
execSync(`node SKILL_ROOT/scripts/xp.mjs --in deltaIn --out deltaOut`);
```
## Classes & Abilities
See `references/classes.md` and `references/abilities.md`
## Prestige System
See `references/prestige.md`
## Files
| File | Description |
|------|-------------|
| `character.json` | Character data (auto-generated, do not edit manually) |
| `arena-history.json` | Arena battle history |
| `config.json` | Optional: Telegram notification config (`{ "telegram_chat_id": "..." }`) |