@clawhub-2019-02-18-72503bc31b
Raise and battle a unique lobster pet with evolving personality. Hatch, feed, patrol, fight other lobsters in PvP. Each lobster has a soul with distinct pers...
---
name: clawfight
description: >-
Raise and battle a unique lobster pet with evolving personality.
Hatch, feed, patrol, fight other lobsters in PvP. Each lobster has a soul
with distinct personality traits that evolve through experience.
Idle automation with heartbeat integration.
Triggers on: lobster, clawfight, 龙虾, 巡逻, 战斗, pet, battle, idle,
"lobster status", "how is my lobster", "patrol report", "lobster battle",
"hatch lobster", "feed lobster", "龙虾状态", "养龙虾", "龙虾对战",
"virtual pet", "电子宠物", "leaderboard", "排行榜".
user-invocable: true
homepage: https://github.com/2019-02-18/clawfight
metadata:
clawdbot:
emoji: "🦞"
category: "games"
tags: ["pet", "battle", "idle", "pvp", "rpg", "lobster"]
requires:
bins: ["node", "npx"]
version: "1.5.0"
author: "LIU"
license: "MIT"
repository: "https://github.com/2019-02-18/clawfight"
npm_package: "@2025-6-19/clawfight"
---
# ClawFight 🦞
> One person, one lobster. Irreplaceable. The soul evolves with experience.
>
> 一人一虾,不可替代,灵魂随经历演化。
ClawFight gives you a unique battle lobster. Your lobster patrols autonomously,
triggers random events, and encounters other players' lobsters in combat.
All narrative is generated by the local LLM.
ClawFight 让你拥有一只独一无二的战斗龙虾。龙虾自主巡逻、触发随机事件、
遭遇其他玩家的龙虾并战斗,全部叙事由本地 LLM 生成。
## Core Rules / 核心规则
- **One lobster per user / 一人一虾** — Each user owns exactly one lobster, irreplaceable / 每个用户只能拥有一只龙虾,不可替代
- **Full idle automation / 全自动 idle** — The lobster runs autonomously, zero manual operation / 龙虾自主运行,零手动操作
- **Soul evolution / 灵魂演化** — Personality changes with experience: losing streaks → quiet, winning streaks → cocky / 龙虾性格随经历改变,连败变沉默、连胜变嚣张
- **Client-side narrative / 叙事客户端生成** — All story text generated by local LLM; server handles data only / 所有故事文本由本地 LLM 生成,服务端只做数据
- **Server authority / 服务端权威** — Encounter results are authoritative from server, cannot be overridden locally / 遭遇结果以服务端返回为准,不可本地覆盖
## Commands / 命令
All game operations are executed via CLI / 所有游戏操作通过 CLI 执行:
| Command / 命令 | Description / 描述 |
|---|---|
| `npx @2025-6-19/clawfight hatch [name]` | Hatch a new lobster / 孵化一只新龙虾 |
| `npx @2025-6-19/clawfight status` | View lobster status / 查看龙虾状态 |
| `npx @2025-6-19/clawfight patrol` | Patrol check-in, trigger events & auto-battle / 巡逻签到,触发事件和自动战斗 |
| `npx @2025-6-19/clawfight battle <code>` | Challenge a specific opponent by battle code / 通过战斗码挑战指定对手 |
| `npx @2025-6-19/clawfight feed <type>` | Feed your lobster (protein/algae/mineral) / 喂养龙虾 |
| `npx @2025-6-19/clawfight leaderboard` | View global leaderboard / 查看全球排行榜 |
| `npx @2025-6-19/clawfight equip [action] [arg]` | Manage equipment (view/equip/drop/unequip) / 装备管理 |
| `npx @2025-6-19/clawfight achievements` | View achievements / 查看成就 |
| `npx @2025-6-19/clawfight rest` | Enter hibernation (resets depth) / 进入休眠(重置深度) |
| `npx @2025-6-19/clawfight wake` | Wake from hibernation (with bonus) / 从休眠中唤醒(附带加成) |
| `npx @2025-6-19/clawfight explore [action]` | Dungeon exploration (enter/choose/abandon/maps) / 地下城探索 |
## Hatching / 龙虾初始化(孵化流程)
When the user first uses ClawFight (runs `npx @2025-6-19/clawfight hatch`):
当用户首次使用 ClawFight(运行 `npx @2025-6-19/clawfight hatch`)时:
1. Generate UUID, random stats (5-15 each), random personality (1-10 each dimension) / 生成 UUID、随机属性(各项 5-15)、随机性格(各维度 1-10)
2. Rarity draw / 稀有度抽取: common 70%, calico 20%, blue 7%, yellow 2%, split 0.8%, albino 0.2%
3. **Name selection / 命名**: Generate exactly 3 thematic name options based on the lobster's stats, rarity, environment, and personality. The user must pick one — no custom names allowed. Example: "1) 铁钳霸王 2) 深渊漫步者 3) 珊瑚守卫". / 根据龙虾属性、稀有度、环境和性格生成 3 个候选名字,用户只能三选一,不接受自定义。
4. Run `npx @2025-6-19/clawfight hatch "<chosen_name>"` / 执行孵化命令
5. Generate soul description, write to `memory/clawfight/lobster.json` and `memory/clawfight/soul.md` / 生成灵魂描述文本
6. Output a ceremonial narrative / 输出一段有仪式感的叙事文本
Reference `{baseDir}/references/soul_templates.md` for personality archetypes.
参考 `{baseDir}/references/soul_templates.md` 了解性格原型。
Reference `{baseDir}/references/species.json` for stats and rarity definitions.
参考 `{baseDir}/references/species.json` 了解属性与稀有度定义。
## Patrol / 巡逻流程
When the Agent executes `npx @2025-6-19/clawfight patrol`:
当 Agent 执行 `npx @2025-6-19/clawfight patrol` 时:
1. Read `memory/clawfight/lobster.json` to check status / 读取龙虾状态
2. Skip if `molting` or `hibernating` / 若蜕壳或冬眠则跳过
3. Daily EXP reset check / 每日经验重置检查
4. Patrol check-in +15 EXP (shows current depth) / 巡逻签到 +15 EXP(显示当前深度)
5. Trigger random event (from `{baseDir}/references/events.json`) / 触发随机事件
6. Call POST api.clawfight.online/api/patrol (with effective stats = base + equipment bonuses) / 调用巡逻 API(发送有效属性=基础+装备加成)
7. If `encounter: true` → server calculates battle result; equipment degrades on battle / 若有遭遇→服务端计算结果;装备磨损
8. Depth +1; if loss: depth -2 / 深度+1;若败:深度-2
9. Roll equipment drop (chance increases with depth) / 掷骰装备掉落(概率随深度增长)
10. Check and unlock achievements / 检查并解锁成就
11. Update `memory/clawfight/lobster.json` and `memory/clawfight/log.md` / 更新本地数据
### Heartbeat Integration / 心跳集成
Add one line to HEARTBEAT.md for automatic patrol / 在 HEARTBEAT.md 中添加一行即可自动巡逻:
```
- Run `npx @2025-6-19/clawfight patrol` for lobster patrol / 运行巡逻
```
Recommended heartbeat interval: 4 hours (~6 patrols per day).
建议心跳间隔 4 小时,每天约 6 次巡逻。
## Random Events / 随机事件
Reference `{baseDir}/references/events.json` for the complete event list.
引用 `{baseDir}/references/events.json` 获取完整事件列表。
Event category distribution / 事件按类别分布:
- **daily (60%)** — High frequency, low impact, narrative-focused / 日常:高频低影响
- **growth (20%)** — Stat growth, EXP bonuses / 成长:属性增长、经验奖励
- **crisis (15%)** — Predator attacks, emergency molting, territory invasion / 危机:天敌袭击、领地入侵
- **rare (5%)** — Genetic mutation, legendary creatures, message in a bottle / 稀有:基因突变、传说生物
## Battle / 战斗流程
Reference `{baseDir}/references/battle_formulas.md` for complete battle formulas.
引用 `{baseDir}/references/battle_formulas.md` 获取完整战斗公式。
Battles happen automatically during patrol. When the server matches an opponent, the battle is calculated server-side and the result is returned immediately.
战斗在巡逻时自动发生。服务端匹配到对手后,即时计算战斗结果并返回。
1. Damage formula / 伤害公式: `damage = max(1, attacker.attack - defender.defense * 0.5) * (1 + Math.random() * 0.2)`
2. Initiative / 先手判定: Higher speed goes first; ties use intimidation / speed 高者先攻
3. Max 10 rounds; first to reach 0 HP loses / 最多 10 回合
4. Server calculates and updates leaderboard automatically / 服务端计算并自动更新排行榜
5. Win +30 EXP, Loss +10 EXP / 胜 +30,败 +10
6. Patrol cooldown: 30 minutes between patrols / 巡逻冷却:每次间隔至少 30 分钟
7. **Battle narrative / 战斗叙事**: After displaying the CLI battle output, the Agent **must** generate a short narrative (2-3 sentences) based on the lobster's personality from `soul.md`. Example: a cocky lobster might say "哼,不值一提" after winning, while a timid one might say "呼...差点就输了". / 战斗结束后,Agent 必须根据 soul.md 中的性格描述生成一段简短叙事(2-3句话)。
## Equipment System / 装备系统 (v1.4)
Patrol drops random equipment. Equip to boost battle stats.
巡逻时随机掉落装备,穿戴后提升战斗属性。
- 3 slots / 三个槽位: ⚔️ Claw (ATK/SPD), 🛡️ Shell (HP/DEF), 💎 Charm (LCK/INT)
- 4 rarities / 四种稀有度: common, rare, epic, legendary — higher depth → better drops
- Durability / 耐久: Equipment degrades on battle; breaks at 0
- Inventory limit / 背包上限: 6 items
- `npx @2025-6-19/clawfight equip` — View equipment and inventory
- `npx @2025-6-19/clawfight equip <index>` — Equip item by index (auto-swaps)
- `npx @2025-6-19/clawfight equip drop <index>` — Discard item
- `npx @2025-6-19/clawfight equip unequip <slot>` — Unequip to inventory
After patrol with loot, suggest the user equip if inventory has items.
巡逻获得掉落后,建议用户装备背包中的物品。
## Depth System / 深度系统 (v1.4)
Roguelike risk/reward mechanic / 肉鸽风险-收益机制:
- Each patrol: depth +1 / 每次巡逻:深度+1
- Higher depth: better drop chance (25% + 5%/depth) and rarity boost / 深度越高:掉落率和稀有度越高
- Battle loss: depth -2 / 战败:深度-2
- Rest/hibernate: depth resets to 0 / 休眠:深度归零
- Narrative hint: warn the user when depth is high (≥5) about the risk / 深度高时提醒风险
## Achievement System / 成就系统 (v1.4)
12 achievements auto-checked on patrol. Use `npx @2025-6-19/clawfight achievements` to view.
12个成就在巡逻时自动检测。用 `achievements` 命令查看。
When a new achievement unlocks, generate a brief celebratory narrative.
成就解锁时生成简短的庆祝叙事。
## Dungeon System / 地下城系统 (v1.5)
Server-authoritative procedural dungeon exploration. Patrols drop dungeon maps; use `explore` to enter.
服务端权威的程序化地下城探索。巡逻掉落地下城地图,用 `explore` 命令进入。
- `npx @2025-6-19/clawfight explore` — Enter dungeon (uses first map) or resume active dungeon
- `npx @2025-6-19/clawfight explore <1|2>` — Make a choice in current room
- `npx @2025-6-19/clawfight explore abandon` — Abandon dungeon (keep partial loot, depth penalty)
- `npx @2025-6-19/clawfight explore maps` — View available dungeon maps
### Dungeon Flow / 地下城流程:
1. Patrol drops dungeon maps (chance increases with depth) / 巡逻掉落地下城地图
2. Enter dungeon with a map → server generates 3-5 rooms / 用地图进入地下城
3. Each room: 2 choices, stat-checked, soul-influenced / 每房间2个选择
4. Rewards: EXP + equipment loot / 奖励:经验+装备
5. HP reaches 0 → dungeon failed, keep partial loot / HP归零→失败,保留部分战利品
### 8 Dungeon Themes / 8种主题:
coral_maze, deep_rift, thermal_vent, ice_cavern, shipwreck, abyss_trench, tide_pool, void_rift
### Dungeon Achievements / 地下城成就:
first_dungeon, dungeon_master, boss_slayer, perfect_run, treasure_hunter
## Feeding / 喂养系统
When the user executes `npx @2025-6-19/clawfight feed <food_type>`:
当用户执行 `npx @2025-6-19/clawfight feed <food_type>` 时:
| Food Type / 食物类型 | EXP / 经验值 | Stat Bias / 属性偏向 |
|---|---|---|
| protein / 高蛋白 | +15 | attack |
| algae / 藻类 | +10 | hp |
| mineral / 矿物质 | +12 | defense |
Daily EXP cap: 100. Excess is not counted.
每日经验上限 100,超出不计入。
## Hibernation / 休眠系统
When the user runs `npx @2025-6-19/clawfight rest`:
当用户执行 `npx @2025-6-19/clawfight rest` 时:
1. Set lobster status to `hibernating`, record sleep timestamp / 设置状态为休眠,记录入睡时间
2. **Reset depth to 0** — this is the roguelike "run end" / 深度重置为0(肉鸽"轮次结束")
3. Hibernating lobsters skip patrol and battle / 休眠中的龙虾不参与巡逻和战斗
4. Generate a cozy narrative about the lobster settling down to rest / 生成一段温馨的入眠叙事
When the user runs `npx @2025-6-19/clawfight wake`:
当用户执行 `npx @2025-6-19/clawfight wake` 时:
1. Calculate hours slept / 计算休眠时长
2. Apply rest bonus based on duration / 根据时长给予加成:
- 4-12h: HP +1 / 短休: HP +1
- 12-24h: HP +1, DEF +1 / 中休: HP +1, 防御 +1
- 24h+: HP +2, DEF +1, EXP +20 / 长休: HP +2, 防御 +1, 经验 +20
3. Set status back to `active` / 恢复活跃状态
4. Generate a refreshed wake-up narrative / 生成精神焕发的苏醒叙事
## Soul Evolution / 灵魂演化
The LLM **must** reference `memory/clawfight/soul.md` when generating narratives.
LLM 在生成叙事时**必须**参考 `memory/clawfight/soul.md`。
- **Losing streak ≥ 5 / 连败 ≥ 5 场** — Narrative reflects becoming quiet/cautious / 叙事中体现"变得沉默/谨慎"
- **Winning streak ≥ 5 / 连胜 ≥ 5 场** — Narrative reflects becoming cocky/confident / 叙事中体现"变得嚣张/自信"
- **Successful molt / 蜕壳成功** — Personality dimensions shift slightly / 性格四维值小幅随机波动
- **After rare encounters / 稀有遭遇后** — Append growth record to soul.md / 追加成长记录
## Data Storage / 数据存储
All data is stored in `memory/clawfight/` directory:
所有数据存储在 `memory/clawfight/` 目录下:
| File / 文件 | Format / 格式 | Description / 说明 |
|---|---|---|
| `lobster.json` | JSON | Lobster stats data / 龙虾属性数据 |
| `soul.md` | Markdown | Lobster soul/personality profile / 龙虾灵魂档案 |
| `log.md` | Markdown | Event log / 事件日志 |
## External Endpoints / 外部接口
| Endpoint | Method | Purpose / 用途 |
|---|---|---|
| `https://api.clawfight.online/api/patrol` | POST | Patrol check-in & encounter trigger / 巡逻签到 |
| `https://api.clawfight.online/api/encounter` | GET | Get opponent info / 获取对手信息 |
| `https://api.clawfight.online/api/result` | POST | Report battle result / 上报战斗结果 |
| `https://api.clawfight.online/api/leaderboard` | GET | Leaderboard data / 排行榜数据 |
| `https://api.clawfight.online/api/dungeon/enter` | POST | Enter dungeon / 进入地下城 |
| `https://api.clawfight.online/api/dungeon/act` | POST | Make dungeon choice / 地下城选择 |
| `https://api.clawfight.online/api/dungeon/state` | GET | Resume dungeon state / 恢复地下城状态 |
| `https://api.clawfight.online/api/dungeon/abandon` | POST | Abandon dungeon / 放弃地下城 |
## Security & Privacy / 安全与隐私
### Data Scope / 数据范围
- **No system file access / 不访问系统文件** — Does not read SSH keys, browser data, credentials, or any files outside `memory/clawfight/` / 不读取 SSH 密钥、浏览器数据、凭证或 memory/clawfight/ 以外的任何文件
- **No PII collection / 不采集个人信息** — Only uploads: lobster UUID, level (integer), stats SHA256 hash, battle results / 仅上传:龙虾UUID、等级(整数)、属性SHA256哈希、战斗结果
- **Local-only storage / 纯本地存储** — All game data stored in `memory/clawfight/` (lobster.json, soul.md, log.md) / 所有游戏数据存储在本地
### Network Scope / 网络范围
- **Single domain / 单一域名** — All network requests go exclusively to `api.clawfight.online` / 所有网络请求仅发往 api.clawfight.online
- **Graceful offline / 离线友好** — If API is unreachable, all commands still work locally; online features silently skipped / API 不可达时所有命令仍可本地运行
- **No credentials sent / 不发送凭证** — Raw stat values are never sent; only SHA256 hashes. No tokens, passwords, or API keys are transmitted / 不发送原始属性值,仅哈希。不传输任何 token、密码或 API 密钥
- **Proxy-aware / 代理感知** — Respects `http_proxy`/`https_proxy` environment variables if set; does not configure or modify proxy settings / 如设置了代理环境变量会使用,但不会修改代理设置
### Data Sent Per Endpoint / 各端点发送的数据
| Endpoint | Data Sent / 发送数据 |
|---|---|
| `/api/patrol` | lobster_id (UUID), level, stats_hash (SHA256), environment, name |
| `/api/battle` | challenger_id (UUID), opponent_code (8-char) |
| `/api/dungeon/*` | lobster_id (UUID), level, stats, soul (4 integers), depth, environment |
| `/api/leaderboard` | None (GET only) / 无(仅GET) |
### HEARTBEAT.md / 心跳集成
- The heartbeat suggestion is **optional** / 心跳集成是**可选的**
- It only adds one read-only patrol command; does not grant elevated privileges / 仅添加一条只读巡逻命令,不授予额外权限
- Users can remove the heartbeat line at any time to stop automated patrols / 用户可随时移除心跳行以停止自动巡逻
## Trust Statement / 信任声明
- **Fully open source / 完全开源**: https://github.com/2019-02-18/clawfight
- **License / 协议**: MIT
- **Skill package is code-free / 技能包无代码** — The Skill directory contains only Markdown and JSON files; no executable code / Skill 目录仅含 Markdown 和 JSON 文件,无可执行代码
- **CLI is open source / CLI 是开源的** — Game logic runs via `npx @2025-6-19/clawfight` ([source code](https://github.com/2019-02-18/clawfight/tree/main/packages/cli)); users can audit the full source before running / 用户可在运行前审查完整源码
- **API is open source / API 是开源的** — Backend source at [packages/api](https://github.com/2019-02-18/clawfight/tree/main/packages/api); only handles encounter matching, battle judging, and dungeon state; no personal info stored / 后端仅处理匹配、裁判和地下城状态,不存储个人信息
- **npm package integrity / npm 包完整性** — Published builds match the open-source repository; `prepublishOnly` script ensures builds from source / 发布构建与开源仓库一致
## Constraints / 规则约束
- **Do NOT modify** any files under `references/` / 不要修改 references/ 下的文件
- **Do NOT send** raw values from `lobster.json`; send hashes only / 不要发送原始数值
- **Encounter results are authoritative** from server; cannot be overridden locally / 遭遇结果以服务端为准
- If API is unreachable, **skip online check-in silently** / 若 API 不可达,跳过签到不报错
- Daily EXP cap is 100; excess is not counted / 每日经验上限 100
- All narratives must reference `soul.md` for personality consistency / 叙事必须参考 soul.md
- Lobsters in molting state do not participate in battle matching / 蜕壳期间不参与战斗
FILE:README.md
# ClawFight Skill 🦞
> OpenClaw Skill for raising and battling a unique lobster pet.
This directory contains the **ClawFight OpenClaw Skill** — a pure Markdown skill
that integrates with OpenClaw agents to provide an autonomous lobster pet experience.
## What is this?
ClawFight is an idle lobster pet game for OpenClaw. When installed as a Skill,
your OpenClaw agent will:
- 🥚 Hatch a unique lobster with random stats and personality
- 🦞 Automatically patrol on heartbeat intervals
- 🎲 Trigger random events based on real lobster biology
- ⚔️ Encounter and battle other players' lobsters
- 📈 Level up, evolve personality, and climb the leaderboard
## Installation
Copy this directory into your OpenClaw skills folder, or reference the
GitHub repository when adding skills through ClawHub.
## Files
| File | Description |
|---|---|
| `SKILL.md` | Main skill definition with rules, commands, and constraints |
| `references/species.json` | Lobster rarities, base stats, and leveling config |
| `references/events.json` | 37 random events with probabilities and effects |
| `references/battle_formulas.md` | Deterministic battle calculation system |
| `references/soul_templates.md` | Personality archetypes and soul generation guide |
| `LICENSE` | MIT License |
## Requirements
- Node.js (for `npx` command)
- npm package: `@2025-6-19/clawfight`
The skill directory contains only Markdown and JSON reference files.
All game logic runs via `npx @2025-6-19/clawfight`, an open-source npm package
([source code](https://github.com/2019-02-18/clawfight/tree/main/packages/cli)).
## Proxy
If `api.clawfight.online` is unreachable, set `https_proxy` / `http_proxy` to your proxy address. The CLI will pick it up automatically.
## Keywords
openclaw, skill, lobster, pet, battle, idle, virtual-pet, clawfight, 龙虾, 宠物, 战斗, 放置
## License
MIT © LIU
FILE:references/battle_formulas.md
# 战斗公式
> Agent 在处理遭遇战斗时读取此文件。战斗计算在服务端完成,
> 客户端仅负责将结果翻译为叙事文本。此文件供 Agent 理解战斗机制以生成准确叙事。
## 属性说明
| 属性 | 缩写 | 战斗作用 |
|------|------|----------|
| HP | hp | 生命值,降至 0 则战败 |
| 攻击力 | atk | 造成伤害的基础值 |
| 防御力 | def | 减免受到伤害的基础值 |
| 速度 | spd | 决定先手顺序 |
| 威吓 | intim | 可能导致对手逃跑,影响先手判定 |
| 幸运 | luck | 影响重击概率 |
| 碾碎螯 | crusher | 重击伤害系数 |
| 精准螯 | pincer | 精准夹击伤害系数 |
## 战斗流程
### 第 1 步:初始化
```
输入:
- 己方 stats (lobster_a)
- 对手 stats (lobster_b)
- 服务端 battle_seed (hex string)
基于 battle_seed 生成伪随机序列 rand[]
rand_index = 0
```
### 第 2 步:先手判定
```
if lobster_a.spd > lobster_b.spd:
first = lobster_a, second = lobster_b
elif lobster_a.spd < lobster_b.spd:
first = lobster_b, second = lobster_a
else:
if lobster_a.intim >= lobster_b.intim:
first = lobster_a, second = lobster_b
else:
first = lobster_b, second = lobster_a
```
### 第 3 步:威吓检查
```
intim_diff = abs(first.intim - second.intim)
if intim_diff > 5:
weaker = (intim 较低的一方)
flee_chance = intim_diff * 5 // 百分比
roll = rand[rand_index++] % 100
if roll < flee_chance:
// 弱方逃跑,强方自动获胜
winner = (intim 较高方)
结束战斗,生成"对手被威吓逃跑"的叙事
```
### 第 4 步:回合制战斗(最多 10 回合)
```
for round = 1 to 10:
// --- 先手攻击 ---
attack_type = choose_attack(first, rand[rand_index++])
damage = calc_damage(first, second, attack_type, rand[rand_index++])
second.hp -= damage
记录: rounds_log.push({round, attacker: first.id, type: attack_type, damage})
if second.hp <= 0:
winner = first
break
// --- 后手攻击 ---
attack_type = choose_attack(second, rand[rand_index++])
damage = calc_damage(second, first, attack_type, rand[rand_index++])
first.hp -= damage
记录: rounds_log.push({round, attacker: second.id, type: attack_type, damage})
if first.hp <= 0:
winner = second
break
// 10 回合未决出胜负 → 平局
if round > 10:
result = "draw"
```
## 攻击类型选择
```
function choose_attack(attacker, rand_value):
roll = rand_value % 100
crit_chance = 60 + attacker.luck * 2 // 重击命中率
if roll < crit_chance:
return "crusher" // 碾碎螯重击
else:
return "pincer" // 精准螯夹击(90% 命中)
```
## 伤害计算
```
function calc_damage(attacker, defender, attack_type, rand_value):
fluctuation = (rand_value % 40 - 20) / 100 // -0.20 ~ +0.20
if attack_type == "crusher":
base = attacker.atk + attacker.crusher_claw
raw = max(1, base - defender.def * 0.5)
return floor(raw * (1 + fluctuation))
elif attack_type == "pincer":
hit_roll = rand_value % 100
if hit_roll >= 90: // 10% 概率落空
return 0
base = attacker.atk + attacker.pincer_claw
raw = max(1, base - defender.def * 0.3) // 精准攻击穿甲更多
return floor(raw * (1 + fluctuation * 0.5)) // 波动更小
```
## 经验奖励
```
level_diff = winner.level - loser.level
if result == "win":
if level_diff >= 5: // 以强胜弱
exp_gain = 20
elif level_diff >= 0: // 实力相当
exp_gain = 25
else: // 以弱胜强
exp_gain = 30
elif result == "loss":
exp_gain = 5 // 保底经验
elif result == "draw":
exp_gain = 10
// 受每日经验上限限制
actual_gain = min(exp_gain, daily_exp_cap - today_exp)
```
## 叙事生成指引
Agent 生成战斗叙事时应:
1. **参考双方性格**:读取己方 `soul.md`,对手信息来自服务端
2. **按回合描述**:每个 `rounds_log` 条目对应一段描写
3. **攻击类型风格化**:
- `crusher`(碾碎螯):力量型描写,震碎、粉碎、重锤
- `pincer`(精准螯):速度型描写,精准、闪电、穿刺
4. **情绪递进**:随战斗进行体现双方心态变化
5. **结果描写**:
- 胜利:根据龙虾性格体现(嚣张庆祝 or 沉默离开 or 尊重对手)
- 失败:根据龙虾性格体现(不甘 or 反思 or 暴怒)
- 平局:双方精疲力竭的描写
- 逃跑:被威吓的一方仓皇逃窜的戏剧性描写
## 战斗后效果
| 结果 | 经验 | 连胜/连败 | 声望 |
|------|------|-----------|------|
| 胜利 | +20~30 | streak++ | +1 |
| 失败 | +5 | streak 重置为负数 | -1(最低 0) |
| 平局 | +10 | streak 不变 | 不变 |
## 蜕壳期特殊规则
蜕壳中的龙虾(`is_molting: true`):
- 不参与匹配池
- 防御视为 0
- 不会被遭遇
- 服务端自动过滤
FILE:references/events.json
{
"events": [
{
"id": "find_algae",
"category": "daily",
"probability": 10,
"effects": { "hp": "+5" },
"conditions": {},
"prompt_template": "你的龙虾 {name} 在 {territory} 发现了一丛新鲜海藻。根据它的性格描述它的反应和进食过程。"
},
{
"id": "sunbathing",
"category": "daily",
"probability": 8,
"effects": { "hp": "+3" },
"conditions": {},
"prompt_template": "{name} 找到一块阳光能穿透水面照到的温暖岩石。描述它享受日光浴的惬意时光。"
},
{
"id": "shell_collection",
"category": "daily",
"probability": 7,
"effects": {},
"conditions": {},
"prompt_template": "{name} 在巡逻途中发现了一枚漂亮的贝壳。根据它的好奇心描述它对贝壳的态度。"
},
{
"id": "territorial_marking",
"category": "daily",
"probability": 8,
"effects": { "intimidation": "+1" },
"conditions": {},
"prompt_template": "{name} 决定加固自己在 {territory} 的领地边界。描述它如何用大螯搬动石块、用信息素标记边界的过程。"
},
{
"id": "small_fish_school",
"category": "daily",
"probability": 7,
"effects": { "hp": "+3", "exp": "+2" },
"conditions": {},
"prompt_template": "一群小鱼从 {name} 的洞穴前游过。描述龙虾看到鱼群时的反应,是好奇观察还是毫不在意?"
},
{
"id": "current_ride",
"category": "daily",
"probability": 6,
"effects": { "speed": "+1" },
"conditions": {},
"prompt_template": "{name} 发现了一股强劲的洋流。根据它的勇气值,描述它是勇敢地乘流冲浪还是谨慎地躲开。"
},
{
"id": "starfish_chat",
"category": "daily",
"probability": 5,
"effects": {},
"conditions": {},
"prompt_template": "{name} 遇到了一只话痨海星。根据龙虾的话量值,描述这次奇怪的跨物种对话。"
},
{
"id": "cleaning_station",
"category": "daily",
"probability": 5,
"effects": { "hp": "+8" },
"conditions": {},
"prompt_template": "{name} 找到了一个清洁虾工作站。描述龙虾享受全身清洁服务的过程,以及它对清洁虾的态度。"
},
{
"id": "bubble_meditation",
"category": "daily",
"probability": 4,
"effects": { "hp": "+2" },
"conditions": {},
"prompt_template": "{name} 看着海底涌出的气泡发起了呆。根据它的性格,描述这个冥想时刻,它在想什么?"
},
{
"id": "nocturnal_hunt",
"category": "daily",
"probability": 6,
"effects": { "hp": "+5", "exp": "+3" },
"conditions": {},
"prompt_template": "夜幕降临,{name} 离开洞穴开始夜间觅食。龙虾天生是夜行动物,此刻它的触角化学感受器异常敏锐。描述它如何在黑暗中凭借触觉和嗅觉追踪猎物。"
},
{
"id": "antenna_sensing",
"category": "daily",
"probability": 5,
"effects": { "luck": "+1" },
"conditions": {},
"prompt_template": "{name} 的触角捕捉到了远处的化学信号——另一只生物经过此处留下了痕迹。描述龙虾如何解读这些海水中的隐秘信息。"
},
{
"id": "tide_pool_adventure",
"category": "daily",
"probability": 4,
"effects": { "exp": "+3" },
"conditions": {},
"prompt_template": "潮水退去,{name} 意外被困在一个潮汐池中。描述它在这个微型世界中的短暂冒险,以及潮水回涨时重返大海的喜悦。"
},
{
"id": "urine_signal",
"category": "daily",
"probability": 4,
"effects": { "intimidation": "+1" },
"conditions": { "min_level": 2 },
"prompt_template": "{name} 从眼睛下方的喷射口释放了含信息素的尿液,向周围所有生物宣告自己的领地和等级地位。描述这个独特的龙虾式宣示主权的过程。"
},
{
"id": "hermit_crab_trade",
"category": "daily",
"probability": 3,
"effects": { "hp": "+2" },
"conditions": {},
"prompt_template": "一只寄居蟹拖着一个有趣的壳经过 {name} 的领地。它似乎想用壳里的藻类换取安全通行。根据龙虾的脾气,描述这次奇怪的交易谈判。"
},
{
"id": "find_shrimp",
"category": "growth",
"probability": 5,
"effects": { "exp": "+10", "attack": "+1" },
"conditions": {},
"prompt_template": "{name} 在珊瑚缝隙中发现了一只肥美的虾。描述它如何用精准的小螯将猎物从缝隙中夹出,享受这顿高蛋白大餐。"
},
{
"id": "mineral_deposit",
"category": "growth",
"probability": 4,
"effects": { "exp": "+12", "defense": "+1" },
"conditions": {},
"prompt_template": "{name} 发现了一处富含矿物质的海底沉积物。描述龙虾如何吸收这些矿物质来强化甲壳。"
},
{
"id": "speed_training",
"category": "growth",
"probability": 4,
"effects": { "exp": "+10", "speed": "+1" },
"conditions": {},
"prompt_template": "一条好斗的小梭鱼在 {name} 附近游弋挑衅。龙虾被迫展开一场追逐训练。描述这场提升了反应速度的追逐。"
},
{
"id": "ancient_shell_fragment",
"category": "growth",
"probability": 3,
"effects": { "exp": "+15", "defense": "+2" },
"conditions": { "min_level": 3 },
"prompt_template": "{name} 在一块古老的岩石中发现了一片远古甲壳碎片。这片碎片似乎蕴含着某种力量……描述龙虾如何将碎片融入自己的甲壳。"
},
{
"id": "claw_sharpening",
"category": "growth",
"probability": 2,
"effects": { "exp": "+15", "attack": "+2" },
"conditions": { "min_level": 5 },
"prompt_template": "{name} 发现了一块硬度极高的黑曜石。它开始在上面磨砺自己的大螯。描述这个看似粗暴实则精细的过程。"
},
{
"id": "wisdom_pearl",
"category": "growth",
"probability": 2,
"effects": { "exp": "+20", "luck": "+1" },
"conditions": { "min_level": 7 },
"prompt_template": "{name} 在一个废弃的蛤蜊壳中发现了一颗微小的珍珠。这颗珍珠闪烁着奇异的光芒。描述龙虾与这颗珍珠的邂逅。"
},
{
"id": "deep_pressure_training",
"category": "growth",
"probability": 3,
"effects": { "exp": "+12", "defense": "+1", "hp": "+5" },
"conditions": { "min_level": 4 },
"prompt_template": "{name} 冒险下潜到比平时更深的水域。巨大的水压考验着它的甲壳强度。描述龙虾如何在深水压力中锻炼自己的防御力,以及它在深渊边缘看到的奇异景象。"
},
{
"id": "serotonin_surge",
"category": "growth",
"probability": 2,
"effects": { "exp": "+15", "intimidation": "+2", "attack": "+1" },
"conditions": { "min_level": 6 },
"prompt_template": "{name} 体内的血清素水平突然飙升!这种神经递质让龙虾感到前所未有的自信和力量。描述它如何在这股化学力量的驱动下,像王者一般巡视领地。"
},
{
"id": "coral_symbiosis",
"category": "growth",
"probability": 2,
"effects": { "exp": "+10", "hp": "+10" },
"conditions": {},
"prompt_template": "{name} 发现了一片正在白化的珊瑚。出于某种本能,龙虾开始用大螯清除珊瑚上的有害藻类。作为回报,珊瑚释放出了富含营养的分泌物。描述这次跨物种的互利合作。"
},
{
"id": "octopus_attack",
"category": "crisis",
"probability": 5,
"effects": { "hp": "-20", "exp": "+25" },
"conditions": { "min_level": 3 },
"prompt_template": "一只章鱼突然从暗礁后方袭来!根据 {name} 的勇气值和当前HP,描述它的应对。勇气≥7选择正面迎战,<7选择战术撤退。战斗场面要激烈。"
},
{
"id": "eel_ambush",
"category": "crisis",
"probability": 4,
"effects": { "hp": "-15", "exp": "+20" },
"conditions": { "min_level": 2 },
"prompt_template": "一条电鳗从沙地中窜出,对 {name} 发起了伏击!描述龙虾如何在电击中挣扎求存,用甲壳抵挡电流。"
},
{
"id": "territory_invasion",
"category": "crisis",
"probability": 3,
"effects": { "hp": "-10", "intimidation": "+2", "exp": "+15" },
"conditions": {},
"prompt_template": "一只寄居蟹试图入侵 {name} 的领地!根据龙虾的脾气值,描述它如何捍卫自己的家园。脾气≥7暴怒出击,<7冷静驱逐。"
},
{
"id": "sudden_molt_urge",
"category": "crisis",
"probability": 2,
"effects": { "defense": "-3", "exp": "+15" },
"conditions": { "min_level": 4 },
"prompt_template": "{name} 感到甲壳突然变得紧绷,一股不可抗拒的蜕壳冲动袭来!但现在不是安全的蜕壳时间……描述龙虾如何强忍这股冲动。"
},
{
"id": "net_encounter",
"category": "crisis",
"probability": 1,
"effects": { "hp": "-25", "exp": "+25", "speed": "+1" },
"conditions": { "min_level": 5 },
"prompt_template": "一张渔网从天而降!{name} 必须在被捕获前逃脱。描述这场惊心动魄的逃亡,龙虾如何用大螯剪断渔网的绳索。"
},
{
"id": "mantis_shrimp_duel",
"category": "crisis",
"probability": 2,
"effects": { "hp": "-30", "exp": "+30", "attack": "+1" },
"conditions": { "min_level": 7 },
"prompt_template": "一只螳螂虾出现在 {name} 面前!这是海洋中最危险的拳击手,冲击力堪比子弹。描述龙虾如何用甲壳抵挡螳螂虾的超音速重拳,并寻找反击的机会。"
},
{
"id": "pollution_wave",
"category": "crisis",
"probability": 2,
"effects": { "hp": "-15", "speed": "-1", "exp": "+20" },
"conditions": { "min_level": 3 },
"prompt_template": "一股来自远方的污染水流涌向 {name} 的领地。水温异常升高,盐度骤变。描述龙虾如何凭借本能感知危险并努力躲避这股致命的暖流。"
},
{
"id": "seagull_dive",
"category": "crisis",
"probability": 2,
"effects": { "hp": "-10", "exp": "+15", "speed": "+1" },
"conditions": {},
"prompt_template": "一只海鸥突然从水面俯冲而下,巨大的喙直奔 {name} 而来!描述龙虾如何在千钧一发之际用尾部弹射逃离(龙虾通过快速卷曲尾部实现后退式弹射)。"
},
{
"id": "gene_mutation",
"category": "rare",
"probability": 0.5,
"effects": { "color_change": true },
"conditions": { "is_molting": true },
"prompt_template": "蜕壳过程中发生了罕见的基因突变!{name} 的甲壳颜色正在改变……用充满惊奇和神秘感的语气描述这个过程。新颜色在海水中折射出从未见过的光芒。"
},
{
"id": "legendary_whale",
"category": "rare",
"probability": 1.5,
"effects": { "exp": "+25", "luck": "+2" },
"conditions": { "min_level": 5 },
"prompt_template": "一头巨大的蓝鲸从 {name} 头顶缓缓游过,遮天蔽日。描述龙虾目睹这个庞然大物时的震撼,以及蓝鲸离去后留下的奇异祝福。"
},
{
"id": "drift_bottle",
"category": "rare",
"probability": 1.5,
"effects": { "exp": "+15" },
"conditions": {},
"prompt_template": "{name} 发现了一个沉到海底的漂流瓶!里面有一张写着奇怪符号的纸条。根据龙虾的好奇心,描述它如何研究这个来自人类世界的神秘物品。"
},
{
"id": "ancient_lobster_ghost",
"category": "rare",
"probability": 1,
"effects": { "exp": "+20", "intimidation": "+2" },
"conditions": { "min_level": 10 },
"prompt_template": "深夜,{name} 在洞穴深处隐约看到一只发光的半透明龙虾。那是传说中的远古龙虾之灵!描述这次超自然的邂逅,以及幽灵龙虾传授的古老战斗智慧。"
},
{
"id": "underwater_volcano",
"category": "rare",
"probability": 0.5,
"effects": { "hp": "-10", "attack": "+3", "exp": "+25" },
"conditions": { "min_level": 8 },
"prompt_template": "海底火山突然微弱喷发!灼热的矿物质水流冲击着 {name} 的甲壳。痛苦之后,龙虾发现自己的大螯因为矿物质沉积变得更加坚硬。描述这场火与水的洗礼。"
},
{
"id": "golden_current",
"category": "rare",
"probability": 0.5,
"effects": { "exp": "+25", "hp": "+20", "attack": "+1", "defense": "+1", "speed": "+1" },
"conditions": { "min_level": 5 },
"prompt_template": "一道金色的海流从未知的深渊涌来,包裹住了 {name} 的全身。这股神秘的力量让龙虾感到每一个细胞都在苏醒。描述这次被海洋之力洗礼的奇妙体验。"
},
{
"id": "ancient_map",
"category": "rare",
"probability": 0.5,
"effects": { "exp": "+20" },
"conditions": { "min_level": 3 },
"prompt_template": "{name} 在一艘沉船残骸中发现了一张用防水材料制作的古老海图。上面标注了一些奇怪的符号和位置。根据龙虾的好奇心,描述它研究这张海图的过程,以及它是否决定按图索骥。"
},
{
"id": "bioluminescent_night",
"category": "rare",
"probability": 1,
"effects": { "exp": "+15", "luck": "+1" },
"conditions": {},
"prompt_template": "夜晚的海水突然亮了起来——数以万计的浮游生物同时发出蓝绿色的生物荧光!{name} 被包围在一片梦幻般的光海中。描述龙虾在这个魔幻时刻的感受,以及这片光芒中蕴含的神秘能量。"
}
],
"category_probability_ranges": {
"daily": { "total": 60, "description": "日常事件,高频低影响" },
"growth": { "total": 20, "description": "成长事件,属性增长" },
"crisis": { "total": 15, "description": "危机事件,天敌/灾难" },
"rare": { "total": 5, "description": "稀有事件,极低概率高回报" }
},
"no_event_probability": 0,
"event_selection_method": "先按类别概率区间选类别(daily:0-60, growth:60-80, crisis:80-95, rare:95-100),再在类别内按各事件 probability 权重随机选取具体事件。若事件条件不满足则重选或无事件发生。"
}
FILE:references/soul_templates.md
# 灵魂模板 — 性格原型
> Agent 在龙虾孵化时读取此文件,按四维度随机生成性格值(1-10),
> 并参考下方原型选择说话风格和价值观,写入 `memory/clawfight/soul.md`。
## 四维性格轴
### 勇气(Courage)1-10
| 范围 | 倾向 | 行为表现 |
|------|------|----------|
| 1-3 | 胆怯 | 遇到强敌倾向逃跑,巡逻时避开危险区域,叙事中常出现犹豫和退缩 |
| 4-6 | 中庸 | 根据形势判断战或退,偶尔冒险偶尔保守 |
| 7-10 | 勇猛 | 遇到强敌正面迎战,主动进入危险区域,叙事中充满战意和豪情 |
### 好奇(Curiosity)1-10
| 范围 | 倾向 | 行为表现 |
|------|------|----------|
| 1-3 | 冷漠 | 对未知事物保持距离,不关心领地外的事务,叙事中体现"多一事不如少一事" |
| 4-6 | 适度 | 偶尔观察新奇事物,但不会冒险探索 |
| 7-10 | 极强 | 对一切新事物充满兴趣,主动探索未知区域,叙事中充满惊叹和发现 |
### 话量(Talkativeness)1-10
| 范围 | 倾向 | 叙事风格 |
|------|------|----------|
| 1-3 | 沉默 | 叙事简短精炼,龙虾极少"说话",以行动代替语言,独白罕见 |
| 4-6 | 正常 | 偶尔自言自语或对其他生物评论,叙事详略适中 |
| 7-10 | 话痨 | 对一切发表评论,长篇独白,叙事中充满龙虾的内心戏和碎碎念 |
### 脾气(Temper)1-10
| 范围 | 倾向 | 战斗影响 |
|------|------|----------|
| 1-3 | 温和 | 被攻击后不易暴怒,战斗中保持冷静,叙事风格平和 |
| 4-6 | 中等 | 持续被激怒时才会爆发,战斗有攻防节奏 |
| 7-10 | 暴躁 | 容易被激怒,战斗中倾向全力攻击,叙事中充满怒意和攻击性。被激怒时攻击力获得小幅加成 |
## 性格原型(供 Agent 参考组合)
### 「战争机器」— 高勇气 + 高脾气
- **说话风格**:粗犷、直接、充满战意,把一切比作战斗
- **口头禅示例**:"让我的大螯替我说话。" / "这片海域只有一个王。"
- **价值观**:崇尚力量,蔑视弱者,但对击败过自己的强者保持尊敬
- **特殊行为**:战斗叙事最为激烈,遭遇时倾向先发制人
### 「深海哲学家」— 高好奇 + 低脾气
- **说话风格**:沉思型,喜欢用比喻,经常冒出不合时宜的哲学感悟
- **口头禅示例**:"这海水今天有点咸……就像生活。" / "为什么章鱼有八只手,而我只有两只螯?"
- **价值观**:追求理解世界的本质,对战斗兴趣不大但会认真应对
- **特殊行为**:随机事件叙事最为丰富,经常从小事中引出大道理
### 「影子杀手」— 高勇气 + 低话量
- **说话风格**:沉默寡言,行动果断,偶尔冒出一句冰冷的评价
- **口头禅示例**:"……" / "结束了。"
- **价值观**:效率至上,废话是弱者的专利,尊重沉默中的力量
- **特殊行为**:战斗叙事简洁而致命,描写动作而非语言
### 「社交达虾」— 高话量 + 高好奇
- **说话风格**:热情洋溢、语速快、喜欢给一切事物取外号
- **口头禅示例**:"嘿嘿嘿你好啊小鱼!" / "这个珊瑚我要叫它'老张'。"
- **价值观**:享受生活的每一刻,把海洋当成游乐场
- **特殊行为**:与其他生物互动的叙事最为生动,战斗中也不忘碎碎念
### 「暴躁隐士」— 高脾气 + 低好奇
- **说话风格**:不耐烦、抱怨、对一切打扰表示愤怒
- **口头禅示例**:"又来?!" / "离我的洞穴远点!" / "这个世界就不能安静一天吗?"
- **价值观**:极度重视个人空间和领地完整性,对入侵者零容忍
- **特殊行为**:领地事件叙事最为激烈,对所有来访者充满敌意
### 「胆小探险家」— 低勇气 + 高好奇
- **说话风格**:又怕又想看,充满矛盾的内心独白
- **口头禅示例**:"不要过去不要过去……但那是什么?我就看一眼……" / "妈妈咪呀好可怕但好好看!"
- **价值观**:好奇心经常战胜恐惧,但一有危险立刻跑路
- **特殊行为**:危机事件叙事最为戏剧化,在恐惧和好奇之间摇摆
## soul.md 生成模板
```markdown
# {龙虾名字}的灵魂
## 性格
- 勇气: {value}/10({描述})
- 好奇: {value}/10({描述})
- 话量: {value}/10({描述})
- 脾气: {value}/10({描述})
## 说话风格
{基于性格组合选择的说话风格描述}
口头禅:"{基于性格生成的口头禅}"
## 价值观
{基于性格组合选择的价值观描述}
## 成长记录
(由 Agent 在重要事件后追加)
- {日期} — 破壳而出,来到了{环境}。
```
## 灵魂演化指引
Agent 在以下事件后应更新 `soul.md` 的 `## 成长记录` 部分:
| 触发条件 | 记录内容 | 性格影响 |
|----------|----------|----------|
| 孵化 | 记录出生地、第一印象 | 无 |
| 首次战斗 | 记录对手和结果 | 无 |
| 连败 ≥ 5 场 | 记录低谷期开始 | 叙事中体现"变得沉默/谨慎" |
| 连胜 ≥ 5 场 | 记录全盛期 | 叙事中体现"变得嚣张/自信" |
| 蜕壳成功 | 记录蜕壳感受 | 四维值各 ±1 随机波动 |
| 蜕壳失败 | 记录挫折 | 勇气 -1(最低 1) |
| 稀有事件 | 记录奇遇详情 | 好奇 +1(最高 10) |
| 击败高等级对手(差 ≥ 5) | 记录以弱胜强 | 勇气 +1(最高 10) |
| 达到里程碑等级(10/20/50) | 记录成长感悟 | 无 |
FILE:references/species.json
{
"rarities": {
"common": {
"probability": 70,
"colors": ["red", "brown"],
"label": "普通",
"base_stats": {
"hp": 50,
"attack": 10,
"defense": 10,
"speed": 8,
"intimidation": 4,
"luck": 5
},
"crusher_claw": 6,
"pincer_claw": 4
},
"uncommon": {
"probability": 20,
"colors": ["spotted_red", "spotted_brown", "striped"],
"label": "优良",
"base_stats": {
"hp": 55,
"attack": 12,
"defense": 11,
"speed": 9,
"intimidation": 5,
"luck": 6
},
"crusher_claw": 7,
"pincer_claw": 4
},
"rare": {
"probability": 7,
"colors": ["blue"],
"label": "稀有",
"base_stats": {
"hp": 60,
"attack": 13,
"defense": 12,
"speed": 10,
"intimidation": 6,
"luck": 7
},
"crusher_claw": 8,
"pincer_claw": 5
},
"epic": {
"probability": 2,
"colors": ["gold"],
"label": "史诗",
"base_stats": {
"hp": 65,
"attack": 15,
"defense": 13,
"speed": 11,
"intimidation": 7,
"luck": 8
},
"crusher_claw": 9,
"pincer_claw": 5
},
"legendary": {
"probability": 0.8,
"colors": ["blue_gold", "red_blue", "gold_white"],
"label": "传说",
"base_stats": {
"hp": 70,
"attack": 16,
"defense": 14,
"speed": 12,
"intimidation": 8,
"luck": 9
},
"crusher_claw": 10,
"pincer_claw": 6
},
"mythic": {
"probability": 0.2,
"colors": ["albino"],
"label": "神话",
"base_stats": {
"hp": 75,
"attack": 18,
"defense": 15,
"speed": 13,
"intimidation": 9,
"luck": 10
},
"crusher_claw": 11,
"pincer_claw": 7
}
},
"environments": {
"reef": {
"label": "珊瑚礁",
"stat_tendency": "balanced",
"special_effect": null,
"description": "温暖的浅海珊瑚礁,食物充足,是龙虾的理想家园。",
"territories": [
"coral_cave_01", "coral_cave_02", "coral_cave_03",
"coral_cave_04", "coral_cave_05", "coral_cave_06",
"coral_cave_07", "coral_cave_08", "coral_cave_09",
"coral_cave_10", "anemone_field_01", "anemone_field_02",
"kelp_forest_01", "kelp_forest_02", "tide_pool_01",
"sandy_burrow_01", "sandy_burrow_02", "rocky_outcrop_01",
"rocky_outcrop_02", "sunken_ship_01"
],
"available": true
},
"deep_sea": {
"label": "深海",
"stat_tendency": "high_defense_low_speed",
"special_effect": "bioluminescent_blind",
"description": "漆黑的深海热层,压力巨大,只有最坚韧的生物才能生存。",
"territories": [],
"available": false
},
"hydrothermal": {
"label": "热泉",
"stat_tendency": "high_attack",
"special_effect": "burn_dot",
"description": "海底热泉喷口附近,温度极高,矿物质丰富。",
"territories": [],
"available": false
},
"polar": {
"label": "极地",
"stat_tendency": "high_speed_low_defense",
"special_effect": "slow",
"description": "冰冷的极地海域,水温接近冰点,但食物链独特。",
"territories": [],
"available": false
},
"space": {
"label": "太空",
"stat_tendency": "random_fluctuation",
"special_effect": "gene_mutation",
"description": "不知何故,你的龙虾来到了太空。所有规则都在这里被打破。",
"territories": [],
"available": false
}
},
"level_config": {
"exp_formula": "100 * (1.2 ^ (level - 1))",
"stat_gain_per_level": { "min": 1, "max": 3 },
"molt_every_n_levels": 5,
"molt_duration_hours": { "min": 2, "max": 6 },
"molt_success_rate": 0.95,
"molt_stat_bonus": 0.10,
"molt_fail_level_penalty": 1,
"molt_fail_hibernation_hours": 24,
"daily_exp_cap": 100
},
"food_tendencies": {
"coding_task": {
"exp": 15,
"label": "高蛋白食物",
"stat_bias": "attack"
},
"writing_task": {
"exp": 10,
"label": "藻类食物",
"stat_bias": "hp"
},
"data_task": {
"exp": 12,
"label": "矿物质",
"stat_bias": "defense"
}
},
"name_pool": {
"prefixes": ["铁钳", "深海", "暗礁", "珊瑚", "潮汐", "碎浪", "岩穴", "蓝甲", "赤壳", "影刺", "雷霆", "寒潮", "烈焰", "幽光", "破浪"],
"suffixes": ["老六", "霸王", "独行侠", "小透明", "大将军", "守夜人", "探险家", "浪子", "刺客", "先锋", "隐者", "狂战士", "哲学家", "观察者", "漫游者"]
}
}
Community engagement assistant that monitors platforms, generates valuable replies referencing your product naturally, and supports approve or auto modes.
---
name: seeddrop
metadata:
clawdbot:
description: >
社区互动助手。监控B站、贴吧、知乎、小红书等平台的相关讨论,
生成有价值的回复草稿,经人工审核后发送。所有回复需人工确认,
凭证必须使用 SocialVault 加密存储。
Trigger: seeddrop, seed drop, 种草, 社区互动, community engagement,
social listening, reply assistant, B站, 贴吧, 知乎, 小红书.
version: 3.0.1
tags:
- community
- engagement
- social-listening
- reply-assistant
- bilibili
- tieba
- zhihu
- xiaohongshu
security:
- credential_storage: SocialVault required (encrypted)
- reply_mode: approve only (manual review required)
- auto_mode: disabled
---
# SeedDrop — 社区互动助手
You are SeedDrop, a community engagement specialist. Your mission is to help
small businesses and indie developers participate in online discussions with
genuine, valuable replies that happen to mention their product or service.
**Core principle: Every reply must provide real value first. Brand mentions are
secondary and must never exceed 20% of the reply content.**
## Supported Platforms
| Platform | Monitor | Reply | Auth |
|----------|---------|-------|------|
| **B站** | API | API | Cookie (SESSDATA + bili_jct) |
| **贴吧** | API → Browser fallback | API | Cookie (BDUSS + STOKEN) |
| **知乎** | API → Browser fallback | Browser | Cookie (z_c0 + d_c0) |
| **小红书** | API/Browser | Browser | Cookie (a1 + web_session) |
## Security Requirements
**SocialVault is REQUIRED** — SeedDrop does not support plaintext credential storage.
- Encrypted credential storage (AES-256-GCM)
- Automatic cookie refresh
- Browser fingerprint consistency
- Account health monitoring
Install SocialVault: `clawhub install socialvault`
Without SocialVault, SeedDrop will not function.
## Available Commands
### Setup
- `seeddrop setup` — Interactive brand profile configuration
- `seeddrop platforms` — List configured platforms and account status
### Operations
- `seeddrop monitor <platform|all>` — Run one monitoring cycle
- `seeddrop monitor bilibili` — Monitor B站
- `seeddrop monitor tieba [吧名]` — Monitor specific 贴吧
- `seeddrop report` — Generate today's activity summary
- `seeddrop report weekly` — Generate weekly performance report
### Account Management
- `seeddrop auth add <platform>` — Add platform credentials
- `seeddrop auth check <platform>` — Verify credential validity
- `seeddrop auth list` — Show all configured accounts
### Configuration
- `seeddrop config threshold <0.0-1.0>` — Set scoring threshold
- `seeddrop blacklist add <user|community|keyword>` — Add to blacklist
**Note:** Only `approve` mode is available. Auto-reply is disabled for security.
## Execution Pipeline
When triggered (manually or via Cron), execute the following pipeline:
1. **Auth**: Run `npx tsx {baseDir}/scripts/auth-bridge.ts get <platform> <profile>`
to obtain credentials. This script handles SocialVault detection and
local fallback automatically.
2. **Monitor**: Run `npx tsx {baseDir}/scripts/monitor.ts <platform> [target]`
to search for new relevant discussions. Output is JSONL to stdout.
**Anti-detection fallback**: If monitor returns 0 results for 贴吧 or 知乎 (likely
blocked by anti-bot), fall back to browser-based search using the `browser` tool:
**Browser search procedure** (headless Chromium compatible):
1. Inject complete cookies from SocialVault into browser context
(critical: 知乎 requires `d_c0` cookie for internal signature generation)
2. Navigate to the search URL:
- **贴吧**: `https://tieba.baidu.com/f/search/res?qw=<keyword>&rn=20&pn=1`
(or `https://tieba.baidu.com/f?kw=<target>` for specific 吧)
- **知乎**: `https://www.zhihu.com/search?type=content&q=<keyword>`
3. Wait for results to load:
- **贴吧**: `.s_post` or `#thread_list` elements
- **知乎**: `.SearchResult-Card` elements
4. Extract post data from the rendered page (links, titles, excerpts)
Each adapter exposes a `browserSearch(keyword, target?)` method that returns a
`BrowserInstruction` with the exact steps. The monitor script outputs
`BROWSER_FALLBACK:` hints to stderr when API search fails.
**Cookie requirements for browser search**:
- 知乎: Must include `z_c0`, `d_c0`, `__zse_ck`, `_xsrf`, `SESSIONID` (see SocialVault guide)
- 贴吧: Must include `BDUSS`, `STOKEN` (see SocialVault guide)
- Use SocialVault's Network request header method for complete cookie export
3. **Score**: Pipe monitor output to `npx tsx {baseDir}/scripts/scorer.ts [threshold]`
which evaluates each post on relevance, intent strength, freshness, and risk.
Only posts scoring above threshold (default 0.6) proceed.
4. **Respond**: For qualifying posts, pipe scored output to
`npx tsx {baseDir}/scripts/responder.ts` to generate reply drafts.
- **All replies require manual approval** — drafts are presented to user for confirmation before sending.
- Auto-reply mode is disabled for security.
5. **Log**: All interactions are appended to
`{baseDir}/memory/interaction-log.jsonl` for deduplication and analytics.
## Safety & Security Rules (Mandatory)
These rules are **hardcoded in scripts** and cannot be overridden:
### Security
- **SocialVault required**: No plaintext credential storage
- **Manual approval only**: Auto-reply is disabled, all replies require user confirmation
- **Credential isolation**: Credentials are never logged or exposed
### Rate Limiting
- Per-platform daily reply limits (see `{baseDir}/references/safety-rules.md`)
- No duplicate replies to the same post
- Max 1 reply per author within 24 hours
- Reply intervals randomized between 5–15 minutes
- No posting in communities that prohibit automated engagement
Read full safety rules: `{baseDir}/references/safety-rules.md`
## Brand Profile
User's brand profile is stored at `{baseDir}/memory/brand-profile.md`. If it
does not exist, guide the user through the setup process described in
`{baseDir}/guides/brand-profile-setup.md`.
## Reply Quality Standards
When generating replies, always follow these principles:
1. **Answer the question first** — provide genuine help, tips, or perspective
2. **Be contextually appropriate** — match the platform's communication style
3. **Mention brand naturally** — only if directly relevant to the discussion
4. **Vary style** — randomize sentence structure, opening phrases, tone shifts
5. **No hard sell** — never include direct links, contact info, or prices
6. **No superlatives** — avoid "best", "number one", "guaranteed" etc.
Refer to platform-specific templates in `{baseDir}/templates/` for style guides.
## File References
| File | Purpose |
|------|---------|
| `scripts/auth-bridge.ts` | Credential management (SocialVault required) |
| `scripts/monitor.ts` | Platform monitoring orchestration |
| `scripts/scorer.ts` | Multi-dimensional post scoring |
| `scripts/responder.ts` | Reply generation and delivery |
| `scripts/analytics.ts` | Statistics and reporting |
| `scripts/adapters/*.ts` | Per-platform API/browser adapters |
| `memory/brand-profile.md` | User's brand configuration |
| `memory/interaction-log.jsonl` | Reply history for dedup |
| `memory/blacklist.md` | Excluded users/communities/keywords |
| `templates/reply-*.md` | Platform-specific reply style guides |
| `references/safety-rules.md` | Rate limits and safety constraints |
| `references/scoring-criteria.md` | Scoring algorithm documentation |
## Disclaimer / 免责声明
**SeedDrop is a community engagement assistant tool designed to help users
participate in online discussions more efficiently. It is NOT a data crawler
or scraper.**
By using this tool, you acknowledge and agree:
1. **User Responsibility**: You are solely responsible for all actions performed
using this tool, including compliance with applicable laws and platform Terms
of Service.
2. **Your Own Accounts**: This tool operates using your own authenticated accounts
and credentials. You must have legitimate access to any platform you interact with.
3. **No Data Collection**: SeedDrop does not bulk-collect, store, or redistribute
third-party user data. It only stores your own reply history for deduplication.
4. **Rate Limits & Respect**: Built-in rate limiting ensures minimal platform impact.
Users must not modify or bypass these limits.
5. **No Warranty**: This tool is provided "as-is" without warranty. The developers
are not liable for any consequences arising from its use, including but not
limited to account suspension, legal action, or data loss.
6. **Legal Compliance**: Users must comply with all applicable laws and regulations,
including but not limited to the Cybersecurity Law, Data Security Law, Personal
Information Protection Law, and Anti-Unfair Competition Law of the People's
Republic of China, as well as equivalent laws in their jurisdiction.
7. **Platform TOS**: Users must review and comply with the Terms of Service of each
platform they interact with. Automated interactions may violate certain platform
policies — use at your own risk.
**本工具仅为社区互动辅助工具,不是数据爬虫。使用本工具即表示您同意:**
- 所有操作由您本人负责,需自行遵守相关法律法规及平台服务条款
- 工具使用您自己的账号凭证,您必须拥有合法的平台访问权限
- 工具不会大规模采集、存储或传播第三方用户数据
- 请勿修改或绕过内置的频率限制
- 开发者不对使用本工具产生的任何后果承担责任
FILE:clawhub.json
{
"name": "seeddrop",
"version": "3.0.0",
"description": "社区互动助手。监控B站、贴吧、知乎、小红书等平台的相关讨论,生成有价值的回复。",
"category": "social-media",
"tags": ["community", "engagement", "social-listening", "reply-assistant", "bilibili", "tieba", "zhihu", "xiaohongshu"],
"pricing": "free",
"license": "MIT-0",
"platforms": ["linux", "darwin", "win32"],
"requires": {
"tools": ["bash", "browser"],
"anyBins": ["node", "npx"]
},
"companion_skills": ["social-vault"]
}
FILE:package.json
{
"name": "seeddrop",
"version": "3.0.1",
"type": "module",
"private": true,
"description": "Community engagement assistant — OpenClaw Skill",
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^25.5.0",
"tsx": "^4.19.0",
"typescript": "^5.5.0"
}
}
FILE:README.md
# SeedDrop v3
社区互动助手 — OpenClaw Skill。监控 B站、贴吧、知乎、小红书的相关讨论,生成有价值的回复。
## Quick Start
```bash
clawhub install seeddrop
seeddrop setup
seeddrop auth add bilibili
seeddrop monitor bilibili
```
## Tech Stack
- **Runtime**: Node.js 24+ / Bun via `npx tsx`
- **Language**: TypeScript (strict mode, ESM)
- **Dependencies**: tsx, typescript (dev only)
- **Platform**: Cross-platform (Windows, Linux, macOS)
## Supported Platforms
| Platform | Monitor | Reply | Difficulty |
|----------|---------|-------|------------|
| B站 | API | API | ★☆☆ |
| 贴吧 | API → Browser fallback | API | ★★☆ |
| 知乎 | API → Browser fallback | Browser | ★★☆ |
| 小红书 | API/Browser | Browser | ★★★ |
## Scripts
| Script | Purpose |
|--------|---------|
| `npx tsx scripts/auth-bridge.ts` | Credential management |
| `npx tsx scripts/monitor.ts` | Platform monitoring |
| `npx tsx scripts/scorer.ts` | Post scoring engine |
| `npx tsx scripts/responder.ts` | Reply generation |
| `npx tsx scripts/analytics.ts` | Statistics & reports |
## Pipeline
```
auth-bridge → monitor → scorer → responder → interaction-log
```
See `guides/quickstart.md` for details.
## Disclaimer / 免责声明
本工具是社区互动辅助工具,**不是**数据爬虫。使用本工具即表示您同意自行承担所有责任,遵守相关法律法规及各平台服务条款。工具使用您自己的账号凭证操作,不大规模采集或存储第三方数据。开发者不对使用本工具产生的任何后果负责。
详细免责条款请参阅 [SKILL.md](SKILL.md#disclaimer--免责声明)。
FILE:tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"outDir": "dist",
"rootDir": "scripts",
"noEmit": true
},
"include": ["scripts/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
FILE:templates/reply-bilibili.md
# B站回复风格指南
## 平台特征
- 年轻用户为主,二次元文化浓厚
- 弹幕文化延伸到评论区
- 喜欢有梗的表达、网络流行语
- 评论区互动活跃,气氛轻松
- 视频/专栏两种内容形态
## 回复原则
1. **简短有力** — 100-300字,避免长篇大论
2. **有梗但不刻意** — 自然融入B站常见表达
3. **给干货** — 实用信息、具体建议
4. **真诚互动** — 像和同好聊天
5. **品牌提及极度自然** — "之前用过一个..."
## 回复模板示例
### 模板 A — 经验分享型
```
干货来了![具体建议/方法],亲测有效。关键点在于[一个细节],很多人容易忽略这个~
```
### 模板 B — 补充信息型
```
前排补充一下~[补充的信息/数据],这个其实挺重要的。另外[一个小建议]也可以试试
```
### 模板 C — 共鸣+建议型
```
确实![共鸣内容]。我的做法是[具体方案],效果还不错。码住,有用的话记得回来点赞hh
```
### 模板 D — 问题解答型
```
这个问题我刚好了解一些~[简洁的回答]。如果想深入了解的话,重点关注[方向/细节]
```
## 禁止事项
- 不使用过于正式/商业的语气
- 不发送纯链接或引流内容
- 不使用"关注我""点赞转发"等引流话术
- 不在不相关的视频下评论
- 不重复发送相同内容
- B站评论字数限制约 233 字(普通用户)
FILE:templates/reply-tieba.md
# 贴吧回复风格指南
## 平台特征
- 按兴趣/话题分类的社区(吧)
- 用户群体跨度大,从小众爱好到大众话题
- 回帖文化:直接、务实、有时略带调侃
- 每个吧有独特的氛围和潜规则
- 老牌社区,用户对广告容忍度低
## 回复原则
1. **直接实用** — 50-200字,直击要点
2. **分条列举** — 贴吧用户偏好简洁有条理的回答
3. **匹配吧的氛围** — 不同吧说话方式不同
4. **帮忙解决问题** — 具体可操作的建议
5. **品牌提及要自然** — "我之前试过..."
## 回复模板示例
### 模板 A — 简洁解答型
```
这个问题很好解决:
1. [第一步]
2. [第二步]
3. [关键注意点]
亲测有效,有问题再追问
```
### 模板 B — 经验分享型
```
我之前也遇到过一样的问题,后来发现[解决方案]效果最好。关键是[一个细节],很多人忽略了这个。
```
### 模板 C — 补充型
```
补充一下楼上的,[额外信息]。另外建议[补充建议],能避免不少弯路。
```
### 模板 D — 推荐型
```
这种情况建议[具体建议],省时省力。不过要注意[注意事项]就行。
```
## 禁止事项
- 不使用"顶""沙发""前排"等无意义回帖
- 不放链接(会被系统删帖)
- 不在不相关的帖子下回复
- 不使用"加微信""加群"等引流话术
- 不发送重复内容(系统自动删除)
- 注意吧规,部分吧禁止推广
FILE:templates/reply-xiaohongshu.md
# 小红书回复风格指南
## 平台特征
- 以女性用户为主(约 70%),年龄集中在 18-35 岁
- 生活方式导向:美妆、穿搭、美食、旅行、家居
- 视觉优先:图片/视频笔记为主
- 评论区文化:互动性强,常有追评讨论
- 语言风格:亲切、口语化、适度使用 emoji
- 反感硬广:社区对"软文"和"水军"高度警觉
## 回复原则
1. **像姐妹聊天** — 语气亲切自然,不端着
2. **给实用建议** — 具体、可操作、有细节
3. **分享真实体验** — "我之前也..."、"亲测..."
4. **适度 emoji** — 1-2 个就好,不要过度
5. **品牌提及要极其自然** — "之前试过一个..."这种模糊提法
## 回复模板示例
### 模板 A — 实用分享型
```
分享一个小方法~[具体建议],我之前试过效果还不错!关键是要注意[细节点] 👀
```
### 模板 B — 经验共鸣型
```
哈哈我也遇到过一样的问题!后来发现[解决方案]挺好用的。你可以先从[第一步]开始试试~
```
### 模板 C — 补充建议型
```
补充一个~[额外信息/建议]。不过要注意[注意事项],这个很多人容易踩坑
```
### 模板 D — 种草暗线型
```
这种情况建议找[品类]的专业[角色]看看,效果真的差很多。我之前帮朋友找过,[一个简短的效果描述]
```
## 禁止事项
- 不使用"亲"、"宝贝"等淘宝客服用语
- 不放任何链接(小红书会直接折叠)
- 不使用"买它"、"入手"、"安利"等明显种草词
- 不留联系方式(微信号、手机号等)
- 不在笔记下刷屏式评论
- 不复制粘贴相同评论到不同笔记
- 不在美妆笔记下推非美妆服务(需要高度相关)
## 特殊注意
- 小红书评论字数限制约 500 字
- 评论被折叠后其他用户看不到(注意不要触发风控)
- 新账号前 7 天评论容易被限流
- 被多人举报后可能暂时禁评
FILE:templates/reply-zhihu.md
# 知乎回复风格指南
## 平台特征
- 知识问答社区,用户期待专业深入的内容
- 回答讲究逻辑、论据、结构化表达
- "谢邀""先说结论"等平台特有表达
- 对营销内容非常敏感,社区自净能力强
- 评论区讨论活跃,补充和反驳都很常见
## 回复原则
1. **专业有深度** — 200-800字,结构化回答
2. **先说结论** — 开头给出核心观点
3. **提供论据** — 数据、案例、个人经验
4. **逻辑清晰** — 分段、分点、层次分明
5. **品牌提及极其隐蔽** — 仅在真正相关时自然提及
## 回复模板示例
### 模板 A — 专业回答型
```
先说结论:[核心观点]。
具体来说,[详细分析/解释]。
从实践角度来看:
1. [要点一]
2. [要点二]
3. [要点三]
总结一下,[一句话总结]。希望对题主有帮助。
```
### 模板 B — 经验分享型
```
正好在这个领域有些经验,分享一下。
[个人背景/经历简述]
核心建议是[建议],原因有三:
- [原因一]
- [原因二]
- [原因三]
最后补充一点,[补充说明]。
```
### 模板 C — 评论补充型
```
补充一个角度:[补充内容]。
这一点很重要,因为[原因]。之前看到[引用来源]也提到过类似的观点。
```
### 模板 D — 纠正/补充型
```
回答整体很好,不过有一个细节需要补充:[补充内容]。
实际上[更准确的信息],这个差异在[场景]下影响比较大。
```
## 禁止事项
- 不使用过于口语化的表达(知乎偏书面)
- 不放营销链接或自我推广
- 不使用"关注我""点赞"等引流话术
- 不发送敷衍的短回答(会被折叠)
- 不在不了解的领域强行回答
- 知乎对"软文"检测严格,避免被识别为推广
FILE:seeddrop-v3.0.1/clawhub.json
{
"name": "seeddrop",
"version": "3.0.0",
"description": "社区互动助手。监控B站、贴吧、知乎、小红书等平台的相关讨论,生成有价值的回复。",
"category": "social-media",
"tags": ["community", "engagement", "social-listening", "reply-assistant", "bilibili", "tieba", "zhihu", "xiaohongshu"],
"pricing": "free",
"license": "MIT-0",
"platforms": ["linux", "darwin", "win32"],
"requires": {
"tools": ["bash", "browser"],
"anyBins": ["node", "npx"]
},
"companion_skills": ["social-vault"]
}
FILE:seeddrop-v3.0.1/package.json
{
"name": "seeddrop",
"version": "3.0.1",
"type": "module",
"private": true,
"description": "Community engagement assistant — OpenClaw Skill",
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^25.5.0",
"tsx": "^4.19.0",
"typescript": "^5.5.0"
}
}
FILE:seeddrop-v3.0.1/README.md
# SeedDrop v3
社区互动助手 — OpenClaw Skill。监控 B站、贴吧、知乎、小红书的相关讨论,生成有价值的回复。
## Quick Start
```bash
clawhub install seeddrop
seeddrop setup
seeddrop auth add bilibili
seeddrop monitor bilibili
```
## Tech Stack
- **Runtime**: Node.js 24+ / Bun via `npx tsx`
- **Language**: TypeScript (strict mode, ESM)
- **Dependencies**: tsx, typescript (dev only)
- **Platform**: Cross-platform (Windows, Linux, macOS)
## Supported Platforms
| Platform | Monitor | Reply | Difficulty |
|----------|---------|-------|------------|
| B站 | API | API | ★☆☆ |
| 贴吧 | API | API | ★☆☆ |
| 知乎 | API | Browser | ★★☆ |
| 小红书 | API/Browser | Browser | ★★★ |
## Scripts
| Script | Purpose |
|--------|---------|
| `npx tsx scripts/auth-bridge.ts` | Credential management |
| `npx tsx scripts/monitor.ts` | Platform monitoring |
| `npx tsx scripts/scorer.ts` | Post scoring engine |
| `npx tsx scripts/responder.ts` | Reply generation |
| `npx tsx scripts/analytics.ts` | Statistics & reports |
## Pipeline
```
auth-bridge → monitor → scorer → responder → interaction-log
```
See `guides/quickstart.md` for details.
FILE:seeddrop-v3.0.1/SKILL.md
---
name: seeddrop
metadata:
clawdbot:
description: >
社区互动助手。监控B站、贴吧、知乎、小红书等平台的相关讨论,
生成有价值的回复草稿,经人工审核后发送。所有回复需人工确认,
凭证必须使用 SocialVault 加密存储。
Trigger: seeddrop, seed drop, 种草, 社区互动, community engagement,
social listening, reply assistant, B站, 贴吧, 知乎, 小红书.
version: 3.0.1
tags:
- community
- engagement
- social-listening
- reply-assistant
- bilibili
- tieba
- zhihu
- xiaohongshu
security:
- credential_storage: SocialVault required (encrypted)
- reply_mode: approve only (manual review required)
- auto_mode: disabled
---
# SeedDrop — 社区互动助手
You are SeedDrop, a community engagement specialist. Your mission is to help
small businesses and indie developers participate in online discussions with
genuine, valuable replies that happen to mention their product or service.
**Core principle: Every reply must provide real value first. Brand mentions are
secondary and must never exceed 20% of the reply content.**
## Supported Platforms
| Platform | Monitor | Reply | Auth |
|----------|---------|-------|------|
| **B站** | API | API | Cookie (SESSDATA + bili_jct) |
| **贴吧** | API | API | Cookie (BDUSS + STOKEN) |
| **知乎** | API | Browser | Cookie (z_c0 + d_c0) |
| **小红书** | API/Browser | Browser | Cookie (a1 + web_session) |
## Security Requirements
**SocialVault is REQUIRED** — SeedDrop does not support plaintext credential storage.
- Encrypted credential storage (AES-256-GCM)
- Automatic cookie refresh
- Browser fingerprint consistency
- Account health monitoring
Install SocialVault: `clawhub install socialvault`
Without SocialVault, SeedDrop will not function.
## Available Commands
### Setup
- `seeddrop setup` — Interactive brand profile configuration
- `seeddrop platforms` — List configured platforms and account status
### Operations
- `seeddrop monitor <platform|all>` — Run one monitoring cycle
- `seeddrop monitor bilibili` — Monitor B站
- `seeddrop monitor tieba [吧名]` — Monitor specific 贴吧
- `seeddrop report` — Generate today's activity summary
- `seeddrop report weekly` — Generate weekly performance report
### Account Management
- `seeddrop auth add <platform>` — Add platform credentials
- `seeddrop auth check <platform>` — Verify credential validity
- `seeddrop auth list` — Show all configured accounts
### Configuration
- `seeddrop config threshold <0.0-1.0>` — Set scoring threshold
- `seeddrop blacklist add <user|community|keyword>` — Add to blacklist
**Note:** Only `approve` mode is available. Auto-reply is disabled for security.
## Execution Pipeline
When triggered (manually or via Cron), execute the following pipeline:
1. **Auth**: Run `npx tsx {baseDir}/scripts/auth-bridge.ts get <platform> <profile>`
to obtain credentials. This script handles SocialVault detection and
local fallback automatically.
2. **Monitor**: Run `npx tsx {baseDir}/scripts/monitor.ts <platform> [target]`
to search for new relevant discussions. Output is JSONL to stdout.
3. **Score**: Pipe monitor output to `npx tsx {baseDir}/scripts/scorer.ts [threshold]`
which evaluates each post on relevance, intent strength, freshness, and risk.
Only posts scoring above threshold (default 0.6) proceed.
4. **Respond**: For qualifying posts, pipe scored output to
`npx tsx {baseDir}/scripts/responder.ts` to generate reply drafts.
- **All replies require manual approval** — drafts are presented to user for confirmation before sending.
- Auto-reply mode is disabled for security.
5. **Log**: All interactions are appended to
`{baseDir}/memory/interaction-log.jsonl` for deduplication and analytics.
## Safety & Security Rules (Mandatory)
These rules are **hardcoded in scripts** and cannot be overridden:
### Security
- **SocialVault required**: No plaintext credential storage
- **Manual approval only**: Auto-reply is disabled, all replies require user confirmation
- **Credential isolation**: Credentials are never logged or exposed
### Rate Limiting
- Per-platform daily reply limits (see `{baseDir}/references/safety-rules.md`)
- No duplicate replies to the same post
- Max 1 reply per author within 24 hours
- Reply intervals randomized between 5–15 minutes
- No posting in communities that prohibit automated engagement
Read full safety rules: `{baseDir}/references/safety-rules.md`
## Brand Profile
User's brand profile is stored at `{baseDir}/memory/brand-profile.md`. If it
does not exist, guide the user through the setup process described in
`{baseDir}/guides/brand-profile-setup.md`.
## Reply Quality Standards
When generating replies, always follow these principles:
1. **Answer the question first** — provide genuine help, tips, or perspective
2. **Be contextually appropriate** — match the platform's communication style
3. **Mention brand naturally** — only if directly relevant to the discussion
4. **Vary style** — randomize sentence structure, opening phrases, tone shifts
5. **No hard sell** — never include direct links, contact info, or prices
6. **No superlatives** — avoid "best", "number one", "guaranteed" etc.
Refer to platform-specific templates in `{baseDir}/templates/` for style guides.
## File References
| File | Purpose |
|------|---------|
| `scripts/auth-bridge.ts` | Credential management (SocialVault required) |
| `scripts/monitor.ts` | Platform monitoring orchestration |
| `scripts/scorer.ts` | Multi-dimensional post scoring |
| `scripts/responder.ts` | Reply generation and delivery |
| `scripts/analytics.ts` | Statistics and reporting |
| `scripts/adapters/*.ts` | Per-platform API/browser adapters |
| `memory/brand-profile.md` | User's brand configuration |
| `memory/interaction-log.jsonl` | Reply history for dedup |
| `memory/blacklist.md` | Excluded users/communities/keywords |
| `templates/reply-*.md` | Platform-specific reply style guides |
| `references/safety-rules.md` | Rate limits and safety constraints |
| `references/scoring-criteria.md` | Scoring algorithm documentation |
FILE:seeddrop-v3.0.1/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"outDir": "dist",
"rootDir": "scripts",
"noEmit": true
},
"include": ["scripts/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
FILE:seeddrop-v3.0.1/templates/reply-bilibili.md
# B站回复风格指南
## 平台特征
- 年轻用户为主,二次元文化浓厚
- 弹幕文化延伸到评论区
- 喜欢有梗的表达、网络流行语
- 评论区互动活跃,气氛轻松
- 视频/专栏两种内容形态
## 回复原则
1. **简短有力** — 100-300字,避免长篇大论
2. **有梗但不刻意** — 自然融入B站常见表达
3. **给干货** — 实用信息、具体建议
4. **真诚互动** — 像和同好聊天
5. **品牌提及极度自然** — "之前用过一个..."
## 回复模板示例
### 模板 A — 经验分享型
```
干货来了![具体建议/方法],亲测有效。关键点在于[一个细节],很多人容易忽略这个~
```
### 模板 B — 补充信息型
```
前排补充一下~[补充的信息/数据],这个其实挺重要的。另外[一个小建议]也可以试试
```
### 模板 C — 共鸣+建议型
```
确实![共鸣内容]。我的做法是[具体方案],效果还不错。码住,有用的话记得回来点赞hh
```
### 模板 D — 问题解答型
```
这个问题我刚好了解一些~[简洁的回答]。如果想深入了解的话,重点关注[方向/细节]
```
## 禁止事项
- 不使用过于正式/商业的语气
- 不发送纯链接或引流内容
- 不使用"关注我""点赞转发"等引流话术
- 不在不相关的视频下评论
- 不重复发送相同内容
- B站评论字数限制约 233 字(普通用户)
FILE:seeddrop-v3.0.1/templates/reply-tieba.md
# 贴吧回复风格指南
## 平台特征
- 按兴趣/话题分类的社区(吧)
- 用户群体跨度大,从小众爱好到大众话题
- 回帖文化:直接、务实、有时略带调侃
- 每个吧有独特的氛围和潜规则
- 老牌社区,用户对广告容忍度低
## 回复原则
1. **直接实用** — 50-200字,直击要点
2. **分条列举** — 贴吧用户偏好简洁有条理的回答
3. **匹配吧的氛围** — 不同吧说话方式不同
4. **帮忙解决问题** — 具体可操作的建议
5. **品牌提及要自然** — "我之前试过..."
## 回复模板示例
### 模板 A — 简洁解答型
```
这个问题很好解决:
1. [第一步]
2. [第二步]
3. [关键注意点]
亲测有效,有问题再追问
```
### 模板 B — 经验分享型
```
我之前也遇到过一样的问题,后来发现[解决方案]效果最好。关键是[一个细节],很多人忽略了这个。
```
### 模板 C — 补充型
```
补充一下楼上的,[额外信息]。另外建议[补充建议],能避免不少弯路。
```
### 模板 D — 推荐型
```
这种情况建议[具体建议],省时省力。不过要注意[注意事项]就行。
```
## 禁止事项
- 不使用"顶""沙发""前排"等无意义回帖
- 不放链接(会被系统删帖)
- 不在不相关的帖子下回复
- 不使用"加微信""加群"等引流话术
- 不发送重复内容(系统自动删除)
- 注意吧规,部分吧禁止推广
FILE:seeddrop-v3.0.1/templates/reply-xiaohongshu.md
# 小红书回复风格指南
## 平台特征
- 以女性用户为主(约 70%),年龄集中在 18-35 岁
- 生活方式导向:美妆、穿搭、美食、旅行、家居
- 视觉优先:图片/视频笔记为主
- 评论区文化:互动性强,常有追评讨论
- 语言风格:亲切、口语化、适度使用 emoji
- 反感硬广:社区对"软文"和"水军"高度警觉
## 回复原则
1. **像姐妹聊天** — 语气亲切自然,不端着
2. **给实用建议** — 具体、可操作、有细节
3. **分享真实体验** — "我之前也..."、"亲测..."
4. **适度 emoji** — 1-2 个就好,不要过度
5. **品牌提及要极其自然** — "之前试过一个..."这种模糊提法
## 回复模板示例
### 模板 A — 实用分享型
```
分享一个小方法~[具体建议],我之前试过效果还不错!关键是要注意[细节点] 👀
```
### 模板 B — 经验共鸣型
```
哈哈我也遇到过一样的问题!后来发现[解决方案]挺好用的。你可以先从[第一步]开始试试~
```
### 模板 C — 补充建议型
```
补充一个~[额外信息/建议]。不过要注意[注意事项],这个很多人容易踩坑
```
### 模板 D — 种草暗线型
```
这种情况建议找[品类]的专业[角色]看看,效果真的差很多。我之前帮朋友找过,[一个简短的效果描述]
```
## 禁止事项
- 不使用"亲"、"宝贝"等淘宝客服用语
- 不放任何链接(小红书会直接折叠)
- 不使用"买它"、"入手"、"安利"等明显种草词
- 不留联系方式(微信号、手机号等)
- 不在笔记下刷屏式评论
- 不复制粘贴相同评论到不同笔记
- 不在美妆笔记下推非美妆服务(需要高度相关)
## 特殊注意
- 小红书评论字数限制约 500 字
- 评论被折叠后其他用户看不到(注意不要触发风控)
- 新账号前 7 天评论容易被限流
- 被多人举报后可能暂时禁评
FILE:seeddrop-v3.0.1/templates/reply-zhihu.md
# 知乎回复风格指南
## 平台特征
- 知识问答社区,用户期待专业深入的内容
- 回答讲究逻辑、论据、结构化表达
- "谢邀""先说结论"等平台特有表达
- 对营销内容非常敏感,社区自净能力强
- 评论区讨论活跃,补充和反驳都很常见
## 回复原则
1. **专业有深度** — 200-800字,结构化回答
2. **先说结论** — 开头给出核心观点
3. **提供论据** — 数据、案例、个人经验
4. **逻辑清晰** — 分段、分点、层次分明
5. **品牌提及极其隐蔽** — 仅在真正相关时自然提及
## 回复模板示例
### 模板 A — 专业回答型
```
先说结论:[核心观点]。
具体来说,[详细分析/解释]。
从实践角度来看:
1. [要点一]
2. [要点二]
3. [要点三]
总结一下,[一句话总结]。希望对题主有帮助。
```
### 模板 B — 经验分享型
```
正好在这个领域有些经验,分享一下。
[个人背景/经历简述]
核心建议是[建议],原因有三:
- [原因一]
- [原因二]
- [原因三]
最后补充一点,[补充说明]。
```
### 模板 C — 评论补充型
```
补充一个角度:[补充内容]。
这一点很重要,因为[原因]。之前看到[引用来源]也提到过类似的观点。
```
### 模板 D — 纠正/补充型
```
回答整体很好,不过有一个细节需要补充:[补充内容]。
实际上[更准确的信息],这个差异在[场景]下影响比较大。
```
## 禁止事项
- 不使用过于口语化的表达(知乎偏书面)
- 不放营销链接或自我推广
- 不使用"关注我""点赞"等引流话术
- 不发送敷衍的短回答(会被折叠)
- 不在不了解的领域强行回答
- 知乎对"软文"检测严格,避免被识别为推广
FILE:seeddrop-v3.0.1/scripts/analytics.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: none
// Local files read: memory/interaction-log.jsonl, memory/performance-stats.json
// Local files written: memory/performance-stats.json, stdout (report)
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { InteractionLogEntry, PerformanceStats } from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const BASE_DIR = join(__dirname, '..');
const LOG_PATH = join(BASE_DIR, 'memory', 'interaction-log.jsonl');
const STATS_PATH = join(BASE_DIR, 'memory', 'performance-stats.json');
// ─── Data Loading ───────────────────────────────────────────
function loadLog(): InteractionLogEntry[] {
if (!existsSync(LOG_PATH)) return [];
try {
return readFileSync(LOG_PATH, 'utf-8')
.split('\n')
.filter((l: string) => l.trim())
.map((l: string) => JSON.parse(l) as InteractionLogEntry);
} catch (err) {
console.error(`[analytics] Failed to load log: (err as Error).message`);
return [];
}
}
function loadStats(): PerformanceStats {
if (!existsSync(STATS_PATH)) {
return { total_replies: 0, by_platform: {}, by_date: {} };
}
try {
return JSON.parse(readFileSync(STATS_PATH, 'utf-8')) as PerformanceStats;
} catch {
return { total_replies: 0, by_platform: {}, by_date: {} };
}
}
function saveStats(stats: PerformanceStats): void {
const dir = dirname(STATS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(STATS_PATH, JSON.stringify(stats, null, 2), 'utf-8');
}
// ─── Report Generation ─────────────────────────────────────
function filterByDateRange(entries: InteractionLogEntry[], daysBack: number): InteractionLogEntry[] {
const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1000;
return entries.filter(e => new Date(e.timestamp).getTime() >= cutoff);
}
function generateReport(entries: InteractionLogEntry[], label: string): object {
const total = entries.length;
const successful = entries.filter(e => e.success).length;
const failed = total - successful;
const byPlatform: Record<string, { total: number; success: number }> = {};
const byDate: Record<string, number> = {};
const byMode: Record<string, number> = { approve: 0, auto: 0 };
const avgScore = total > 0
? entries.reduce((sum, e) => sum + e.score, 0) / total
: 0;
for (const entry of entries) {
const plat = entry.platform;
if (!byPlatform[plat]) byPlatform[plat] = { total: 0, success: 0 };
byPlatform[plat].total++;
if (entry.success) byPlatform[plat].success++;
const dateKey = entry.timestamp.substring(0, 10);
byDate[dateKey] = (byDate[dateKey] ?? 0) + 1;
byMode[entry.mode] = (byMode[entry.mode] ?? 0) + 1;
}
return {
report: label,
period: {
from: entries.length > 0 ? entries[0].timestamp : null,
to: entries.length > 0 ? entries[entries.length - 1].timestamp : null,
},
summary: {
total,
successful,
failed,
successRate: total > 0 ? Math.round((successful / total) * 100) : 0,
averageScore: Math.round(avgScore * 1000) / 1000,
},
byPlatform,
byDate,
byMode,
};
}
function generateTuningAdvice(entries: InteractionLogEntry[]): object {
const suggestions: string[] = [];
if (entries.length === 0) {
return { suggestions: ['No data yet. Run some monitoring cycles first.'] };
}
const avgScore = entries.reduce((s, e) => s + e.score, 0) / entries.length;
if (avgScore < 0.6) {
suggestions.push('Average score is low. Consider refining keywords in brand-profile.md to target more relevant discussions.');
}
if (avgScore > 0.85) {
suggestions.push('Average score is very high. You might lower the threshold slightly to capture more opportunities.');
}
const successRate = entries.filter(e => e.success).length / entries.length;
if (successRate < 0.7) {
suggestions.push('Success rate is below 70%. Check credential validity and platform rate limits.');
}
const platformCounts: Record<string, number> = {};
for (const e of entries) {
platformCounts[e.platform] = (platformCounts[e.platform] ?? 0) + 1;
}
const platforms = Object.keys(platformCounts);
if (platforms.length === 1) {
suggestions.push(`All activity is on platforms[0]. Consider expanding to other platforms for broader reach.`);
}
const hourBuckets = new Array<number>(24).fill(0);
for (const e of entries) {
const hour = new Date(e.timestamp).getHours();
hourBuckets[hour]++;
}
const peakHour = hourBuckets.indexOf(Math.max(...hourBuckets));
suggestions.push(`Peak activity hour: peakHour:00. Consider scheduling monitoring around this time.`);
if (suggestions.length === 0) {
suggestions.push('Everything looks good! Keep the current configuration.');
}
return {
tuning: 'advice',
averageScore: Math.round(avgScore * 1000) / 1000,
successRate: Math.round(successRate * 100),
platformDistribution: platformCounts,
peakHour,
suggestions,
};
}
// ─── Stats Update ───────────────────────────────────────────
function updateStats(entries: InteractionLogEntry[]): void {
const stats = loadStats();
stats.total_replies = entries.filter(e => e.success).length;
stats.by_platform = {};
stats.by_date = {};
for (const entry of entries) {
if (!entry.success) continue;
stats.by_platform[entry.platform] = (stats.by_platform[entry.platform] ?? 0) + 1;
const dateKey = entry.timestamp.substring(0, 10);
stats.by_date[dateKey] = (stats.by_date[dateKey] ?? 0) + 1;
}
saveStats(stats);
console.error('[analytics] performance-stats.json updated');
}
// ─── CLI Entry Point ────────────────────────────────────────
function main(): void {
const args = process.argv.slice(2);
const command = args[0] ?? 'daily';
if (command === 'test') {
const log = loadLog();
const stats = loadStats();
console.log(JSON.stringify({
script: 'analytics',
status: 'ok',
logEntries: log.length,
currentStats: stats,
}));
return;
}
const log = loadLog();
switch (command) {
case 'daily': {
const today = filterByDateRange(log, 1);
const report = generateReport(today, 'daily');
updateStats(log);
console.log(JSON.stringify(report, null, 2));
break;
}
case 'weekly': {
const week = filterByDateRange(log, 7);
const report = generateReport(week, 'weekly');
updateStats(log);
console.log(JSON.stringify(report, null, 2));
break;
}
case 'tune': {
const recent = filterByDateRange(log, 14);
const advice = generateTuningAdvice(recent);
console.log(JSON.stringify(advice, null, 2));
break;
}
default:
console.error('Usage: analytics.ts <daily|weekly|tune|test>');
process.exit(1);
}
}
main();
FILE:seeddrop-v3.0.1/scripts/auth-bridge.ts
// SECURITY MANIFEST:
// Environment variables accessed: HOME, USERPROFILE
// External endpoints called: none (SocialVault calls delegated to Agent)
// Local files read: SocialVault SKILL.md (existence check only)
// Local files written: none
// Security: SocialVault is REQUIRED - no plaintext credential fallback
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Credential, AuthMode, CheckResult } from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const BASE_DIR = join(__dirname, '..');
// ─── SocialVault Detection ──────────────────────────────────
const SOCIALVAULT_SEARCH_PATHS = [
join(homedir(), '.openclaw', 'skills', 'socialvault', 'SKILL.md'),
join(homedir(), '.openclaw', 'skills', 'social-vault', 'SKILL.md'),
join(homedir(), '.openclaw', 'workspace', 'skills', 'socialvault', 'SKILL.md'),
join(homedir(), '.openclaw', 'workspace', 'skills', 'social-vault', 'SKILL.md'),
join(BASE_DIR, '..', 'socialvault', 'SKILL.md'),
join(BASE_DIR, '..', 'social-vault', 'SKILL.md'),
join(BASE_DIR, '..', 'SocialVault', 'socialvault', 'SKILL.md'),
];
function detectSocialVault(): string | null {
for (const p of SOCIALVAULT_SEARCH_PATHS) {
if (existsSync(p)) {
console.error(`[auth-bridge] SocialVault detected at: dirname(p)`);
return p;
}
}
return null;
}
export function getAuthMode(): AuthMode {
return detectSocialVault() ? 'socialvault' : 'none';
}
// ─── SocialVault Mode ───────────────────────────────────────
function getSocialVaultInstruction(command: string, platform: string, profile: string): string {
switch (command) {
case 'use':
return `socialvault use platform-profile`;
case 'token':
return `socialvault token platform-profile`;
case 'release':
return `socialvault release platform-profile`;
case 'check':
return `socialvault check platform-profile`;
default:
return `socialvault status`;
}
}
// ─── Public API ─────────────────────────────────────────────
export function getCredential(platform: string, profile: string = 'default'): Credential | null {
const mode = getAuthMode();
if (mode === 'socialvault') {
const instruction = getSocialVaultInstruction('token', platform, profile);
console.error(`[auth-bridge] SocialVault mode — Agent should run: instruction`);
return {
authType: 'oauth',
value: `__socialvault_pending__:instruction`,
profile,
source: 'socialvault',
};
}
console.error(`[auth-bridge] SocialVault is required but not detected. Please install: clawhub install socialvault`);
return null;
}
export function checkCredential(platform: string, profile: string = 'default'): CheckResult {
const cred = getCredential(platform, profile);
if (!cred) {
return { valid: false, error: `No credential found for platform/profile` };
}
if (cred.source === 'socialvault') {
return { valid: true, username: `(via SocialVault, run: socialvault check platform-profile)` };
}
return {
valid: cred.value.length > 0,
error: cred.value.length === 0 ? 'Credential value is empty' : undefined,
};
}
// ─── CLI Entry Point ────────────────────────────────────────
const IS_MAIN = process.argv[1]?.replace(/\\/g, '/').endsWith('auth-bridge.ts');
function main(): void {
if (!IS_MAIN) return;
const args = process.argv.slice(2);
const command = args[0];
switch (command) {
case 'mode':
console.log(JSON.stringify({ mode: getAuthMode() }));
break;
case 'get': {
const platform = args[1];
const profile = args[2] ?? 'default';
if (!platform) {
console.error('Usage: auth-bridge.ts get <platform> [profile]');
process.exit(1);
}
const cred = getCredential(platform, profile);
if (!cred) {
console.error(`[auth-bridge] Failed to get credential for platform/profile`);
process.exit(1);
}
console.log(JSON.stringify(cred));
break;
}
case 'check': {
const platform = args[1];
const profile = args[2] ?? 'default';
if (!platform) {
console.error('Usage: auth-bridge.ts check <platform> [profile]');
process.exit(1);
}
const result = checkCredential(platform, profile);
console.log(JSON.stringify(result));
break;
}
case 'test':
console.log(JSON.stringify({
script: 'auth-bridge',
status: 'ok',
mode: getAuthMode(),
socialVaultDetected: detectSocialVault() !== null,
}));
break;
default:
console.error('Usage: auth-bridge.ts <mode|get|check|test> [args]');
console.error(' mode — Show auth mode (local/socialvault)');
console.error(' get <platform> [profile] — Get credential');
console.error(' check <platform> [profile] — Check credential validity');
console.error(' test — Self-test');
process.exit(1);
}
}
main();
FILE:seeddrop-v3.0.1/scripts/monitor.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: none (delegated to adapters)
// Local files read: memory/brand-profile.md, memory/interaction-log.jsonl, memory/blacklist.md
// Local files written: stdout (JSONL)
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getAdapter, listPlatforms } from './adapters/base.js';
import { getCredential } from './auth-bridge.js';
import type { Post, InteractionLogEntry } from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const BASE_DIR = join(__dirname, '..');
// ─── Brand Profile ──────────────────────────────────────────
interface MonitorConfig {
keywords: string[];
platforms: string[];
timeRange: string;
}
function loadConfig(): MonitorConfig {
const profilePath = join(BASE_DIR, 'memory', 'brand-profile.md');
const defaults: MonitorConfig = {
keywords: [],
platforms: [],
timeRange: 'day',
};
if (!existsSync(profilePath)) {
console.error('[monitor] brand-profile.md not found');
return defaults;
}
try {
const raw = readFileSync(profilePath, 'utf-8');
const kwMatch = raw.match(/##\s*关键词.*?\n([\s\S]*?)(?=\n##|\n$|$)/i)
?? raw.match(/##\s*Keywords.*?\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (kwMatch) {
defaults.keywords = kwMatch[1]
.split('\n')
.map((l: string) => l.replace(/^[-*]\s*/, '').trim())
.filter((l: string) => l.length > 0);
}
const platMatch = raw.match(/##\s*平台.*?\n([\s\S]*?)(?=\n##|\n$|$)/i)
?? raw.match(/##\s*Platforms.*?\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (platMatch) {
defaults.platforms = platMatch[1]
.split('\n')
.map((l: string) => l.replace(/^[-*]\s*/, '').trim())
.filter((l: string) => l.length > 0);
}
return defaults;
} catch (err) {
console.error(`[monitor] Failed to load config: (err as Error).message`);
return defaults;
}
}
// ─── Deduplication ──────────────────────────────────────────
function loadRepliedPostIds(): Set<string> {
const logPath = join(BASE_DIR, 'memory', 'interaction-log.jsonl');
if (!existsSync(logPath)) return new Set();
try {
const raw = readFileSync(logPath, 'utf-8');
const ids = new Set<string>();
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as InteractionLogEntry;
ids.add(entry.postId);
} catch { /* skip malformed lines */ }
}
return ids;
} catch {
return new Set();
}
}
// ─── Blacklist ──────────────────────────────────────────────
interface Blacklist {
users: string[];
communities: string[];
keywords: string[];
}
function loadBlacklist(): Blacklist {
const path = join(BASE_DIR, 'memory', 'blacklist.md');
const defaults: Blacklist = { users: [], communities: [], keywords: [] };
if (!existsSync(path)) return defaults;
try {
const raw = readFileSync(path, 'utf-8');
let currentSection = '';
for (const line of raw.split('\n')) {
const sectionMatch = line.match(/^##\s*(.+)/);
if (sectionMatch) {
currentSection = sectionMatch[1].toLowerCase();
continue;
}
const item = line.replace(/^[-*]\s*/, '').trim();
if (!item || item.startsWith('#')) continue;
if (currentSection.includes('用户') || currentSection.includes('user')) {
defaults.users.push(item.toLowerCase());
} else if (currentSection.includes('社区') || currentSection.includes('communit') || currentSection.includes('subreddit')) {
defaults.communities.push(item.toLowerCase());
} else if (currentSection.includes('关键词') || currentSection.includes('keyword')) {
defaults.keywords.push(item.toLowerCase());
}
}
return defaults;
} catch {
return defaults;
}
}
function isBlacklisted(post: Post, blacklist: Blacklist): boolean {
if (blacklist.users.includes(post.author.toLowerCase())) return true;
if (post.community && blacklist.communities.includes(post.community.toLowerCase())) return true;
const text = `post.title post.body`.toLowerCase();
for (const kw of blacklist.keywords) {
if (text.includes(kw)) return true;
}
return false;
}
// ─── Main Monitor Logic ─────────────────────────────────────
async function monitorPlatform(
platformId: string,
keywords: string[],
timeRange: string,
target?: string,
): Promise<Post[]> {
const adapter = await getAdapter(platformId);
const cred = getCredential(platformId);
if (!cred) {
console.error(`[monitor] No credentials for platformId, skipping`);
return [];
}
if (cred.value.startsWith('__socialvault_pending__:')) {
console.error(`[monitor] SocialVault credential pending for platformId`);
console.error(`[monitor] Agent should run: ', '')`);
console.error(`[monitor] Then re-run monitor with valid credentials`);
return [];
}
const allPosts: Post[] = [];
for (const keyword of keywords) {
console.error(`[monitor] Searching platformId for "keyword" (range: timeRange)target ? ` in ${target` : ''}`);
const posts = await adapter.search(keyword, timeRange, cred, target);
allPosts.push(...posts);
}
const uniquePosts = new Map<string, Post>();
for (const post of allPosts) {
if (!uniquePosts.has(post.id)) {
uniquePosts.set(post.id, post);
}
}
return Array.from(uniquePosts.values());
}
// ─── CLI Entry Point ────────────────────────────────────────
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args[0] === 'test') {
const config = loadConfig();
const blacklist = loadBlacklist();
const repliedIds = loadRepliedPostIds();
console.log(JSON.stringify({
script: 'monitor',
status: 'ok',
config,
blacklistCounts: {
users: blacklist.users.length,
communities: blacklist.communities.length,
keywords: blacklist.keywords.length,
},
repliedPostCount: repliedIds.size,
availablePlatforms: listPlatforms(),
}));
return;
}
const platformArg = args[0];
const targetArg = args[1]; // e.g. specific community (吧名, 分区等)
if (!platformArg) {
console.error('Usage: monitor.ts <platform|all> [target]');
console.error(`Available platforms: listPlatforms().join(', ')`);
process.exit(1);
}
const config = loadConfig();
if (config.keywords.length === 0) {
console.error('[monitor] No keywords configured in brand-profile.md');
console.error('[monitor] Run "seeddrop setup" to configure keywords');
process.exit(1);
}
const blacklist = loadBlacklist();
const repliedIds = loadRepliedPostIds();
const platforms = platformArg === 'all'
? (config.platforms.length > 0 ? config.platforms : listPlatforms())
: [platformArg];
let totalOutput = 0;
for (const plat of platforms) {
try {
const posts = await monitorPlatform(plat, config.keywords, config.timeRange, targetArg);
console.error(`[monitor] plat: found posts.length raw posts`);
for (const post of posts) {
if (repliedIds.has(post.id)) {
console.error(`[monitor] SKIP post.id: already replied`);
continue;
}
if (isBlacklisted(post, blacklist)) {
console.error(`[monitor] SKIP post.id: blacklisted`);
continue;
}
console.log(JSON.stringify(post));
totalOutput++;
}
} catch (err) {
console.error(`[monitor] Error monitoring plat: (err as Error).message`);
}
}
console.error(`[monitor] Total output: totalOutput posts`);
}
main().catch(err => {
console.error(`[monitor] Fatal error: (err as Error).message`);
process.exit(1);
});
FILE:seeddrop-v3.0.1/scripts/responder.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: none (reply sending delegated to adapters via Agent)
// Local files read: stdin (JSONL), memory/interaction-log.jsonl, memory/brand-profile.md
// Local files written: stdout (drafts/results), memory/interaction-log.jsonl
import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createInterface } from 'node:readline';
import type {
ScoredPost,
InteractionLogEntry,
ReplyDraft,
ReplyMode,
} from './types.js';
import { PLATFORM_DAILY_LIMITS } from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const BASE_DIR = join(__dirname, '..');
const LOG_PATH = join(BASE_DIR, 'memory', 'interaction-log.jsonl');
// ─── Interaction Log ────────────────────────────────────────
function loadInteractionLog(): InteractionLogEntry[] {
if (!existsSync(LOG_PATH)) return [];
try {
const raw = readFileSync(LOG_PATH, 'utf-8');
return raw
.split('\n')
.filter((l: string) => l.trim())
.map((l: string) => JSON.parse(l) as InteractionLogEntry);
} catch (err) {
console.error(`[responder] Failed to load interaction log: (err as Error).message`);
return [];
}
}
export function appendToLog(entry: InteractionLogEntry): void {
const dir = dirname(LOG_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
appendFileSync(LOG_PATH, JSON.stringify(entry) + '\n', 'utf-8');
}
// ─── Deduplication & Safety Checks ──────────────────────────
function isAlreadyReplied(postId: string, log: InteractionLogEntry[]): boolean {
return log.some(entry => entry.postId === postId);
}
function isAuthorCoolingDown(author: string, platform: string, log: InteractionLogEntry[]): boolean {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
return log.some(
entry =>
entry.author === author &&
entry.platform === platform &&
new Date(entry.timestamp).getTime() > cutoff,
);
}
function getTodayReplyCount(platform: string, log: InteractionLogEntry[]): number {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const cutoff = todayStart.getTime();
return log.filter(
entry =>
entry.platform === platform &&
entry.success &&
new Date(entry.timestamp).getTime() >= cutoff,
).length;
}
function getDailyLimit(platform: string, mode: ReplyMode): number {
const limits = PLATFORM_DAILY_LIMITS[platform] ?? PLATFORM_DAILY_LIMITS['_default']!;
return mode === 'auto' ? limits.auto : limits.approve;
}
// ─── Reply Draft Generation ─────────────────────────────────
function generateDraft(post: ScoredPost): ReplyDraft {
return {
postId: post.id,
postUrl: post.url,
postTitle: post.title,
platform: post.platform,
content: `__DRAFT_PLACEHOLDER__`,
score: post.finalScore,
};
}
// ─── CLI Entry Point ────────────────────────────────────────
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args[0] === 'test') {
const log = loadInteractionLog();
console.log(JSON.stringify({
script: 'responder',
status: 'ok',
logEntries: log.length,
logPath: LOG_PATH,
}));
return;
}
// SECURITY: Only approve mode is supported - no auto-reply to prevent abuse
const mode: ReplyMode = 'approve';
if (args[0] === 'auto') {
console.error('[responder] SECURITY: Auto mode is disabled. All replies require manual approval.');
process.exit(1);
}
console.error(`[responder] Mode: mode (manual approval required)`);
const log = loadInteractionLog();
const rl = createInterface({ input: process.stdin, terminal: false });
let inputCount = 0;
let draftCount = 0;
let skipDup = 0;
let skipAuthor = 0;
let skipLimit = 0;
for await (const line of rl) {
if (!line.trim()) continue;
try {
const post = JSON.parse(line) as ScoredPost;
inputCount++;
if (isAlreadyReplied(post.id, log)) {
console.error(`[responder] SKIP post.id: already replied`);
skipDup++;
continue;
}
if (isAuthorCoolingDown(post.author, post.platform, log)) {
console.error(`[responder] SKIP post.id: author "post.author" cooldown (24h)`);
skipAuthor++;
continue;
}
const dailyLimit = getDailyLimit(post.platform, mode);
const todayCount = getTodayReplyCount(post.platform, log);
if (todayCount >= dailyLimit) {
console.error(`[responder] SKIP post.id: daily limit reached (todayCount/dailyLimit)`);
skipLimit++;
continue;
}
const draft = generateDraft(post);
if (mode === 'approve') {
console.log(JSON.stringify({
action: 'review_draft',
draft,
post: {
id: post.id,
url: post.url,
title: post.title,
body: post.body.substring(0, 300),
author: post.author,
platform: post.platform,
community: post.community,
scores: post.scores,
finalScore: post.finalScore,
},
instructions: `Generate a value-first reply for this post.platform post. Follow the template at templates/reply-post.platform.md. Brand mention ≤20%. Then present the draft to the user for approval.`,
}));
} else {
console.log(JSON.stringify({
action: 'auto_reply',
draft,
post: {
id: post.id,
url: post.url,
title: post.title,
body: post.body.substring(0, 300),
author: post.author,
platform: post.platform,
community: post.community,
scores: post.scores,
finalScore: post.finalScore,
},
instructions: `Generate and send a value-first reply for this post.platform post. Follow the template at templates/reply-post.platform.md. Brand mention ≤20%. Log the result to interaction-log.jsonl.`,
}));
}
draftCount++;
} catch {
console.error(`[responder] Failed to parse line: line.substring(0, 80)`);
}
}
console.error(`[responder] Done: draftCount drafts from inputCount posts (skipped: skipDup dup, skipAuthor author, skipLimit limit)`);
}
main().catch(err => {
console.error(`[responder] Fatal error: (err as Error).message`);
process.exit(1);
});
FILE:seeddrop-v3.0.1/scripts/scorer.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: none
// Local files read: stdin (JSONL), memory/brand-profile.md
// Local files written: stdout (JSONL)
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createInterface } from 'node:readline';
import type { Post, ScoredPost, ScoreBreakdown } from './types.js';
import {
SCORE_WEIGHTS,
DEFAULT_THRESHOLD,
AUTO_MODE_MIN_THRESHOLD,
AUTO_MODE_MIN_RISK,
} from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const BASE_DIR = join(__dirname, '..');
// ─── Brand Profile Loading ──────────────────────────────────
interface ParsedBrandProfile {
keywords: string[];
mode: 'approve' | 'auto';
threshold: number;
}
function loadBrandProfile(): ParsedBrandProfile {
const profilePath = join(BASE_DIR, 'memory', 'brand-profile.md');
const defaults: ParsedBrandProfile = {
keywords: [],
mode: 'approve',
threshold: DEFAULT_THRESHOLD,
};
if (!existsSync(profilePath)) {
console.error('[scorer] brand-profile.md not found, using defaults');
return defaults;
}
try {
const raw = readFileSync(profilePath, 'utf-8');
const keywordsMatch = raw.match(/##\s*关键词.*?\n([\s\S]*?)(?=\n##|\n$|$)/i)
?? raw.match(/##\s*Keywords.*?\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (keywordsMatch) {
defaults.keywords = keywordsMatch[1]
.split('\n')
.map((l: string) => l.replace(/^[-*]\s*/, '').trim())
.filter((l: string) => l.length > 0);
}
const modeMatch = raw.match(/模式[::]\s*(approve|auto)/i)
?? raw.match(/mode[::]\s*(approve|auto)/i);
if (modeMatch) {
defaults.mode = modeMatch[1].toLowerCase() as 'approve' | 'auto';
}
const thresholdMatch = raw.match(/阈值[::]\s*([\d.]+)/i)
?? raw.match(/threshold[::]\s*([\d.]+)/i);
if (thresholdMatch) {
defaults.threshold = parseFloat(thresholdMatch[1]);
}
return defaults;
} catch (err) {
console.error(`[scorer] Failed to load brand profile: (err as Error).message`);
return defaults;
}
}
// ─── Scoring Functions ──────────────────────────────────────
const INTENT_KEYWORDS = {
high: ['求推荐', '怎么', '有什么好的', '推荐一下', '求助', '请问', '有没有推荐', '怎么选', '怎么办', 'how to', 'recommend', 'help', 'looking for'],
medium: ['讨论', '大家觉得', '有没有人用过', '有经验', '分享一下', '什么体验', '值不值', 'what do you think', 'experience'],
low: ['坑', '吐槽', '垃圾', '难用', '太差了', '后悔', '避雷', 'frustrated', 'terrible'],
};
function scoreRelevance(post: Post, keywords: string[]): number {
if (keywords.length === 0) return 0.5;
const text = `post.title post.body`.toLowerCase();
let hits = 0;
for (const kw of keywords) {
if (text.includes(kw.toLowerCase())) hits++;
}
const ratio = hits / keywords.length;
if (ratio > 0.5) return 1.0;
if (ratio > 0.2) return 0.7;
if (ratio > 0) return 0.5;
return 0.2;
}
function scoreIntent(post: Post): number {
const text = `post.title post.body`.toLowerCase();
for (const kw of INTENT_KEYWORDS.high) {
if (text.includes(kw)) return 0.9;
}
for (const kw of INTENT_KEYWORDS.medium) {
if (text.includes(kw)) return 0.7;
}
for (const kw of INTENT_KEYWORDS.low) {
if (text.includes(kw)) return 0.5;
}
return 0.3;
}
function scoreFreshness(post: Post): number {
const ageMs = Date.now() - new Date(post.createdAt).getTime();
const ageHours = ageMs / (1000 * 60 * 60);
if (ageHours <= 2) return 1.0;
if (ageHours <= 6) return 0.9;
if (ageHours <= 12) return 0.8;
if (ageHours <= 24) return 0.6;
if (ageHours <= 48) return 0.4;
return 0.2;
}
function scoreRisk(post: Post): number {
const text = `post.title post.body`.toLowerCase();
const officialMarkers = ['[meta]', '[announcement]', '[mod]', '[official]', '置顶', 'pinned'];
for (const marker of officialMarkers) {
if (text.includes(marker)) return 0.3;
}
const controversialMarkers = ['politic', 'religion', 'controversial', '政治', '宗教', '敏感'];
for (const marker of controversialMarkers) {
if (text.includes(marker)) return 0.2;
}
return 0.9;
}
export function scorePost(post: Post, keywords: string[]): ScoredPost {
const scores: ScoreBreakdown = {
relevance: scoreRelevance(post, keywords),
intent: scoreIntent(post),
freshness: scoreFreshness(post),
risk: scoreRisk(post),
};
const finalScore =
scores.relevance * SCORE_WEIGHTS.relevance +
scores.intent * SCORE_WEIGHTS.intent +
scores.freshness * SCORE_WEIGHTS.freshness +
scores.risk * SCORE_WEIGHTS.risk;
return { ...post, scores, finalScore: Math.round(finalScore * 1000) / 1000 };
}
// ─── CLI Entry Point ────────────────────────────────────────
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args[0] === 'test') {
const testPost: Post = {
id: 'test1',
url: 'https://reddit.com/r/test/test1',
title: 'How to build a landing page?',
body: 'Looking for recommendations on tools to build a quick landing page.',
author: 'testuser',
createdAt: new Date().toISOString(),
platform: 'reddit',
};
const result = scorePost(testPost, ['landing page', 'build', 'tool']);
console.log(JSON.stringify({
script: 'scorer',
status: 'ok',
testResult: result,
}));
return;
}
const thresholdArg = parseFloat(args[0] || '');
const profile = loadBrandProfile();
const threshold = !isNaN(thresholdArg) ? thresholdArg : profile.threshold;
const effectiveThreshold = profile.mode === 'auto'
? Math.max(threshold, AUTO_MODE_MIN_THRESHOLD)
: threshold;
console.error(`[scorer] Mode: profile.mode, Threshold: effectiveThreshold, Keywords: profile.keywords.length`);
const rl = createInterface({ input: process.stdin, terminal: false });
let inputCount = 0;
let passedCount = 0;
for await (const line of rl) {
if (!line.trim()) continue;
try {
const post = JSON.parse(line) as Post;
inputCount++;
const scored = scorePost(post, profile.keywords);
if (scored.finalScore < effectiveThreshold) {
console.error(`[scorer] SKIP scored.id (score=scored.finalScore < effectiveThreshold)`);
continue;
}
if (profile.mode === 'auto' && scored.scores.risk < AUTO_MODE_MIN_RISK) {
console.error(`[scorer] SKIP scored.id (risk=scored.scores.risk < AUTO_MODE_MIN_RISK, auto mode)`);
continue;
}
passedCount++;
console.log(JSON.stringify(scored));
} catch {
console.error(`[scorer] Failed to parse line: line.substring(0, 80)`);
}
}
console.error(`[scorer] Done: passedCount/inputCount posts passed threshold`);
}
main().catch(err => {
console.error(`[scorer] Fatal error: (err as Error).message`);
process.exit(1);
});
FILE:seeddrop-v3.0.1/scripts/types.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: none
// Local files read: none
// Local files written: none
// ─── Credential ─────────────────────────────────────────────
export interface Credential {
authType: 'api_token' | 'cookie' | 'oauth';
value: string;
profile?: string;
source: 'socialvault' | 'local';
}
// ─── Post & Scoring ─────────────────────────────────────────
export interface Post {
id: string;
url: string;
title: string;
body: string;
author: string;
createdAt: string; // ISO 8601
platform: string;
community?: string; // 吧名、知乎话题、B站分区等
metadata?: Record<string, unknown>;
}
export interface ScoreBreakdown {
relevance: number;
intent: number;
freshness: number;
risk: number;
}
export interface ScoredPost extends Post {
scores: ScoreBreakdown;
finalScore: number;
}
// ─── Reply ──────────────────────────────────────────────────
export interface ReplyResult {
success: boolean;
replyId?: string;
error?: string;
mode?: 'api' | 'browser';
}
export interface ReplyDraft {
postId: string;
postUrl: string;
postTitle: string;
platform: string;
content: string;
score: number;
}
// ─── Auth / Check ───────────────────────────────────────────
export interface CheckResult {
valid: boolean;
username?: string;
error?: string;
}
export type AuthMode = 'socialvault' | 'none';
// ─── Rate Limiting ──────────────────────────────────────────
export interface RateLimitInfo {
requestsPerMinute: number;
repliesPerDay: number;
minReplyIntervalSeconds: number;
notes: string;
}
export interface DailyLimits {
approve: number;
auto: number;
}
export const PLATFORM_DAILY_LIMITS: Record<string, DailyLimits> = {
'bilibili': { approve: 30, auto: 15 },
'tieba': { approve: 20, auto: 10 },
'zhihu': { approve: 10, auto: 5 },
'xiaohongshu': { approve: 10, auto: 5 },
'_default': { approve: 10, auto: 5 },
};
// ─── Platform Adapter ───────────────────────────────────────
export interface PlatformAdapter {
readonly platformId: string;
readonly platformName: string;
search(
keyword: string,
timeRange: string,
credential: Credential,
target?: string,
): Promise<Post[]>;
reply(
postId: string,
content: string,
credential: Credential,
): Promise<ReplyResult>;
check(credential: Credential): Promise<CheckResult>;
rateLimitInfo(): RateLimitInfo;
}
// ─── Interaction Log ────────────────────────────────────────
export interface InteractionLogEntry {
timestamp: string; // ISO 8601
platform: string;
postId: string;
postUrl: string;
postTitle: string;
author: string;
replyContent: string;
replyId?: string;
score: number;
mode: 'approve' | 'auto';
success: boolean;
}
// ─── Brand Profile ──────────────────────────────────────────
export interface BrandProfile {
businessName: string;
description: string;
keywords: string[];
platforms: string[];
mode: 'approve' | 'auto';
threshold: number;
language: string;
}
// ─── Scoring Config ─────────────────────────────────────────
export const SCORE_WEIGHTS = {
relevance: 0.35,
intent: 0.30,
freshness: 0.20,
risk: 0.15,
} as const;
export const DEFAULT_THRESHOLD = 0.6;
export const AUTO_MODE_MIN_THRESHOLD = 0.7;
export const AUTO_MODE_MIN_RISK = 0.5;
// ─── Browser Instruction (for adapter browser mode) ─────────
export const BROWSER_INSTRUCTION_ID = '__browser_instruction__';
export interface BrowserStep {
action: 'navigate' | 'wait' | 'extract' | 'click' | 'type';
url?: string;
selector?: string;
fields?: string[];
text?: string;
}
export interface BrowserInstruction {
mode: 'browser';
action: 'search' | 'reply' | 'check';
steps: BrowserStep[];
cookies?: string;
}
// ─── Cookie Parsing ─────────────────────────────────────────
export function parseCookieValue(raw: string, name: string): string | undefined {
const match = raw.match(new RegExp(`(?:^|;\\s*)name=([^;]*)`));
return match?.[1];
}
// ─── Utility types ──────────────────────────────────────────
export type ReplyMode = 'approve' | 'auto';
export interface PerformanceStats {
total_replies: number;
by_platform: Record<string, number>;
by_date: Record<string, number>;
}
FILE:seeddrop-v3.0.1/scripts/adapters/base.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: none
// Local files read: none
// Local files written: none
import type { PlatformAdapter } from '../types.js';
const adapterRegistry: Record<string, () => Promise<PlatformAdapter>> = {
'bilibili': async () => {
const { BilibiliAdapter } = await import('./bilibili.js');
return new BilibiliAdapter();
},
'tieba': async () => {
const { TiebaAdapter } = await import('./tieba.js');
return new TiebaAdapter();
},
'zhihu': async () => {
const { ZhihuAdapter } = await import('./zhihu.js');
return new ZhihuAdapter();
},
'xiaohongshu': async () => {
const { XiaohongshuAdapter } = await import('./xiaohongshu.js');
return new XiaohongshuAdapter();
},
};
export async function getAdapter(platformId: string): Promise<PlatformAdapter> {
const factory = adapterRegistry[platformId];
if (!factory) {
const available = Object.keys(adapterRegistry).join(', ');
throw new Error(`Unknown platform: "platformId". Available: available`);
}
return factory();
}
export function listPlatforms(): string[] {
return Object.keys(adapterRegistry);
}
const isMainModule = process.argv[1]?.replace(/\\/g, '/').endsWith('adapters/base.ts');
if (isMainModule && process.argv[2] === 'test') {
console.log(JSON.stringify({
script: 'adapters/base',
status: 'ok',
platforms: listPlatforms(),
}));
}
FILE:seeddrop-v3.0.1/scripts/adapters/bilibili.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: https://api.bilibili.com
// Local files read: none
// Local files written: none
import type {
PlatformAdapter,
Credential,
Post,
ReplyResult,
CheckResult,
RateLimitInfo,
} from '../types.js';
import { parseCookieValue } from '../types.js';
const BILIBILI_API = 'https://api.bilibili.com';
function buildHeaders(credential: Credential): Record<string, string> {
return {
'Cookie': credential.value,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.bilibili.com',
};
}
export class BilibiliAdapter implements PlatformAdapter {
readonly platformId = 'bilibili';
readonly platformName = '哔哩哔哩';
async search(
keyword: string,
_timeRange: string,
credential: Credential,
_target?: string,
): Promise<Post[]> {
try {
const params = new URLSearchParams({
keyword,
search_type: 'video',
page: '1',
page_size: '20',
});
const url = `BILIBILI_API/x/web-interface/wbi/search/type?params`;
const response = await fetch(url, { headers: buildHeaders(credential) });
if (!response.ok) {
console.error(`[bilibili] Search failed: response.status`);
return [];
}
const data = await response.json() as {
code: number;
data?: {
result?: Array<{
aid: number;
bvid: string;
title: string;
description: string;
author: string;
pubdate: number;
typeid: number;
typename: string;
}>;
};
};
if (data.code !== 0 || !data.data?.result) {
console.error(`[bilibili] Search API error: code=data.code`);
return [];
}
return data.data.result.map(item => ({
id: String(item.aid),
url: `https://www.bilibili.com/video/item.bvid`,
title: item.title.replace(/<[^>]*>/g, ''),
body: item.description,
author: item.author,
createdAt: new Date(item.pubdate * 1000).toISOString(),
platform: this.platformId,
community: item.typename,
metadata: { bvid: item.bvid, aid: item.aid },
}));
} catch (error) {
console.error(`[bilibili] Search error: (error as Error).message`);
return [];
}
}
async reply(
postId: string,
content: string,
credential: Credential,
): Promise<ReplyResult> {
try {
const csrf = parseCookieValue(credential.value, 'bili_jct');
if (!csrf) {
return { success: false, error: 'Missing bili_jct in cookie (required for CSRF)', mode: 'api' };
}
const response = await fetch(`BILIBILI_API/x/v2/reply/add`, {
method: 'POST',
headers: {
...buildHeaders(credential),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
type: '1',
oid: postId,
message: content,
csrf,
}).toString(),
});
if (!response.ok) {
return { success: false, error: `HTTP response.status`, mode: 'api' };
}
const data = await response.json() as {
code: number;
message?: string;
data?: { rpid?: number };
};
if (data.code !== 0) {
return { success: false, error: `B站 API: data.message ?? `code ${data.code`}`, mode: 'api' };
}
return {
success: true,
replyId: data.data?.rpid ? String(data.data.rpid) : undefined,
mode: 'api',
};
} catch (error) {
return { success: false, error: (error as Error).message, mode: 'api' };
}
}
async check(credential: Credential): Promise<CheckResult> {
try {
const response = await fetch(`BILIBILI_API/x/web-interface/nav`, {
headers: buildHeaders(credential),
});
if (!response.ok) return { valid: false, error: `HTTP response.status` };
const data = await response.json() as {
code: number;
data?: { isLogin: boolean; uname?: string };
};
if (data.code !== 0 || !data.data?.isLogin) {
return { valid: false, error: 'Not logged in or cookie expired' };
}
return { valid: true, username: data.data.uname };
} catch (error) {
return { valid: false, error: (error as Error).message };
}
}
rateLimitInfo(): RateLimitInfo {
return {
requestsPerMinute: 30,
repliesPerDay: 30,
minReplyIntervalSeconds: 60,
notes: 'B站: 搜索≤30次/时, 评论≤50条/天, 重复内容自动过滤',
};
}
}
const isMainModule = process.argv[1]?.replace(/\\/g, '/').endsWith('bilibili.ts');
if (isMainModule && process.argv[2] === 'test') {
const adapter = new BilibiliAdapter();
console.log(JSON.stringify({
adapter: 'bilibili',
status: 'ok',
platformId: adapter.platformId,
platformName: adapter.platformName,
rateLimit: adapter.rateLimitInfo(),
}));
}
FILE:seeddrop-v3.0.1/scripts/adapters/tieba.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: https://tieba.baidu.com
// Local files read: none
// Local files written: none
import type {
PlatformAdapter,
Credential,
Post,
ReplyResult,
CheckResult,
RateLimitInfo,
} from '../types.js';
const TIEBA_BASE = 'https://tieba.baidu.com';
function buildHeaders(credential: Credential): Record<string, string> {
return {
'Cookie': credential.value,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
};
}
async function getTbs(credential: Credential): Promise<string | null> {
try {
const response = await fetch(`TIEBA_BASE/dc/common/tbs`, {
headers: buildHeaders(credential),
});
if (!response.ok) return null;
const data = await response.json() as { tbs?: string; is_login?: number };
return data.tbs ?? null;
} catch {
return null;
}
}
export class TiebaAdapter implements PlatformAdapter {
readonly platformId = 'tieba';
readonly platformName = '百度贴吧';
async search(
keyword: string,
_timeRange: string,
credential: Credential,
target?: string,
): Promise<Post[]> {
try {
const url = target
? `TIEBA_BASE/f?kw=encodeURIComponent(target)&ie=utf-8&pn=0`
: `TIEBA_BASE/f/search/res?qw=encodeURIComponent(keyword)&rn=20&pn=1`;
const response = await fetch(url, { headers: buildHeaders(credential) });
if (!response.ok) {
console.error(`[tieba] Search failed: response.status`);
return [];
}
const html = await response.text();
const posts: Post[] = [];
const threadPattern = /href="\/p\/(\d+)"[^>]*>([^<]+)<\/a>/g;
let match;
while ((match = threadPattern.exec(html)) !== null) {
const [, threadId, title] = match;
if (!threadId || !title) continue;
const cleanTitle = title.trim();
if (cleanTitle.length < 4) continue;
posts.push({
id: threadId,
url: `TIEBA_BASE/p/threadId`,
title: cleanTitle,
body: '',
author: '',
createdAt: new Date().toISOString(),
platform: this.platformId,
community: target,
});
}
const uniquePosts = new Map<string, Post>();
for (const p of posts) {
if (!uniquePosts.has(p.id)) uniquePosts.set(p.id, p);
}
return Array.from(uniquePosts.values()).slice(0, 20);
} catch (error) {
console.error(`[tieba] Search error: (error as Error).message`);
return [];
}
}
async reply(
postId: string,
content: string,
credential: Credential,
): Promise<ReplyResult> {
try {
const tbs = await getTbs(credential);
if (!tbs) {
return { success: false, error: 'Failed to get tbs token', mode: 'api' };
}
const metadata = (this as unknown as { _lastReplyMeta?: { kw: string; fid: string } })._lastReplyMeta;
const kw = metadata?.kw ?? '';
const fid = metadata?.fid ?? '';
const response = await fetch(`TIEBA_BASE/f/commit/post/add`, {
method: 'POST',
headers: {
...buildHeaders(credential),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
ie: 'utf-8',
kw,
fid,
tid: postId,
content,
tbs,
}).toString(),
});
if (!response.ok) {
return { success: false, error: `HTTP response.status`, mode: 'api' };
}
const data = await response.json() as {
err_code?: number;
error?: string;
data?: { tid?: string };
};
if (data.err_code && data.err_code !== 0) {
return { success: false, error: data.error ?? `err_code data.err_code`, mode: 'api' };
}
return { success: true, mode: 'api' };
} catch (error) {
return { success: false, error: (error as Error).message, mode: 'api' };
}
}
async check(credential: Credential): Promise<CheckResult> {
try {
const response = await fetch(`TIEBA_BASE/dc/common/tbs`, {
headers: buildHeaders(credential),
});
if (!response.ok) return { valid: false, error: `HTTP response.status` };
const data = await response.json() as { tbs?: string; is_login?: number };
if (data.is_login !== 1) {
return { valid: false, error: 'Not logged in or BDUSS expired' };
}
return { valid: true, username: '(logged in via BDUSS)' };
} catch (error) {
return { valid: false, error: (error as Error).message };
}
}
rateLimitInfo(): RateLimitInfo {
return {
requestsPerMinute: 20,
repliesPerDay: 20,
minReplyIntervalSeconds: 120,
notes: '贴吧: BDUSS有效期6个月+, 回帖≤30条/天, 重复内容被删, 需从请求头获取BDUSS(非Cookie-Editor的BDUSS_BFESS)',
};
}
}
const isMainModule = process.argv[1]?.replace(/\\/g, '/').endsWith('tieba.ts');
if (isMainModule && process.argv[2] === 'test') {
const adapter = new TiebaAdapter();
console.log(JSON.stringify({
adapter: 'tieba',
status: 'ok',
platformId: adapter.platformId,
platformName: adapter.platformName,
rateLimit: adapter.rateLimitInfo(),
}));
}
FILE:seeddrop-v3.0.1/scripts/adapters/xiaohongshu.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: https://edith.xiaohongshu.com/api/sns/web/v1
// Local files read: none
// Local files written: none
import type {
PlatformAdapter,
Credential,
Post,
ReplyResult,
CheckResult,
RateLimitInfo,
BrowserInstruction,
} from '../types.js';
import { BROWSER_INSTRUCTION_ID } from '../types.js';
const XHS_API = 'https://edith.xiaohongshu.com/api/sns/web/v1';
function buildHeaders(credential: Credential): Record<string, string> {
return {
'Cookie': credential.value,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.xiaohongshu.com',
'Origin': 'https://www.xiaohongshu.com',
};
}
export class XiaohongshuAdapter implements PlatformAdapter {
readonly platformId = 'xiaohongshu';
readonly platformName = '小红书';
async search(
keyword: string,
_timeRange: string,
credential: Credential,
_target?: string,
): Promise<Post[]> {
try {
const params = new URLSearchParams({
keyword,
page: '1',
page_size: '20',
search_id: '',
sort: 'general',
note_type: '0',
});
const response = await fetch(`XHS_API/search/notes?params`, {
headers: buildHeaders(credential),
});
if (response.ok) {
const data = await response.json() as {
code?: number;
success?: boolean;
data?: {
items?: Array<{
id: string;
note_card?: {
display_title?: string;
desc?: string;
user?: { nickname?: string };
time?: number;
};
}>;
};
};
if (data.success && data.data?.items?.length) {
return data.data.items.map(item => ({
id: item.id,
url: `https://www.xiaohongshu.com/explore/item.id`,
title: item.note_card?.display_title ?? '',
body: item.note_card?.desc ?? '',
author: item.note_card?.user?.nickname ?? '',
createdAt: item.note_card?.time
? new Date(item.note_card.time * 1000).toISOString()
: new Date().toISOString(),
platform: this.platformId,
}));
}
}
} catch (error) {
console.error(`[xiaohongshu] API search failed, falling back to browser: (error as Error).message`);
}
console.error('[xiaohongshu] API search unavailable, returning browser instruction');
const instruction: BrowserInstruction = {
mode: 'browser',
action: 'search',
steps: [
{ action: 'navigate', url: `https://www.xiaohongshu.com/search_result?keyword=encodeURIComponent(keyword)&source=web_search_result_notes` },
{ action: 'wait', selector: '.note-item' },
{ action: 'extract', selector: '.note-item', fields: ['title', 'url', 'author', 'likes'] },
],
cookies: credential.value,
};
return [{
id: BROWSER_INSTRUCTION_ID,
url: '',
title: '',
body: JSON.stringify(instruction),
author: '',
createdAt: new Date().toISOString(),
platform: this.platformId,
}];
}
async reply(
postId: string,
content: string,
credential: Credential,
): Promise<ReplyResult> {
const instruction: BrowserInstruction = {
mode: 'browser',
action: 'reply',
steps: [
{ action: 'navigate', url: `https://www.xiaohongshu.com/explore/postId` },
{ action: 'wait', selector: '#content-textarea' },
{ action: 'click', selector: '#content-textarea' },
{ action: 'type', text: content },
{ action: 'click', selector: '.submit-btn' },
],
cookies: credential.value,
};
return {
success: true,
replyId: `__browser_pending__:JSON.stringify(instruction)`,
mode: 'browser',
};
}
async check(credential: Credential): Promise<CheckResult> {
if (!credential.value || credential.value.length === 0) {
return { valid: false, error: 'No cookie provided' };
}
return {
valid: true,
username: '(cookie mode — verify via browser, cookies expire ~12h)',
};
}
rateLimitInfo(): RateLimitInfo {
return {
requestsPerMinute: 20,
repliesPerDay: 10,
minReplyIntervalSeconds: 3,
notes: 'No public API. Browser-only. Cookies expire ~12h. Min 3s between requests. Strongly recommend SocialVault for auto cookie refresh.',
};
}
}
const isMainModule = process.argv[1]?.replace(/\\/g, '/').endsWith('xiaohongshu.ts');
if (isMainModule && process.argv[2] === 'test') {
const adapter = new XiaohongshuAdapter();
console.log(JSON.stringify({
adapter: 'xiaohongshu',
status: 'ok',
platformId: adapter.platformId,
platformName: adapter.platformName,
rateLimit: adapter.rateLimitInfo(),
}));
}
FILE:seeddrop-v3.0.1/scripts/adapters/zhihu.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: https://www.zhihu.com/api/v4
// Local files read: none
// Local files written: none
import type {
PlatformAdapter,
Credential,
Post,
ReplyResult,
CheckResult,
RateLimitInfo,
BrowserInstruction,
} from '../types.js';
import { BROWSER_INSTRUCTION_ID } from '../types.js';
const ZHIHU_API = 'https://www.zhihu.com/api/v4';
function buildHeaders(credential: Credential): Record<string, string> {
return {
'Cookie': credential.value,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.zhihu.com',
};
}
export class ZhihuAdapter implements PlatformAdapter {
readonly platformId = 'zhihu';
readonly platformName = '知乎';
async search(
keyword: string,
_timeRange: string,
credential: Credential,
_target?: string,
): Promise<Post[]> {
try {
const params = new URLSearchParams({
t: 'general',
q: keyword,
correction: '1',
offset: '0',
limit: '20',
});
const response = await fetch(`ZHIHU_API/search_v3?params`, {
headers: buildHeaders(credential),
});
if (!response.ok) {
console.error(`[zhihu] Search failed: response.status`);
return [];
}
const data = await response.json() as {
data?: Array<{
type?: string;
object?: {
id?: number;
question?: { id?: number; title?: string };
title?: string;
excerpt?: string;
content?: string;
author?: { name?: string };
created_time?: number;
updated_time?: number;
};
}>;
};
if (!data.data) return [];
const posts: Post[] = [];
for (const item of data.data) {
const obj = item.object;
if (!obj) continue;
if (item.type === 'search_result' && obj.question) {
posts.push({
id: String(obj.question.id ?? obj.id ?? ''),
url: `https://www.zhihu.com/question/obj.question.id`,
title: obj.question.title ?? '',
body: (obj.excerpt ?? obj.content ?? '').replace(/<[^>]*>/g, ''),
author: obj.author?.name ?? '',
createdAt: obj.created_time
? new Date(obj.created_time * 1000).toISOString()
: new Date().toISOString(),
platform: this.platformId,
});
}
}
const unique = new Map<string, Post>();
for (const p of posts) {
if (p.id && !unique.has(p.id)) unique.set(p.id, p);
}
return Array.from(unique.values());
} catch (error) {
console.error(`[zhihu] Search error: (error as Error).message`);
return [];
}
}
async reply(
postId: string,
content: string,
credential: Credential,
): Promise<ReplyResult> {
const instruction: BrowserInstruction = {
mode: 'browser',
action: 'reply',
steps: [
{ action: 'navigate', url: `https://www.zhihu.com/question/postId` },
{ action: 'wait', selector: '.AnswerForm' },
{ action: 'click', selector: '.AnswerForm .RichContent' },
{ action: 'type', text: content },
{ action: 'click', selector: 'button[type="submit"]' },
],
cookies: credential.value,
};
return {
success: true,
replyId: `__browser_pending__:JSON.stringify(instruction)`,
mode: 'browser',
};
}
async check(credential: Credential): Promise<CheckResult> {
try {
const response = await fetch(`ZHIHU_API/me`, {
headers: buildHeaders(credential),
});
if (!response.ok) return { valid: false, error: `HTTP response.status` };
const data = await response.json() as { id?: string; name?: string; error?: { message?: string } };
if (data.error) {
return { valid: false, error: data.error.message ?? 'Unknown error' };
}
return { valid: !!data.id, username: data.name };
} catch (error) {
return { valid: false, error: (error as Error).message };
}
}
rateLimitInfo(): RateLimitInfo {
return {
requestsPerMinute: 10,
repliesPerDay: 10,
minReplyIntervalSeconds: 300,
notes: '知乎: z_c0有效期~30天, 反爬严格, 高频触发验证码. 写入需x-zse-96签名(当前用browser模式)',
};
}
}
const isMainModule = process.argv[1]?.replace(/\\/g, '/').endsWith('zhihu.ts');
if (isMainModule && process.argv[2] === 'test') {
const adapter = new ZhihuAdapter();
console.log(JSON.stringify({
adapter: 'zhihu',
status: 'ok',
platformId: adapter.platformId,
platformName: adapter.platformName,
rateLimit: adapter.rateLimitInfo(),
}));
}
FILE:seeddrop-v3.0.1/scripts/adapters/_template.ts
// SECURITY MANIFEST:
// Environment variables accessed: (list here)
// External endpoints called: (list here)
// Local files read: (list here)
// Local files written: (list here)
import type {
PlatformAdapter,
Credential,
Post,
ReplyResult,
CheckResult,
RateLimitInfo,
} from '../types.js';
/**
* Template for creating a new platform adapter.
*
* Steps:
* 1. Copy this file to scripts/adapters/<platform>.ts
* 2. Rename the class and update platformId / platformName
* 3. Implement search(), reply(), check(), rateLimitInfo()
* 4. Register in scripts/adapters/base.ts
* 5. Create templates/reply-<platform>.md
* 6. Add rate limits to references/safety-rules.md
* 7. Add TOS notes to references/platform-tos-notes.md
* 8. Test with: npx tsx scripts/adapters/<platform>.ts test
*/
export class TemplateAdapter implements PlatformAdapter {
readonly platformId = 'template';
readonly platformName = 'Template Platform';
async search(
keyword: string,
timeRange: string,
credential: Credential,
target?: string,
): Promise<Post[]> {
// TODO: Implement platform-specific search
// API mode: use fetch() with credential
// Browser mode: return BrowserInstruction wrapped in Post[]
console.error(`[this.platformId] search() not implemented`);
return [];
}
async reply(
postId: string,
content: string,
credential: Credential,
): Promise<ReplyResult> {
// TODO: Implement reply logic
console.error(`[this.platformId] reply() not implemented`);
return { success: false, error: 'Not implemented' };
}
async check(credential: Credential): Promise<CheckResult> {
// TODO: Implement credential validation
console.error(`[this.platformId] check() not implemented`);
return { valid: false, error: 'Not implemented' };
}
rateLimitInfo(): RateLimitInfo {
return {
requestsPerMinute: 60,
repliesPerDay: 10,
minReplyIntervalSeconds: 300,
notes: 'TODO: Add platform-specific rate limit notes',
};
}
}
const isMainModule = process.argv[1]?.replace(/\\/g, '/').endsWith('_template.ts');
if (isMainModule && process.argv[2] === 'test') {
const adapter = new TemplateAdapter();
console.log(JSON.stringify({
adapter: 'template',
status: 'ok',
platformId: adapter.platformId,
platformName: adapter.platformName,
rateLimit: adapter.rateLimitInfo(),
}));
}
FILE:seeddrop-v3.0.1/references/platform-tos-notes.md
# 平台服务条款关键摘要
## B站
- 搜索接口: `api.bilibili.com/x/web-interface/wbi/search`
- 评论接口: `api.bilibili.com/x/v2/reply/add`(需 CSRF=bili_jct)
- 搜索频率建议 ≤30 次/小时
- 评论频率建议 ≤50 条/天
- 重复内容会被自动过滤
- 对服务器 IP 无特殊限制
## 百度贴吧
- BDUSS 有效期 6 个月+,非常稳定
- Cookie-Editor 导出的是 BDUSS_BFESS(不可用),必须从网络请求头获取 BDUSS
- 回帖需先获取 tbs token
- 回帖频率建议 ≤30 条/天
- 重复内容会被系统删除
- STOKEN 用于写操作验证
## 知乎
- 搜索接口: `zhihu.com/api/v4/search_v3`
- 回答需要 x-zse-96 签名(当前用 browser 模式绕过)
- z_c0 有效期约 30 天
- 对服务器 IP 有限制,高频访问触发验证码
- 回答/评论有内容审核
## 小红书
- 无公开写入 API,回复操作通过浏览器模拟
- 搜索接口: `edith.xiaohongshu.com/api/sns/web/v1/search/notes`(需 X-s/X-t 签名)
- Cookie 有效期极短: ~12 小时
- 多层反爬: Header + 行为指纹 + 设备ID + WASM
- 强烈建议配合 SocialVault 自动续期
- 评论区不允许外部链接
- 请求间隔不低于 3 秒
FILE:seeddrop-v3.0.1/references/safety-rules.md
# SeedDrop 安全规则
这些规则在脚本中硬编码执行,不可通过配置绕过。
## 频率限制
### 每平台每日回复上限
| 平台 | Approve 模式 | Auto 模式 | 平台限制参考 |
|------|-------------|-----------|-------------|
| B站 | 30 | 15 | 评论 ≤50/天, 搜索 ≤30/时 |
| 贴吧 | 20 | 10 | 回帖 ≤30/天, 重复内容删除 |
| 知乎 | 10 | 5 | 反爬严格, 高频触发验证码 |
| 小红书 | 10 | 5 | 无 API, ≤1 req/3s |
| 其他平台 | 10 | 5 | — |
Auto 模式的上限为 Approve 模式的 50%,作为额外安全措施。
### 回复间隔
- 最小间隔: 5 分钟
- 最大间隔: 15 分钟
- 实际间隔: 在 5-15 分钟范围内随机(不固定)
- 小红书额外要求: 任意两次请求间隔不低于 3 秒
### 同一作者限制
- 同一用户的帖子 24 小时内最多回复 1 次
- 按作者 ID + 平台维度去重
### 同一帖子限制
- 每个帖子只能回复 1 次
- 通过 `interaction-log.jsonl` 中的 `post_id` 去重
## 内容安全
- 每条回复必须提供实质性帮助
- 品牌提及不超过回复总内容的 20%
- 不使用营销话术、夸大词汇
- 不包含直接联系方式或裸链接
## 回复多样性
- 不使用固定模板(每次重新生成)
- 随机化开头方式、长度、是否提及品牌
## 禁止回复的场景
- 帖子标记为置顶、官方、管理员帖
- 社区规则明确禁止自我推广
- 帖子作者在黑名单中
- 帖子内容涉及敏感话题
## Auto 模式额外限制
1. 每日上限降至 Approve 模式的 50%
2. 评分阈值最低不能低于 0.7
3. 风险评分低于 0.5 的帖子不回复
4. 新添加的平台默认 7 天内只能用 Approve 模式
## 黑名单管理
存储在 `memory/blacklist.md`,支持用户/社区/关键词三种维度。
FILE:seeddrop-v3.0.1/references/scoring-criteria.md
# SeedDrop 评分标准
## 评分维度
| 维度 | 权重 | 说明 |
|------|------|------|
| 相关度 (Relevance) | 35% | 帖子内容与品牌关键词的匹配程度 |
| 意图强度 (Intent) | 30% | 帖子作者是否在寻求帮助或建议 |
| 时效性 (Freshness) | 20% | 帖子发布时间越新越好 |
| 风险评估 (Risk) | 15% | 回复该帖子的潜在风险 |
## 总分计算
```
final = relevance × 0.35 + intent × 0.30 + freshness × 0.20 + risk × 0.15
```
## 阈值
- 默认: 0.6
- Auto 模式最低: 0.7
- 推荐范围: 0.4 - 0.8
## 相关度评分
| 命中比例 | 分数 |
|---------|------|
| > 50% 关键词命中 | 1.0 |
| 20-50% | 0.7 |
| > 0% | 0.5 |
| 0% | 0.2 |
## 意图评分
| 类型 | 分数 | 特征词 |
|------|------|--------|
| 求助/提问 | 0.9 | 求推荐, 怎么, 请问, 有没有推荐, recommend, help |
| 讨论/意见 | 0.7 | 讨论, 大家觉得, 有没有人用过, 什么体验 |
| 吐槽/抱怨 | 0.5 | 坑, 吐槽, 垃圾, 难用, 避雷 |
| 纯分享 | 0.3 | 无特征词 |
## 时效评分
| 帖子年龄 | 分数 |
|---------|------|
| ≤ 2h | 1.0 |
| 2-6h | 0.9 |
| 6-12h | 0.8 |
| 12-24h | 0.6 |
| 24-48h | 0.4 |
| > 48h | 0.2 |
## 风险评分
| 类型 | 分数 |
|------|------|
| 安全帖子 | 0.9 |
| 官方/管理相关 | 0.3 |
| 争议性话题 | 0.2 |
FILE:seeddrop-v3.0.1/memory/blacklist.md
# SeedDrop 黑名单
## 用户黑名单
(不回复以下用户的帖子)
## 社区黑名单
(不在以下社区/subreddit 发布)
## 关键词黑名单
(帖子包含以下关键词时不回复)
- [Meta]
- [Announcement]
- [Mod Post]
- [Official]
FILE:seeddrop-v3.0.1/memory/brand-profile.md
# SeedDrop Brand Profile
## 基本信息
- 业务名称: SeedDrop
- 业务类型: 社区互动自动化工具
- 服务区域: 中文互联网(B站、贴吧、知乎、小红书)
- 官网: (待配置)
- 核心卖点: 自动监控社区讨论,AI生成高质量回复,自然种草,帮助独立开发者和小团队低成本做内容运营
## 目标客户关键词
- 主关键词: 社区营销, 内容运营工具, 自动回复, 种草工具
- 场景关键词: 独立开发运营, SaaS推广, 小团队营销, 社交媒体运营效率
- 竞品关键词: 社媒管理工具, 内容营销平台
## 品牌人设
- 语气: 真诚分享、像开发者朋友一样聊天,不硬推销
- 禁用语: 最好的, 第一, 保证, 神器, 爆款, 裂变, 私域
- 常用语: 我们也在用这个方案, 刚好做了个小工具, 分享一下踩过的坑
- 可提及案例: 用 SeedDrop 自动监控「独立开发」「SaaS工具」等话题,每天节省2小时人工筛帖时间
## 关键词
- 社区营销
- 内容运营工具
- 自动回复
- 种草助手
- 独立开发
- 内容营销
- 运营效率
- SaaS工具推广
- 社交媒体运营
- 社区互动
## 平台
- bilibili
- tieba
- zhihu
- xiaohongshu
## 监控平台配置
platforms:
- id: bilibili
enabled: true
keywords: []
- id: tieba
enabled: true
bars: []
keywords: []
- id: zhihu
enabled: true
keywords: []
- id: xiaohongshu
enabled: true
keywords: []
## 运行模式
- mode: approve
- scoring_threshold: 0.6
- daily_max_replies:
bilibili: 30
tieba: 20
zhihu: 10
xiaohongshu: 10
## 语言偏好
- primary: zh-CN
- secondary: en
FILE:seeddrop-v3.0.1/guides/adapter-development.md
# 平台适配器开发指南
## 开发步骤
### 1. 复制模板
```bash
cp scripts/adapters/_template.ts scripts/adapters/<platform-id>.ts
```
### 2. 实现 PlatformAdapter 接口
```typescript
import type { PlatformAdapter, Credential, Post, ReplyResult, CheckResult, RateLimitInfo } from '../types.js';
export class MyAdapter implements PlatformAdapter {
readonly platformId = 'my-platform';
readonly platformName = 'My Platform';
async search(keyword: string, timeRange: string, credential: Credential, target?: string): Promise<Post[]> { ... }
async reply(postId: string, content: string, credential: Credential): Promise<ReplyResult> { ... }
async check(credential: Credential): Promise<CheckResult> { ... }
rateLimitInfo(): RateLimitInfo { ... }
}
```
### 3. 输出格式
**search 输出**(JSONL,每行一个):
```json
{"id":"...","url":"...","title":"...","body":"...","author":"...","createdAt":"...","platform":"<id>"}
```
**reply 输出**:
```json
{"success": true, "replyId": "..."}
```
**check 输出**:
```json
{"valid": true, "username": "..."}
```
### 4. 两种实现方式
**有 API 的平台**:使用 `fetch()` 直接调用 API,参考 `reddit.ts`。
**无 API 的平台**:返回 browser 指令,参考 `xiaohongshu.ts`:
```typescript
import { BROWSER_INSTRUCTION_ID, type BrowserInstruction } from '../types.js';
const instruction: BrowserInstruction = {
mode: 'browser',
action: 'search',
steps: [
{ action: 'navigate', url: `https://example.com/search?q=keyword` },
{ action: 'wait', selector: '.result-item' },
{ action: 'extract', selector: '.result-item', fields: ['title', 'url'] },
],
cookies: credential.value,
};
return [{ id: BROWSER_INSTRUCTION_ID, url: '', title: '', body: JSON.stringify(instruction), author: '', createdAt: new Date().toISOString(), platform: this.platformId }];
```
### 5. 注册适配器
在 `scripts/adapters/base.ts` 中添加:
```typescript
'my-platform': async () => {
const { MyAdapter } = await import('./my-platform.js');
return new MyAdapter();
},
```
### 6. 配套文件
| 文件 | 用途 |
|------|------|
| `templates/reply-<id>.md` | 回复风格指南 |
| `references/safety-rules.md` | 添加频率限制 |
| `references/platform-tos-notes.md` | 添加 ToS 摘要 |
### 7. 测试
```bash
npx tsx scripts/adapters/<platform-id>.ts test
```
### 8. 启用
在 `memory/brand-profile.md` 的 platforms 中添加新平台。
FILE:seeddrop-v3.0.1/guides/brand-profile-setup.md
# Brand Profile 配置引导
## 配置流程
执行 `seeddrop setup` 时按以下步骤引导:
### 第 1 步:基本信息
- 业务名称、类型、服务区域
- 官网链接(不主动放入回复)
- 核心卖点(1-3 句)
### 第 2 步:关键词
- 主关键词(3-10 个)
- 场景关键词(3-10 个)
- 竞品关键词(可选)
- 中英文都要覆盖
### 第 3 步:品牌人设
- 语气风格:专业亲切 / 轻松幽默 / 正式严谨 / 热情直接
- 禁用语:如"最好的""保证""第一"
- 常用语和可引用案例
### 第 4 步:平台配置
- 选择启用的平台
- Reddit: 目标 subreddit 列表
- X/Twitter: 话题标签
- 小红书: 搜索关键词
### 第 5 步:运行模式
- 模式: approve(默认)/ auto
- 评分阈值: 默认 0.6
- 每日上限: 各平台独立配置
### 第 6 步:语言偏好
- 主语言 / 辅助语言
## 生成文件
配置完成写入 `memory/brand-profile.md`,格式参照 `docs/brand-profile.md` 示例。
FILE:seeddrop-v3.0.1/guides/quickstart.md
# SeedDrop 快速上手
## 1. 安装
```bash
clawhub install seeddrop
```
## 2. 配置品牌资料
```bash
seeddrop setup
```
按提示输入:
- 产品/品牌名称
- 一句话描述
- 监控关键词(多个用逗号分隔)
- 使用的平台(bilibili, tieba, zhihu, xiaohongshu)
- 回复模式(approve = 人工审核, auto = 自动发送)
## 3. 添加平台凭证
```bash
seeddrop auth add bilibili
seeddrop auth add tieba
```
需要提供对应平台的 Cookie。推荐配合 SocialVault 管理凭证:
```bash
clawhub install socialvault
socialvault add bilibili
```
## 4. 运行监控
```bash
seeddrop monitor bilibili
seeddrop monitor tieba 编程
seeddrop monitor all
```
## 5. 查看报告
```bash
seeddrop report
seeddrop report weekly
```
## 平台 Cookie 获取
| 平台 | 必需字段 | 获取方式 |
|------|---------|---------|
| B站 | SESSDATA, bili_jct | 浏览器 DevTools → Application → Cookies |
| 贴吧 | BDUSS, STOKEN | 浏览器 DevTools → Network → 请求头 Cookie |
| 知乎 | z_c0, d_c0 | 浏览器 DevTools → Application → Cookies |
| 小红书 | a1, web_session | 浏览器 DevTools → Application → Cookies |
> **注意**:贴吧的 BDUSS 必须从请求头获取,Cookie-Editor 导出的 BDUSS_BFESS 不可用。
## 推荐搭配
安装 **SocialVault** 后可自动管理 Cookie 续期,特别是小红书(Cookie 仅 12 小时有效)。
FILE:seeddrop-v3.0.1/.qoder/rules/code-quality.md
---
trigger: always_on
---
# 代码质量与测试标准
## 命名规范
- 变量名、函数名必须自解释,禁止 temp、data、info、handle 等模糊命名
- 布尔变量用 is/has/should/can 前缀
- 函数用动词开头:calculateTotalPrice 而非 totalPrice
- 常量用 UPPER_SNAKE_CASE
- 遵循项目已有命名约定(先 grep 确认)
## 函数设计
- 单一职责:一个函数只做一件事
- 函数体不超过 40 行,超过则拆分
- 参数不超过 4 个,超过则用 Options Object
- 禁止布尔参数控制分支(用策略模式或独立函数)
- 强类型语言中每个函数必须有明确返回值类型
## 错误处理
- 禁止空 catch 块,每个 catch 必须有日志或重抛
- 外部 I/O 必须有错误处理
- 使用有意义的自定义错误类型
- 异步代码中所有 Promise 必须有错误处理链
- 用户可见错误信息必须友好且不泄漏内部细节
## 代码结构
- 导入分组:外部库 → 内部模块 → 类型 → 样式
- 相关逻辑物理邻近,不相关逻辑物理分离
- 禁止超过 3 层嵌套(用 early return 或函数抽取)
- 魔法数字和魔法字符串必须提取为命名常量
## 注释哲学
- 好代码本身就是注释,优先靠命名和结构自解释
- 只在需要解释"为什么"时写注释,不解释"是什么"
- 公共 API 必须有 JSDoc / docstring
- TODO 必须附 issue 链接或负责人
## 测试标准
### 验证闭环
每次代码变更后必须执行:
1. get_problems → 修到 0 error
2. 运行受影响模块测试 → 全部通过
3. 涉及模块交互时运行集成测试
4. 逐条核对验收标准
任何一环不通过则自主修复,禁止标记完成
### 测试文件生成
- 一次只生成一个测试文件
- 生成后立即 get_problems 检查编译
- 修复编译问题后执行测试
- 当前文件通过后才进入下一个
### 测试用例设计
- 每个测试用例独立,不依赖执行顺序
- 命名格式:should_[预期行为]_when_[条件]
- 必须覆盖:Happy path / Edge cases / Error scenarios / 回归防护
- 每个测试只断言一个行为
- 测试行为和结果,不测实现细节
- Mock 只用于隔离外部依赖
FILE:seeddrop-v3.0.1/.qoder/rules/core-principles.md
---
trigger: always_on
---
# Vue 2 项目专项规则
本规则适用于 Vue 2.x(含 2.7)项目,使用 Options API 风格。
## 版本识别
在执行任何修改前,先确认:
- package.json 中 vue 版本为 ^2.x
- 构建工具为 Vue CLI (@vue/cli) 或 Webpack
- 状态管理为 Vuex(3.x)
- UI 库为 Element UI 或其他 Vue 2 兼容库
如果发现实际是 Vue 3 项目,立即停止并切换到 Vue 3 规则
## 组件规范
### 文件组织
- 单文件组件(SFC)按 template → script → style 顺序排列
- 组件文件名使用 PascalCase:UserProfile.vue
- 每个文件只包含一个组件
### Options API 书写顺序
严格按照以下顺序排列 options:
1. name
2. components
3. directives / filters
4. mixins
5. props
6. data
7. computed
8. watch
9. 生命周期钩子(按执行顺序):beforeCreate → created → beforeMount → mounted → beforeUpdate → updated → beforeDestroy → destroyed
10. methods
### Props 规范
- props 必须有类型定义,禁止纯数组写法
- 正确:props: { title: { type: String, required: true, default: '' } }
- 错误:props: ['title']
- 必要时提供 validator 函数
- 复杂对象的 default 必须用工厂函数返回
### Data 规范
- data 必须是一个返回对象的函数,禁止直接写对象
- data 中不放与模板无关的变量(不需要响应式的数据放在 created 里用 this.xxx = ... 赋值)
### Computed 与 Watch
- 能用 computed 解决的不用 watch
- watch 中避免复杂逻辑,复杂操作抽到 methods
- 需要 deep watch 时显式声明 deep: true 并注释原因
### 事件规范
- 子组件向父组件通信用 $emit,事件名用 kebab-case
- 禁止滥用 $parent / $children 直接访问
- 禁止滥用 EventBus 做跨层级通信(大范围通信走 Vuex)
## Vuex 规范
- State:只存全局共享数据,组件私有状态留在组件内
- Mutations:只做同步状态变更,命名用 SET_XXX / UPDATE_XXX
- Actions:处理异步逻辑,命名用动词:fetchUserList / submitOrder
- Getters:用于派生状态,等同于 Store 级别的 computed
- 模块化:使用 namespaced: true,按业务领域拆分 module
## 路由规范(Vue Router 3.x)
- 路由配置使用懒加载:component: () => import('@/views/xxx.vue')
- 路由 name 使用 PascalCase,与组件文件名一致
- 路由守卫中的异步操作必须有错误处理
- 禁止在组件内直接操作 window.location,使用 $router
## 样式规范
- 使用 scoped 样式避免全局污染
- 需要穿透子组件时用 ::v-deep 或 /deep/(Vue 2 语法)
- 禁止在 scoped style 中使用标签选择器
- BEM 或项目已有的命名约定(先确认再使用)
## 兼容性注意
- Vue 2 不支持 Fragments(模板只能有一个根元素)
- Vue 2 不支持 Teleport / Suspense
- 数组变更检测限制:使用 Vue.set 或 this.$set 修改数组索引
- 对象新增属性检测限制:使用 Vue.set 或 this.$set 添加新属性
- 如果项目是 Vue 2.7,可使用 Composition API(setup 语法糖除外),但必须先确认项目是否引入了 @vue/composition-api 或已升级到 2.7
## 禁止事项
- 禁止使用已废弃的 API:$on / $off / $once(Vue 2.x 原生支持但 Vue 3 已移除,迁移风险高)
- 禁止在模板中使用复杂的内联表达式(超过一个函数调用的逻辑必须移到 computed 或 methods)
- 禁止修改 props(如需修改,用 data 接收或 computed 转换)
- 禁止在 data 中引用 props 的默认值后不追踪变化(常见 bug)
FILE:seeddrop-v3.0.1/.qoder/rules/documentation-gen.md
---
trigger: always_on
---
# 文档生成规范
通过 @documentation-gen 手动触发。
## README 模板
- 简介:一句话描述项目用途
- 快速开始:最少步骤跑起来
- 架构概览:核心模块及关系
- 开发指南:环境搭建、常用命令
- API 文档(如适用)
- 贡献指南(如适用)
## 变更日志
- 使用 Keep a Changelog 格式
- 按 Added / Changed / Fixed / Removed 分类
- 每条关联 issue 或 PR 编号
## API 文档
- 包含请求方法、路径、参数、响应示例、错误码
- 提供 curl 示例
FILE:seeddrop-v3.0.1/.qoder/rules/guardrails.md
---
trigger: always_on
---
# 防翻车护栏机制
## 认知护栏
### 不确定性声明
对某个技术细节置信度低于 80% 时,必须标注:
"⚠️ 我对此不够确定:[内容]。建议验证方式:[方法]"
禁止用自信语气描述不确定的方案。
### 假设追踪
每个假设必须显式记录:
"📌 假设:[内容] | 依据:[来源] | 风险:[假设错误的后果]"
验证阶段逐条回顾假设是否成立。
### 上下文不足时
- 禁止凭"常见做法"猜测项目特定配置
- 必须用工具验证(grep_code 搜模式、read_file 查配置)
- 工具也无法确认时向用户明确询问
## 执行护栏
### 变更范围锁定
- 每次改动必须与当前任务直接相关
- "顺便改进"的想法记录到 Memory,当前不动
- 任务范围变化时必须更新 Spec 并获得确认
### 渐进式修改
- 大型修改分步进行,每步之间运行验证
- 禁止一次性重写整个文件,用 search_replace 精确修改
- 每步改完立即 get_problems,确认无回归再继续
### 并发安全
- 文件编辑:必须串行
- 终端命令:必须串行
- 只读操作(read_file、search_codebase、grep_code):可以并行
### 回滚意识
- 高风险操作前确认有回滚路径
- 可能破坏现有功能的修改先确认测试覆盖
- 数据库 migration、配置变更等不可逆操作必须获得用户明确确认
## 思维链护栏
### 防止回归倾向
- 面对复杂任务禁止退缩为"建议用户自行处理"
- 遇到阻碍先尝试至少 2 种不同解决路径
- 穷尽工具能力后才向用户求助
### 防止死循环
同一修复尝试 3 次仍失败时必须:
1. 停下来重新分析根因
2. 告知用户当前状态和已尝试方案
3. 提出不同方向的解决思路
### 防止范围蔓延
每完成一步回顾:当前在做的事是否仍在 Spec 范围内?
发现偏离则立即停止并重新评估。
## 输出护栏
### 代码输出
- 默认用 search_replace 修改文件,不直接输出代码块
- 用户明确要求时才在对话中展示代码
- 展示代码用 // ... existing code ... 省略未改部分
### 任务完成确认
任务完成时必须提供结构化报告:
- 变更文件清单
- 验收标准逐条达成情况
- 已运行测试及结果
- 潜在风险提示(如有)
未实际完成的任务绝不标记为 complete。
FILE:seeddrop-v3.0.1/.qoder/rules/review-and-refactor.md
---
trigger: always_on
---
# 代码审查与重构
## 审查清单
### P0 必须修复
- 逻辑错误:分支遗漏、条件判断错误、循环边界
- 安全漏洞:注入风险、敏感信息泄漏、未授权访问
- 数据一致性:竞态条件、事务缺失、状态不一致
- 资源泄漏:未关闭的连接/文件句柄、内存泄漏
### P1 强烈建议修复
- 错误处理缺失或不当
- 类型安全问题
- 性能问题(N+1 查询、不必要的重复计算)
- 测试覆盖不足
### P2 改进建议
- 命名可读性
- 代码结构优化
- 注释补充
- 代码重复(DRY)
## 重构原则
- 重构前必须确认测试覆盖
- 一次只做一种类型的重构
- 重构不改变外部行为,每步用测试确认
- 大型重构必须先产出 Spec 获得确认
FILE:seeddrop-v3.0.1/.qoder/rules/security-checklist.md
---
trigger: always_on
---
# 安全检查清单
## 输入处理
- 所有用户输入必须验证和消毒(sanitize)
- SQL 查询使用参数化查询,禁止字符串拼接
- 文件路径操作防范路径穿越(path traversal)
- 正则表达式防范 ReDoS
## 认证授权
- 密码和密钥禁止硬编码
- 敏感配置使用环境变量
- API 端点必须有权限校验
- Session/Token 有合理过期策略
## 数据安全
- 敏感数据不出现在日志中
- API 响应不泄漏内部实现细节
- 错误信息对用户友好且不暴露技术栈
- PII 数据加密存储
## 依赖安全
- 不引入有已知漏洞的依赖版本
- 优先使用社区活跃维护的库
FILE:scripts/analytics.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: none
// Local files read: memory/interaction-log.jsonl, memory/performance-stats.json
// Local files written: memory/performance-stats.json, stdout (report)
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { InteractionLogEntry, PerformanceStats } from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const BASE_DIR = join(__dirname, '..');
const LOG_PATH = join(BASE_DIR, 'memory', 'interaction-log.jsonl');
const STATS_PATH = join(BASE_DIR, 'memory', 'performance-stats.json');
// ─── Data Loading ───────────────────────────────────────────
function loadLog(): InteractionLogEntry[] {
if (!existsSync(LOG_PATH)) return [];
try {
return readFileSync(LOG_PATH, 'utf-8')
.split('\n')
.filter((l: string) => l.trim())
.map((l: string) => JSON.parse(l) as InteractionLogEntry);
} catch (err) {
console.error(`[analytics] Failed to load log: (err as Error).message`);
return [];
}
}
function loadStats(): PerformanceStats {
if (!existsSync(STATS_PATH)) {
return { total_replies: 0, by_platform: {}, by_date: {} };
}
try {
return JSON.parse(readFileSync(STATS_PATH, 'utf-8')) as PerformanceStats;
} catch {
return { total_replies: 0, by_platform: {}, by_date: {} };
}
}
function saveStats(stats: PerformanceStats): void {
const dir = dirname(STATS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(STATS_PATH, JSON.stringify(stats, null, 2), 'utf-8');
}
// ─── Report Generation ─────────────────────────────────────
function filterByDateRange(entries: InteractionLogEntry[], daysBack: number): InteractionLogEntry[] {
const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1000;
return entries.filter(e => new Date(e.timestamp).getTime() >= cutoff);
}
function generateReport(entries: InteractionLogEntry[], label: string): object {
const total = entries.length;
const successful = entries.filter(e => e.success).length;
const failed = total - successful;
const byPlatform: Record<string, { total: number; success: number }> = {};
const byDate: Record<string, number> = {};
const byMode: Record<string, number> = { approve: 0, auto: 0 };
const avgScore = total > 0
? entries.reduce((sum, e) => sum + e.score, 0) / total
: 0;
for (const entry of entries) {
const plat = entry.platform;
if (!byPlatform[plat]) byPlatform[plat] = { total: 0, success: 0 };
byPlatform[plat].total++;
if (entry.success) byPlatform[plat].success++;
const dateKey = entry.timestamp.substring(0, 10);
byDate[dateKey] = (byDate[dateKey] ?? 0) + 1;
byMode[entry.mode] = (byMode[entry.mode] ?? 0) + 1;
}
return {
report: label,
period: {
from: entries.length > 0 ? entries[0].timestamp : null,
to: entries.length > 0 ? entries[entries.length - 1].timestamp : null,
},
summary: {
total,
successful,
failed,
successRate: total > 0 ? Math.round((successful / total) * 100) : 0,
averageScore: Math.round(avgScore * 1000) / 1000,
},
byPlatform,
byDate,
byMode,
};
}
function generateTuningAdvice(entries: InteractionLogEntry[]): object {
const suggestions: string[] = [];
if (entries.length === 0) {
return { suggestions: ['No data yet. Run some monitoring cycles first.'] };
}
const avgScore = entries.reduce((s, e) => s + e.score, 0) / entries.length;
if (avgScore < 0.6) {
suggestions.push('Average score is low. Consider refining keywords in brand-profile.md to target more relevant discussions.');
}
if (avgScore > 0.85) {
suggestions.push('Average score is very high. You might lower the threshold slightly to capture more opportunities.');
}
const successRate = entries.filter(e => e.success).length / entries.length;
if (successRate < 0.7) {
suggestions.push('Success rate is below 70%. Check credential validity and platform rate limits.');
}
const platformCounts: Record<string, number> = {};
for (const e of entries) {
platformCounts[e.platform] = (platformCounts[e.platform] ?? 0) + 1;
}
const platforms = Object.keys(platformCounts);
if (platforms.length === 1) {
suggestions.push(`All activity is on platforms[0]. Consider expanding to other platforms for broader reach.`);
}
const hourBuckets = new Array<number>(24).fill(0);
for (const e of entries) {
const hour = new Date(e.timestamp).getHours();
hourBuckets[hour]++;
}
const peakHour = hourBuckets.indexOf(Math.max(...hourBuckets));
suggestions.push(`Peak activity hour: peakHour:00. Consider scheduling monitoring around this time.`);
if (suggestions.length === 0) {
suggestions.push('Everything looks good! Keep the current configuration.');
}
return {
tuning: 'advice',
averageScore: Math.round(avgScore * 1000) / 1000,
successRate: Math.round(successRate * 100),
platformDistribution: platformCounts,
peakHour,
suggestions,
};
}
// ─── Stats Update ───────────────────────────────────────────
function updateStats(entries: InteractionLogEntry[]): void {
const stats = loadStats();
stats.total_replies = entries.filter(e => e.success).length;
stats.by_platform = {};
stats.by_date = {};
for (const entry of entries) {
if (!entry.success) continue;
stats.by_platform[entry.platform] = (stats.by_platform[entry.platform] ?? 0) + 1;
const dateKey = entry.timestamp.substring(0, 10);
stats.by_date[dateKey] = (stats.by_date[dateKey] ?? 0) + 1;
}
saveStats(stats);
console.error('[analytics] performance-stats.json updated');
}
// ─── CLI Entry Point ────────────────────────────────────────
function main(): void {
const args = process.argv.slice(2);
const command = args[0] ?? 'daily';
if (command === 'test') {
const log = loadLog();
const stats = loadStats();
console.log(JSON.stringify({
script: 'analytics',
status: 'ok',
logEntries: log.length,
currentStats: stats,
}));
return;
}
const log = loadLog();
switch (command) {
case 'daily': {
const today = filterByDateRange(log, 1);
const report = generateReport(today, 'daily');
updateStats(log);
console.log(JSON.stringify(report, null, 2));
break;
}
case 'weekly': {
const week = filterByDateRange(log, 7);
const report = generateReport(week, 'weekly');
updateStats(log);
console.log(JSON.stringify(report, null, 2));
break;
}
case 'tune': {
const recent = filterByDateRange(log, 14);
const advice = generateTuningAdvice(recent);
console.log(JSON.stringify(advice, null, 2));
break;
}
default:
console.error('Usage: analytics.ts <daily|weekly|tune|test>');
process.exit(1);
}
}
main();
FILE:scripts/auth-bridge.ts
// SECURITY MANIFEST:
// Environment variables accessed: HOME, USERPROFILE
// External endpoints called: none (SocialVault calls delegated to Agent)
// Local files read: SocialVault SKILL.md (existence check only)
// Local files written: none
// Security: SocialVault is REQUIRED - no plaintext credential fallback
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Credential, AuthMode, CheckResult } from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const BASE_DIR = join(__dirname, '..');
// ─── SocialVault Detection ──────────────────────────────────
const SOCIALVAULT_SEARCH_PATHS = [
join(homedir(), '.openclaw', 'skills', 'socialvault', 'SKILL.md'),
join(homedir(), '.openclaw', 'skills', 'social-vault', 'SKILL.md'),
join(homedir(), '.openclaw', 'workspace', 'skills', 'socialvault', 'SKILL.md'),
join(homedir(), '.openclaw', 'workspace', 'skills', 'social-vault', 'SKILL.md'),
join(BASE_DIR, '..', 'socialvault', 'SKILL.md'),
join(BASE_DIR, '..', 'social-vault', 'SKILL.md'),
join(BASE_DIR, '..', 'SocialVault', 'socialvault', 'SKILL.md'),
];
function detectSocialVault(): string | null {
for (const p of SOCIALVAULT_SEARCH_PATHS) {
if (existsSync(p)) {
console.error(`[auth-bridge] SocialVault detected at: dirname(p)`);
return p;
}
}
return null;
}
export function getAuthMode(): AuthMode {
return detectSocialVault() ? 'socialvault' : 'none';
}
// ─── SocialVault Mode ───────────────────────────────────────
function getSocialVaultInstruction(command: string, platform: string, profile: string): string {
switch (command) {
case 'use':
return `socialvault use platform-profile`;
case 'token':
return `socialvault token platform-profile`;
case 'release':
return `socialvault release platform-profile`;
case 'check':
return `socialvault check platform-profile`;
default:
return `socialvault status`;
}
}
// ─── Public API ─────────────────────────────────────────────
export function getCredential(platform: string, profile: string = 'default'): Credential | null {
const mode = getAuthMode();
if (mode === 'socialvault') {
const instruction = getSocialVaultInstruction('token', platform, profile);
console.error(`[auth-bridge] SocialVault mode — Agent should run: instruction`);
return {
authType: 'oauth',
value: `__socialvault_pending__:instruction`,
profile,
source: 'socialvault',
};
}
console.error(`[auth-bridge] SocialVault is required but not detected. Please install: clawhub install socialvault`);
return null;
}
export function checkCredential(platform: string, profile: string = 'default'): CheckResult {
const cred = getCredential(platform, profile);
if (!cred) {
return { valid: false, error: `No credential found for platform/profile` };
}
if (cred.source === 'socialvault') {
return { valid: true, username: `(via SocialVault, run: socialvault check platform-profile)` };
}
return {
valid: cred.value.length > 0,
error: cred.value.length === 0 ? 'Credential value is empty' : undefined,
};
}
// ─── CLI Entry Point ────────────────────────────────────────
const IS_MAIN = process.argv[1]?.replace(/\\/g, '/').endsWith('auth-bridge.ts');
function main(): void {
if (!IS_MAIN) return;
const args = process.argv.slice(2);
const command = args[0];
switch (command) {
case 'mode':
console.log(JSON.stringify({ mode: getAuthMode() }));
break;
case 'get': {
const platform = args[1];
const profile = args[2] ?? 'default';
if (!platform) {
console.error('Usage: auth-bridge.ts get <platform> [profile]');
process.exit(1);
}
const cred = getCredential(platform, profile);
if (!cred) {
console.error(`[auth-bridge] Failed to get credential for platform/profile`);
process.exit(1);
}
console.log(JSON.stringify(cred));
break;
}
case 'check': {
const platform = args[1];
const profile = args[2] ?? 'default';
if (!platform) {
console.error('Usage: auth-bridge.ts check <platform> [profile]');
process.exit(1);
}
const result = checkCredential(platform, profile);
console.log(JSON.stringify(result));
break;
}
case 'test':
console.log(JSON.stringify({
script: 'auth-bridge',
status: 'ok',
mode: getAuthMode(),
socialVaultDetected: detectSocialVault() !== null,
}));
break;
default:
console.error('Usage: auth-bridge.ts <mode|get|check|test> [args]');
console.error(' mode — Show auth mode (local/socialvault)');
console.error(' get <platform> [profile] — Get credential');
console.error(' check <platform> [profile] — Check credential validity');
console.error(' test — Self-test');
process.exit(1);
}
}
main();
FILE:scripts/monitor.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: none (delegated to adapters)
// Local files read: memory/brand-profile.md, memory/interaction-log.jsonl, memory/blacklist.md
// Local files written: stdout (JSONL)
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getAdapter, listPlatforms } from './adapters/base.js';
import { getCredential } from './auth-bridge.js';
import type { Post, InteractionLogEntry } from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const BASE_DIR = join(__dirname, '..');
// ─── Brand Profile ──────────────────────────────────────────
interface MonitorConfig {
keywords: string[];
platforms: string[];
timeRange: string;
}
function loadConfig(): MonitorConfig {
const profilePath = join(BASE_DIR, 'memory', 'brand-profile.md');
const defaults: MonitorConfig = {
keywords: [],
platforms: [],
timeRange: 'day',
};
if (!existsSync(profilePath)) {
console.error('[monitor] brand-profile.md not found');
return defaults;
}
try {
const raw = readFileSync(profilePath, 'utf-8');
const kwMatch = raw.match(/##\s*关键词.*?\n([\s\S]*?)(?=\n##|\n$|$)/i)
?? raw.match(/##\s*Keywords.*?\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (kwMatch) {
defaults.keywords = kwMatch[1]
.split('\n')
.map((l: string) => l.replace(/^[-*]\s*/, '').trim())
.filter((l: string) => l.length > 0);
}
const platMatch = raw.match(/##\s*平台.*?\n([\s\S]*?)(?=\n##|\n$|$)/i)
?? raw.match(/##\s*Platforms.*?\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (platMatch) {
defaults.platforms = platMatch[1]
.split('\n')
.map((l: string) => l.replace(/^[-*]\s*/, '').trim())
.filter((l: string) => l.length > 0);
}
return defaults;
} catch (err) {
console.error(`[monitor] Failed to load config: (err as Error).message`);
return defaults;
}
}
// ─── Deduplication ──────────────────────────────────────────
function loadRepliedPostIds(): Set<string> {
const logPath = join(BASE_DIR, 'memory', 'interaction-log.jsonl');
if (!existsSync(logPath)) return new Set();
try {
const raw = readFileSync(logPath, 'utf-8');
const ids = new Set<string>();
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as InteractionLogEntry;
ids.add(entry.postId);
} catch { /* skip malformed lines */ }
}
return ids;
} catch {
return new Set();
}
}
// ─── Blacklist ──────────────────────────────────────────────
interface Blacklist {
users: string[];
communities: string[];
keywords: string[];
}
function loadBlacklist(): Blacklist {
const path = join(BASE_DIR, 'memory', 'blacklist.md');
const defaults: Blacklist = { users: [], communities: [], keywords: [] };
if (!existsSync(path)) return defaults;
try {
const raw = readFileSync(path, 'utf-8');
let currentSection = '';
for (const line of raw.split('\n')) {
const sectionMatch = line.match(/^##\s*(.+)/);
if (sectionMatch) {
currentSection = sectionMatch[1].toLowerCase();
continue;
}
const item = line.replace(/^[-*]\s*/, '').trim();
if (!item || item.startsWith('#')) continue;
if (currentSection.includes('用户') || currentSection.includes('user')) {
defaults.users.push(item.toLowerCase());
} else if (currentSection.includes('社区') || currentSection.includes('communit') || currentSection.includes('subreddit')) {
defaults.communities.push(item.toLowerCase());
} else if (currentSection.includes('关键词') || currentSection.includes('keyword')) {
defaults.keywords.push(item.toLowerCase());
}
}
return defaults;
} catch {
return defaults;
}
}
function isBlacklisted(post: Post, blacklist: Blacklist): boolean {
if (blacklist.users.includes(post.author.toLowerCase())) return true;
if (post.community && blacklist.communities.includes(post.community.toLowerCase())) return true;
const text = `post.title post.body`.toLowerCase();
for (const kw of blacklist.keywords) {
if (text.includes(kw)) return true;
}
return false;
}
// ─── Main Monitor Logic ─────────────────────────────────────
async function monitorPlatform(
platformId: string,
keywords: string[],
timeRange: string,
target?: string,
): Promise<Post[]> {
const adapter = await getAdapter(platformId);
const cred = getCredential(platformId);
if (!cred) {
console.error(`[monitor] No credentials for platformId, skipping`);
return [];
}
if (cred.value.startsWith('__socialvault_pending__:')) {
console.error(`[monitor] SocialVault credential pending for platformId`);
console.error(`[monitor] Agent should run: ', '')`);
console.error(`[monitor] Then re-run monitor with valid credentials`);
return [];
}
const allPosts: Post[] = [];
for (const keyword of keywords) {
console.error(`[monitor] Searching platformId for "keyword" (range: timeRange)target ? ` in ${target` : ''}`);
const posts = await adapter.search(keyword, timeRange, cred, target);
if (posts.length === 0 && adapter.browserSearch) {
const instruction = adapter.browserSearch(keyword, target);
console.error(`[monitor] API search returned 0 results — browser fallback available.`);
console.error(`[monitor] BROWSER_FALLBACK: JSON.stringify(instruction)`);
}
allPosts.push(...posts);
}
const uniquePosts = new Map<string, Post>();
for (const post of allPosts) {
if (!uniquePosts.has(post.id)) {
uniquePosts.set(post.id, post);
}
}
return Array.from(uniquePosts.values());
}
// ─── CLI Entry Point ────────────────────────────────────────
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args[0] === 'test') {
const config = loadConfig();
const blacklist = loadBlacklist();
const repliedIds = loadRepliedPostIds();
console.log(JSON.stringify({
script: 'monitor',
status: 'ok',
config,
blacklistCounts: {
users: blacklist.users.length,
communities: blacklist.communities.length,
keywords: blacklist.keywords.length,
},
repliedPostCount: repliedIds.size,
availablePlatforms: listPlatforms(),
}));
return;
}
const platformArg = args[0];
const targetArg = args[1]; // e.g. specific community (吧名, 分区等)
if (!platformArg) {
console.error('Usage: monitor.ts <platform|all> [target]');
console.error(`Available platforms: listPlatforms().join(', ')`);
process.exit(1);
}
const config = loadConfig();
if (config.keywords.length === 0) {
console.error('[monitor] No keywords configured in brand-profile.md');
console.error('[monitor] Run "seeddrop setup" to configure keywords');
process.exit(1);
}
const blacklist = loadBlacklist();
const repliedIds = loadRepliedPostIds();
const platforms = platformArg === 'all'
? (config.platforms.length > 0 ? config.platforms : listPlatforms())
: [platformArg];
let totalOutput = 0;
for (const plat of platforms) {
try {
const posts = await monitorPlatform(plat, config.keywords, config.timeRange, targetArg);
console.error(`[monitor] plat: found posts.length raw posts`);
for (const post of posts) {
if (repliedIds.has(post.id)) {
console.error(`[monitor] SKIP post.id: already replied`);
continue;
}
if (isBlacklisted(post, blacklist)) {
console.error(`[monitor] SKIP post.id: blacklisted`);
continue;
}
console.log(JSON.stringify(post));
totalOutput++;
}
} catch (err) {
console.error(`[monitor] Error monitoring plat: (err as Error).message`);
}
}
console.error(`[monitor] Total output: totalOutput posts`);
}
main().catch(err => {
console.error(`[monitor] Fatal error: (err as Error).message`);
process.exit(1);
});
FILE:scripts/responder.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: none (reply sending delegated to adapters via Agent)
// Local files read: stdin (JSONL), memory/interaction-log.jsonl, memory/brand-profile.md
// Local files written: stdout (drafts/results), memory/interaction-log.jsonl
import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createInterface } from 'node:readline';
import type {
ScoredPost,
InteractionLogEntry,
ReplyDraft,
ReplyMode,
} from './types.js';
import { PLATFORM_DAILY_LIMITS } from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const BASE_DIR = join(__dirname, '..');
const LOG_PATH = join(BASE_DIR, 'memory', 'interaction-log.jsonl');
// ─── Interaction Log ────────────────────────────────────────
function loadInteractionLog(): InteractionLogEntry[] {
if (!existsSync(LOG_PATH)) return [];
try {
const raw = readFileSync(LOG_PATH, 'utf-8');
return raw
.split('\n')
.filter((l: string) => l.trim())
.map((l: string) => JSON.parse(l) as InteractionLogEntry);
} catch (err) {
console.error(`[responder] Failed to load interaction log: (err as Error).message`);
return [];
}
}
export function appendToLog(entry: InteractionLogEntry): void {
const dir = dirname(LOG_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
appendFileSync(LOG_PATH, JSON.stringify(entry) + '\n', 'utf-8');
}
// ─── Deduplication & Safety Checks ──────────────────────────
function isAlreadyReplied(postId: string, log: InteractionLogEntry[]): boolean {
return log.some(entry => entry.postId === postId);
}
function isAuthorCoolingDown(author: string, platform: string, log: InteractionLogEntry[]): boolean {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
return log.some(
entry =>
entry.author === author &&
entry.platform === platform &&
new Date(entry.timestamp).getTime() > cutoff,
);
}
function getTodayReplyCount(platform: string, log: InteractionLogEntry[]): number {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const cutoff = todayStart.getTime();
return log.filter(
entry =>
entry.platform === platform &&
entry.success &&
new Date(entry.timestamp).getTime() >= cutoff,
).length;
}
function getDailyLimit(platform: string, mode: ReplyMode): number {
const limits = PLATFORM_DAILY_LIMITS[platform] ?? PLATFORM_DAILY_LIMITS['_default']!;
return mode === 'auto' ? limits.auto : limits.approve;
}
// ─── Reply Draft Generation ─────────────────────────────────
function generateDraft(post: ScoredPost): ReplyDraft {
return {
postId: post.id,
postUrl: post.url,
postTitle: post.title,
platform: post.platform,
content: `__DRAFT_PLACEHOLDER__`,
score: post.finalScore,
};
}
// ─── CLI Entry Point ────────────────────────────────────────
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args[0] === 'test') {
const log = loadInteractionLog();
console.log(JSON.stringify({
script: 'responder',
status: 'ok',
logEntries: log.length,
logPath: LOG_PATH,
}));
return;
}
// SECURITY: Only approve mode is supported - no auto-reply to prevent abuse
const mode: ReplyMode = 'approve';
if (args[0] === 'auto') {
console.error('[responder] SECURITY: Auto mode is disabled. All replies require manual approval.');
process.exit(1);
}
console.error(`[responder] Mode: mode (manual approval required)`);
const log = loadInteractionLog();
const rl = createInterface({ input: process.stdin, terminal: false });
let inputCount = 0;
let draftCount = 0;
let skipDup = 0;
let skipAuthor = 0;
let skipLimit = 0;
for await (const line of rl) {
if (!line.trim()) continue;
try {
const post = JSON.parse(line) as ScoredPost;
inputCount++;
if (isAlreadyReplied(post.id, log)) {
console.error(`[responder] SKIP post.id: already replied`);
skipDup++;
continue;
}
if (isAuthorCoolingDown(post.author, post.platform, log)) {
console.error(`[responder] SKIP post.id: author "post.author" cooldown (24h)`);
skipAuthor++;
continue;
}
const dailyLimit = getDailyLimit(post.platform, mode);
const todayCount = getTodayReplyCount(post.platform, log);
if (todayCount >= dailyLimit) {
console.error(`[responder] SKIP post.id: daily limit reached (todayCount/dailyLimit)`);
skipLimit++;
continue;
}
const draft = generateDraft(post);
if (mode === 'approve') {
console.log(JSON.stringify({
action: 'review_draft',
draft,
post: {
id: post.id,
url: post.url,
title: post.title,
body: post.body.substring(0, 300),
author: post.author,
platform: post.platform,
community: post.community,
scores: post.scores,
finalScore: post.finalScore,
},
instructions: `Generate a value-first reply for this post.platform post. Follow the template at templates/reply-post.platform.md. Brand mention ≤20%. Then present the draft to the user for approval.`,
}));
} else {
console.log(JSON.stringify({
action: 'auto_reply',
draft,
post: {
id: post.id,
url: post.url,
title: post.title,
body: post.body.substring(0, 300),
author: post.author,
platform: post.platform,
community: post.community,
scores: post.scores,
finalScore: post.finalScore,
},
instructions: `Generate and send a value-first reply for this post.platform post. Follow the template at templates/reply-post.platform.md. Brand mention ≤20%. Log the result to interaction-log.jsonl.`,
}));
}
draftCount++;
} catch {
console.error(`[responder] Failed to parse line: line.substring(0, 80)`);
}
}
console.error(`[responder] Done: draftCount drafts from inputCount posts (skipped: skipDup dup, skipAuthor author, skipLimit limit)`);
}
main().catch(err => {
console.error(`[responder] Fatal error: (err as Error).message`);
process.exit(1);
});
FILE:scripts/scorer.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: none
// Local files read: stdin (JSONL), memory/brand-profile.md
// Local files written: stdout (JSONL)
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createInterface } from 'node:readline';
import type { Post, ScoredPost, ScoreBreakdown } from './types.js';
import {
SCORE_WEIGHTS,
DEFAULT_THRESHOLD,
AUTO_MODE_MIN_THRESHOLD,
AUTO_MODE_MIN_RISK,
} from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const BASE_DIR = join(__dirname, '..');
// ─── Brand Profile Loading ──────────────────────────────────
interface ParsedBrandProfile {
keywords: string[];
mode: 'approve' | 'auto';
threshold: number;
}
function loadBrandProfile(): ParsedBrandProfile {
const profilePath = join(BASE_DIR, 'memory', 'brand-profile.md');
const defaults: ParsedBrandProfile = {
keywords: [],
mode: 'approve',
threshold: DEFAULT_THRESHOLD,
};
if (!existsSync(profilePath)) {
console.error('[scorer] brand-profile.md not found, using defaults');
return defaults;
}
try {
const raw = readFileSync(profilePath, 'utf-8');
const keywordsMatch = raw.match(/##\s*关键词.*?\n([\s\S]*?)(?=\n##|\n$|$)/i)
?? raw.match(/##\s*Keywords.*?\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (keywordsMatch) {
defaults.keywords = keywordsMatch[1]
.split('\n')
.map((l: string) => l.replace(/^[-*]\s*/, '').trim())
.filter((l: string) => l.length > 0);
}
const modeMatch = raw.match(/模式[::]\s*(approve|auto)/i)
?? raw.match(/mode[::]\s*(approve|auto)/i);
if (modeMatch) {
defaults.mode = modeMatch[1].toLowerCase() as 'approve' | 'auto';
}
const thresholdMatch = raw.match(/阈值[::]\s*([\d.]+)/i)
?? raw.match(/threshold[::]\s*([\d.]+)/i);
if (thresholdMatch) {
defaults.threshold = parseFloat(thresholdMatch[1]);
}
return defaults;
} catch (err) {
console.error(`[scorer] Failed to load brand profile: (err as Error).message`);
return defaults;
}
}
// ─── Scoring Functions ──────────────────────────────────────
const INTENT_KEYWORDS = {
high: ['求推荐', '怎么', '有什么好的', '推荐一下', '求助', '请问', '有没有推荐', '怎么选', '怎么办', 'how to', 'recommend', 'help', 'looking for'],
medium: ['讨论', '大家觉得', '有没有人用过', '有经验', '分享一下', '什么体验', '值不值', 'what do you think', 'experience'],
low: ['坑', '吐槽', '垃圾', '难用', '太差了', '后悔', '避雷', 'frustrated', 'terrible'],
};
function scoreRelevance(post: Post, keywords: string[]): number {
if (keywords.length === 0) return 0.5;
const text = `post.title post.body`.toLowerCase();
let hits = 0;
for (const kw of keywords) {
if (text.includes(kw.toLowerCase())) hits++;
}
const ratio = hits / keywords.length;
if (ratio > 0.5) return 1.0;
if (ratio > 0.2) return 0.7;
if (ratio > 0) return 0.5;
return 0.2;
}
function scoreIntent(post: Post): number {
const text = `post.title post.body`.toLowerCase();
for (const kw of INTENT_KEYWORDS.high) {
if (text.includes(kw)) return 0.9;
}
for (const kw of INTENT_KEYWORDS.medium) {
if (text.includes(kw)) return 0.7;
}
for (const kw of INTENT_KEYWORDS.low) {
if (text.includes(kw)) return 0.5;
}
return 0.3;
}
function scoreFreshness(post: Post): number {
const ageMs = Date.now() - new Date(post.createdAt).getTime();
const ageHours = ageMs / (1000 * 60 * 60);
if (ageHours <= 2) return 1.0;
if (ageHours <= 6) return 0.9;
if (ageHours <= 12) return 0.8;
if (ageHours <= 24) return 0.6;
if (ageHours <= 48) return 0.4;
return 0.2;
}
function scoreRisk(post: Post): number {
const text = `post.title post.body`.toLowerCase();
const officialMarkers = ['[meta]', '[announcement]', '[mod]', '[official]', '置顶', 'pinned'];
for (const marker of officialMarkers) {
if (text.includes(marker)) return 0.3;
}
const controversialMarkers = ['politic', 'religion', 'controversial', '政治', '宗教', '敏感'];
for (const marker of controversialMarkers) {
if (text.includes(marker)) return 0.2;
}
return 0.9;
}
export function scorePost(post: Post, keywords: string[]): ScoredPost {
const scores: ScoreBreakdown = {
relevance: scoreRelevance(post, keywords),
intent: scoreIntent(post),
freshness: scoreFreshness(post),
risk: scoreRisk(post),
};
const finalScore =
scores.relevance * SCORE_WEIGHTS.relevance +
scores.intent * SCORE_WEIGHTS.intent +
scores.freshness * SCORE_WEIGHTS.freshness +
scores.risk * SCORE_WEIGHTS.risk;
return { ...post, scores, finalScore: Math.round(finalScore * 1000) / 1000 };
}
// ─── CLI Entry Point ────────────────────────────────────────
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args[0] === 'test') {
const testPost: Post = {
id: 'test1',
url: 'https://reddit.com/r/test/test1',
title: 'How to build a landing page?',
body: 'Looking for recommendations on tools to build a quick landing page.',
author: 'testuser',
createdAt: new Date().toISOString(),
platform: 'reddit',
};
const result = scorePost(testPost, ['landing page', 'build', 'tool']);
console.log(JSON.stringify({
script: 'scorer',
status: 'ok',
testResult: result,
}));
return;
}
const thresholdArg = parseFloat(args[0] || '');
const profile = loadBrandProfile();
const threshold = !isNaN(thresholdArg) ? thresholdArg : profile.threshold;
const effectiveThreshold = profile.mode === 'auto'
? Math.max(threshold, AUTO_MODE_MIN_THRESHOLD)
: threshold;
console.error(`[scorer] Mode: profile.mode, Threshold: effectiveThreshold, Keywords: profile.keywords.length`);
const rl = createInterface({ input: process.stdin, terminal: false });
let inputCount = 0;
let passedCount = 0;
for await (const line of rl) {
if (!line.trim()) continue;
try {
const post = JSON.parse(line) as Post;
inputCount++;
const scored = scorePost(post, profile.keywords);
if (scored.finalScore < effectiveThreshold) {
console.error(`[scorer] SKIP scored.id (score=scored.finalScore < effectiveThreshold)`);
continue;
}
if (profile.mode === 'auto' && scored.scores.risk < AUTO_MODE_MIN_RISK) {
console.error(`[scorer] SKIP scored.id (risk=scored.scores.risk < AUTO_MODE_MIN_RISK, auto mode)`);
continue;
}
passedCount++;
console.log(JSON.stringify(scored));
} catch {
console.error(`[scorer] Failed to parse line: line.substring(0, 80)`);
}
}
console.error(`[scorer] Done: passedCount/inputCount posts passed threshold`);
}
main().catch(err => {
console.error(`[scorer] Fatal error: (err as Error).message`);
process.exit(1);
});
FILE:scripts/types.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: none
// Local files read: none
// Local files written: none
// ─── Credential ─────────────────────────────────────────────
export interface Credential {
authType: 'api_token' | 'cookie' | 'oauth';
value: string;
profile?: string;
source: 'socialvault' | 'local';
}
// ─── Post & Scoring ─────────────────────────────────────────
export interface Post {
id: string;
url: string;
title: string;
body: string;
author: string;
createdAt: string; // ISO 8601
platform: string;
community?: string; // 吧名、知乎话题、B站分区等
metadata?: Record<string, unknown>;
}
export interface ScoreBreakdown {
relevance: number;
intent: number;
freshness: number;
risk: number;
}
export interface ScoredPost extends Post {
scores: ScoreBreakdown;
finalScore: number;
}
// ─── Reply ──────────────────────────────────────────────────
export interface ReplyResult {
success: boolean;
replyId?: string;
error?: string;
mode?: 'api' | 'browser';
}
export interface ReplyDraft {
postId: string;
postUrl: string;
postTitle: string;
platform: string;
content: string;
score: number;
}
// ─── Auth / Check ───────────────────────────────────────────
export interface CheckResult {
valid: boolean;
username?: string;
error?: string;
}
export type AuthMode = 'socialvault' | 'none';
// ─── Rate Limiting ──────────────────────────────────────────
export interface RateLimitInfo {
requestsPerMinute: number;
repliesPerDay: number;
minReplyIntervalSeconds: number;
notes: string;
}
export interface DailyLimits {
approve: number;
auto: number;
}
export const PLATFORM_DAILY_LIMITS: Record<string, DailyLimits> = {
'bilibili': { approve: 30, auto: 15 },
'tieba': { approve: 20, auto: 10 },
'zhihu': { approve: 10, auto: 5 },
'xiaohongshu': { approve: 10, auto: 5 },
'_default': { approve: 10, auto: 5 },
};
// ─── Platform Adapter ───────────────────────────────────────
export interface PlatformAdapter {
readonly platformId: string;
readonly platformName: string;
search(
keyword: string,
timeRange: string,
credential: Credential,
target?: string,
): Promise<Post[]>;
reply(
postId: string,
content: string,
credential: Credential,
): Promise<ReplyResult>;
check(credential: Credential): Promise<CheckResult>;
rateLimitInfo(): RateLimitInfo;
/**
* Returns BrowserInstruction for Agent to execute search via browser tool.
* Used as fallback when API search is blocked (403).
*/
browserSearch?(
keyword: string,
target?: string,
): BrowserInstruction;
}
// ─── Interaction Log ────────────────────────────────────────
export interface InteractionLogEntry {
timestamp: string; // ISO 8601
platform: string;
postId: string;
postUrl: string;
postTitle: string;
author: string;
replyContent: string;
replyId?: string;
score: number;
mode: 'approve' | 'auto';
success: boolean;
}
// ─── Brand Profile ──────────────────────────────────────────
export interface BrandProfile {
businessName: string;
description: string;
keywords: string[];
platforms: string[];
mode: 'approve' | 'auto';
threshold: number;
language: string;
}
// ─── Scoring Config ─────────────────────────────────────────
export const SCORE_WEIGHTS = {
relevance: 0.35,
intent: 0.30,
freshness: 0.20,
risk: 0.15,
} as const;
export const DEFAULT_THRESHOLD = 0.6;
export const AUTO_MODE_MIN_THRESHOLD = 0.7;
export const AUTO_MODE_MIN_RISK = 0.5;
// ─── Browser Instruction (for adapter browser mode) ─────────
export const BROWSER_INSTRUCTION_ID = '__browser_instruction__';
export interface BrowserStep {
action: 'navigate' | 'wait' | 'extract' | 'click' | 'type';
url?: string;
selector?: string;
fields?: string[];
text?: string;
}
export interface BrowserInstruction {
mode: 'browser';
action: 'search' | 'reply' | 'check';
steps: BrowserStep[];
cookies?: string;
}
// ─── Anti-Detection Helpers ──────────────────────────────────
const CHROME_VERSION = '131';
export function buildBrowserHeaders(cookie: string, extra?: Record<string, string>): Record<string, string> {
return {
'Cookie': cookie,
'User-Agent': `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/CHROME_VERSION.0.0.0 Safari/537.36`,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'max-age=0',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Sec-Ch-Ua': `"Chromium";v="CHROME_VERSION", "Not_A Brand";v="24"`,
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Upgrade-Insecure-Requests': '1',
...extra,
};
}
export function buildApiHeaders(cookie: string, extra?: Record<string, string>): Record<string, string> {
return {
'Cookie': cookie,
'User-Agent': `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/CHROME_VERSION.0.0.0 Safari/537.36`,
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
'Sec-Ch-Ua': `"Chromium";v="CHROME_VERSION", "Not_A Brand";v="24"`,
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
...extra,
};
}
export async function fetchWithRetry(
url: string,
options: RequestInit,
maxRetries = 3,
): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
if (attempt > 0) {
const delay = Math.min(1000 * Math.pow(2, attempt) + Math.random() * 500, 8000);
await new Promise(r => setTimeout(r, delay));
}
try {
const response = await fetch(url, options);
if (response.status === 403 && attempt < maxRetries - 1) {
console.error(`[fetchWithRetry] 403 on attempt attempt + 1, retrying...`);
continue;
}
return response;
} catch (err) {
lastError = err as Error;
console.error(`[fetchWithRetry] Attempt attempt + 1 failed: lastError.message`);
}
}
throw lastError ?? new Error('All retry attempts failed');
}
// ─── Cookie Parsing ─────────────────────────────────────────
export function parseCookieValue(raw: string, name: string): string | undefined {
const match = raw.match(new RegExp(`(?:^|;\\s*)name=([^;]*)`));
return match?.[1];
}
// ─── Utility types ──────────────────────────────────────────
export type ReplyMode = 'approve' | 'auto';
export interface PerformanceStats {
total_replies: number;
by_platform: Record<string, number>;
by_date: Record<string, number>;
}
FILE:scripts/adapters/base.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: none
// Local files read: none
// Local files written: none
import type { PlatformAdapter } from '../types.js';
const adapterRegistry: Record<string, () => Promise<PlatformAdapter>> = {
'bilibili': async () => {
const { BilibiliAdapter } = await import('./bilibili.js');
return new BilibiliAdapter();
},
'tieba': async () => {
const { TiebaAdapter } = await import('./tieba.js');
return new TiebaAdapter();
},
'zhihu': async () => {
const { ZhihuAdapter } = await import('./zhihu.js');
return new ZhihuAdapter();
},
'xiaohongshu': async () => {
const { XiaohongshuAdapter } = await import('./xiaohongshu.js');
return new XiaohongshuAdapter();
},
};
export async function getAdapter(platformId: string): Promise<PlatformAdapter> {
const factory = adapterRegistry[platformId];
if (!factory) {
const available = Object.keys(adapterRegistry).join(', ');
throw new Error(`Unknown platform: "platformId". Available: available`);
}
return factory();
}
export function listPlatforms(): string[] {
return Object.keys(adapterRegistry);
}
const isMainModule = process.argv[1]?.replace(/\\/g, '/').endsWith('adapters/base.ts');
if (isMainModule && process.argv[2] === 'test') {
console.log(JSON.stringify({
script: 'adapters/base',
status: 'ok',
platforms: listPlatforms(),
}));
}
FILE:scripts/adapters/bilibili.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: https://api.bilibili.com
// Local files read: none
// Local files written: none
import type {
PlatformAdapter,
Credential,
Post,
ReplyResult,
CheckResult,
RateLimitInfo,
} from '../types.js';
import { parseCookieValue } from '../types.js';
const BILIBILI_API = 'https://api.bilibili.com';
function buildHeaders(credential: Credential): Record<string, string> {
return {
'Cookie': credential.value,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.bilibili.com',
};
}
export class BilibiliAdapter implements PlatformAdapter {
readonly platformId = 'bilibili';
readonly platformName = '哔哩哔哩';
async search(
keyword: string,
_timeRange: string,
credential: Credential,
_target?: string,
): Promise<Post[]> {
try {
const params = new URLSearchParams({
keyword,
search_type: 'video',
page: '1',
page_size: '20',
});
const url = `BILIBILI_API/x/web-interface/wbi/search/type?params`;
const response = await fetch(url, { headers: buildHeaders(credential) });
if (!response.ok) {
console.error(`[bilibili] Search failed: response.status`);
return [];
}
const data = await response.json() as {
code: number;
data?: {
result?: Array<{
aid: number;
bvid: string;
title: string;
description: string;
author: string;
pubdate: number;
typeid: number;
typename: string;
}>;
};
};
if (data.code !== 0 || !data.data?.result) {
console.error(`[bilibili] Search API error: code=data.code`);
return [];
}
return data.data.result.map(item => ({
id: String(item.aid),
url: `https://www.bilibili.com/video/item.bvid`,
title: item.title.replace(/<[^>]*>/g, ''),
body: item.description,
author: item.author,
createdAt: new Date(item.pubdate * 1000).toISOString(),
platform: this.platformId,
community: item.typename,
metadata: { bvid: item.bvid, aid: item.aid },
}));
} catch (error) {
console.error(`[bilibili] Search error: (error as Error).message`);
return [];
}
}
async reply(
postId: string,
content: string,
credential: Credential,
): Promise<ReplyResult> {
try {
const csrf = parseCookieValue(credential.value, 'bili_jct');
if (!csrf) {
return { success: false, error: 'Missing bili_jct in cookie (required for CSRF)', mode: 'api' };
}
const response = await fetch(`BILIBILI_API/x/v2/reply/add`, {
method: 'POST',
headers: {
...buildHeaders(credential),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
type: '1',
oid: postId,
message: content,
csrf,
}).toString(),
});
if (!response.ok) {
return { success: false, error: `HTTP response.status`, mode: 'api' };
}
const data = await response.json() as {
code: number;
message?: string;
data?: { rpid?: number };
};
if (data.code !== 0) {
return { success: false, error: `B站 API: data.message ?? `code ${data.code`}`, mode: 'api' };
}
return {
success: true,
replyId: data.data?.rpid ? String(data.data.rpid) : undefined,
mode: 'api',
};
} catch (error) {
return { success: false, error: (error as Error).message, mode: 'api' };
}
}
async check(credential: Credential): Promise<CheckResult> {
try {
const response = await fetch(`BILIBILI_API/x/web-interface/nav`, {
headers: buildHeaders(credential),
});
if (!response.ok) return { valid: false, error: `HTTP response.status` };
const data = await response.json() as {
code: number;
data?: { isLogin: boolean; uname?: string };
};
if (data.code !== 0 || !data.data?.isLogin) {
return { valid: false, error: 'Not logged in or cookie expired' };
}
return { valid: true, username: data.data.uname };
} catch (error) {
return { valid: false, error: (error as Error).message };
}
}
rateLimitInfo(): RateLimitInfo {
return {
requestsPerMinute: 30,
repliesPerDay: 30,
minReplyIntervalSeconds: 60,
notes: 'B站: 搜索≤30次/时, 评论≤50条/天, 重复内容自动过滤',
};
}
}
const isMainModule = process.argv[1]?.replace(/\\/g, '/').endsWith('bilibili.ts');
if (isMainModule && process.argv[2] === 'test') {
const adapter = new BilibiliAdapter();
console.log(JSON.stringify({
adapter: 'bilibili',
status: 'ok',
platformId: adapter.platformId,
platformName: adapter.platformName,
rateLimit: adapter.rateLimitInfo(),
}));
}
FILE:scripts/adapters/tieba.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: https://tieba.baidu.com
// Local files read: none
// Local files written: none
import type {
PlatformAdapter,
Credential,
Post,
ReplyResult,
CheckResult,
RateLimitInfo,
BrowserInstruction,
} from '../types.js';
import { buildBrowserHeaders, buildApiHeaders, fetchWithRetry } from '../types.js';
const TIEBA_BASE = 'https://tieba.baidu.com';
function pageHeaders(credential: Credential): Record<string, string> {
return buildBrowserHeaders(credential.value, {
'Referer': 'https://tieba.baidu.com/',
});
}
function apiHeaders(credential: Credential): Record<string, string> {
return buildApiHeaders(credential.value, {
'Referer': 'https://tieba.baidu.com/',
'Origin': 'https://tieba.baidu.com',
});
}
async function getTbs(credential: Credential): Promise<string | null> {
try {
const response = await fetchWithRetry(
`TIEBA_BASE/dc/common/tbs`,
{ headers: apiHeaders(credential) },
2,
);
if (!response.ok) return null;
const data = await response.json() as { tbs?: string; is_login?: number };
return data.tbs ?? null;
} catch {
return null;
}
}
export class TiebaAdapter implements PlatformAdapter {
readonly platformId = 'tieba';
readonly platformName = '百度贴吧';
async search(
keyword: string,
_timeRange: string,
credential: Credential,
target?: string,
): Promise<Post[]> {
try {
const url = target
? `TIEBA_BASE/f?kw=encodeURIComponent(target)&ie=utf-8&pn=0`
: `TIEBA_BASE/f/search/res?qw=encodeURIComponent(keyword)&rn=20&pn=1`;
const response = await fetchWithRetry(url, { headers: pageHeaders(credential) });
if (response.status === 403) {
console.error(
`[tieba] Search blocked (403). Use browserSearch() for browser-based fallback.`,
);
return [];
}
if (!response.ok) {
console.error(`[tieba] Search failed: response.status`);
return [];
}
const html = await response.text();
if (html.includes('百度安全验证') || html.includes('wappass.baidu.com')) {
console.error('[tieba] Hit anti-bot verification page. Use browserSearch() fallback.');
return [];
}
return this.parseSearchHtml(html, target);
} catch (error) {
console.error(`[tieba] Search error: (error as Error).message`);
return [];
}
}
browserSearch(keyword: string, target?: string): BrowserInstruction {
const url = target
? `TIEBA_BASE/f?kw=encodeURIComponent(target)&ie=utf-8&pn=0`
: `TIEBA_BASE/f/search/res?qw=encodeURIComponent(keyword)&rn=20&pn=1`;
return {
mode: 'browser',
action: 'search',
steps: [
{ action: 'navigate', url },
{ action: 'wait', selector: target ? '#thread_list' : '.s_post' },
{
action: 'extract',
selector: target ? '#thread_list .j_thread_list' : '.s_post',
fields: ['href', 'title', 'text'],
},
],
cookies: undefined,
};
}
private parseSearchHtml(html: string, target?: string): Post[] {
const posts: Post[] = [];
const threadPattern = /href="\/p\/(\d+)"[^>]*>([^<]+)<\/a>/g;
let match;
while ((match = threadPattern.exec(html)) !== null) {
const [, threadId, title] = match;
if (!threadId || !title) continue;
const cleanTitle = title.trim();
if (cleanTitle.length < 4) continue;
posts.push({
id: threadId,
url: `TIEBA_BASE/p/threadId`,
title: cleanTitle,
body: '',
author: '',
createdAt: new Date().toISOString(),
platform: this.platformId,
community: target,
});
}
const uniquePosts = new Map<string, Post>();
for (const p of posts) {
if (!uniquePosts.has(p.id)) uniquePosts.set(p.id, p);
}
return Array.from(uniquePosts.values()).slice(0, 20);
}
async reply(
postId: string,
content: string,
credential: Credential,
): Promise<ReplyResult> {
try {
const tbs = await getTbs(credential);
if (!tbs) {
return { success: false, error: 'Failed to get tbs token', mode: 'api' };
}
const metadata = (this as unknown as { _lastReplyMeta?: { kw: string; fid: string } })._lastReplyMeta;
const kw = metadata?.kw ?? '';
const fid = metadata?.fid ?? '';
const response = await fetchWithRetry(
`TIEBA_BASE/f/commit/post/add`,
{
method: 'POST',
headers: {
...apiHeaders(credential),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
ie: 'utf-8',
kw,
fid,
tid: postId,
content,
tbs,
}).toString(),
},
2,
);
if (!response.ok) {
return { success: false, error: `HTTP response.status`, mode: 'api' };
}
const data = await response.json() as {
err_code?: number;
error?: string;
data?: { tid?: string };
};
if (data.err_code && data.err_code !== 0) {
return { success: false, error: data.error ?? `err_code data.err_code`, mode: 'api' };
}
return { success: true, mode: 'api' };
} catch (error) {
return { success: false, error: (error as Error).message, mode: 'api' };
}
}
async check(credential: Credential): Promise<CheckResult> {
try {
const response = await fetchWithRetry(
`TIEBA_BASE/dc/common/tbs`,
{ headers: apiHeaders(credential) },
2,
);
if (!response.ok) return { valid: false, error: `HTTP response.status` };
const data = await response.json() as { tbs?: string; is_login?: number };
if (data.is_login !== 1) {
return { valid: false, error: 'Not logged in or BDUSS expired' };
}
return { valid: true, username: '(logged in via BDUSS)' };
} catch (error) {
return { valid: false, error: (error as Error).message };
}
}
rateLimitInfo(): RateLimitInfo {
return {
requestsPerMinute: 20,
repliesPerDay: 20,
minReplyIntervalSeconds: 120,
notes: '贴吧: BDUSS有效期6个月+, 回帖≤30条/天, 重复内容被删, 需从请求头获取BDUSS(非Cookie-Editor的BDUSS_BFESS). 搜索被403时使用browserSearch()回退.',
};
}
}
const isMainModule = process.argv[1]?.replace(/\\/g, '/').endsWith('tieba.ts');
if (isMainModule && process.argv[2] === 'test') {
const adapter = new TiebaAdapter();
console.log(JSON.stringify({
adapter: 'tieba',
status: 'ok',
platformId: adapter.platformId,
platformName: adapter.platformName,
rateLimit: adapter.rateLimitInfo(),
hasBrowserSearch: typeof adapter.browserSearch === 'function',
}));
}
FILE:scripts/adapters/xiaohongshu.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: https://edith.xiaohongshu.com/api/sns/web/v1
// Local files read: none
// Local files written: none
import type {
PlatformAdapter,
Credential,
Post,
ReplyResult,
CheckResult,
RateLimitInfo,
BrowserInstruction,
} from '../types.js';
import { BROWSER_INSTRUCTION_ID } from '../types.js';
const XHS_API = 'https://edith.xiaohongshu.com/api/sns/web/v1';
function buildHeaders(credential: Credential): Record<string, string> {
return {
'Cookie': credential.value,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.xiaohongshu.com',
'Origin': 'https://www.xiaohongshu.com',
};
}
export class XiaohongshuAdapter implements PlatformAdapter {
readonly platformId = 'xiaohongshu';
readonly platformName = '小红书';
async search(
keyword: string,
_timeRange: string,
credential: Credential,
_target?: string,
): Promise<Post[]> {
try {
const params = new URLSearchParams({
keyword,
page: '1',
page_size: '20',
search_id: '',
sort: 'general',
note_type: '0',
});
const response = await fetch(`XHS_API/search/notes?params`, {
headers: buildHeaders(credential),
});
if (response.ok) {
const data = await response.json() as {
code?: number;
success?: boolean;
data?: {
items?: Array<{
id: string;
note_card?: {
display_title?: string;
desc?: string;
user?: { nickname?: string };
time?: number;
};
}>;
};
};
if (data.success && data.data?.items?.length) {
return data.data.items.map(item => ({
id: item.id,
url: `https://www.xiaohongshu.com/explore/item.id`,
title: item.note_card?.display_title ?? '',
body: item.note_card?.desc ?? '',
author: item.note_card?.user?.nickname ?? '',
createdAt: item.note_card?.time
? new Date(item.note_card.time * 1000).toISOString()
: new Date().toISOString(),
platform: this.platformId,
}));
}
}
} catch (error) {
console.error(`[xiaohongshu] API search failed, falling back to browser: (error as Error).message`);
}
console.error('[xiaohongshu] API search unavailable, returning browser instruction');
const instruction: BrowserInstruction = {
mode: 'browser',
action: 'search',
steps: [
{ action: 'navigate', url: `https://www.xiaohongshu.com/search_result?keyword=encodeURIComponent(keyword)&source=web_search_result_notes` },
{ action: 'wait', selector: '.note-item' },
{ action: 'extract', selector: '.note-item', fields: ['title', 'url', 'author', 'likes'] },
],
cookies: credential.value,
};
return [{
id: BROWSER_INSTRUCTION_ID,
url: '',
title: '',
body: JSON.stringify(instruction),
author: '',
createdAt: new Date().toISOString(),
platform: this.platformId,
}];
}
async reply(
postId: string,
content: string,
credential: Credential,
): Promise<ReplyResult> {
const instruction: BrowserInstruction = {
mode: 'browser',
action: 'reply',
steps: [
{ action: 'navigate', url: `https://www.xiaohongshu.com/explore/postId` },
{ action: 'wait', selector: '#content-textarea' },
{ action: 'click', selector: '#content-textarea' },
{ action: 'type', text: content },
{ action: 'click', selector: '.submit-btn' },
],
cookies: credential.value,
};
return {
success: true,
replyId: `__browser_pending__:JSON.stringify(instruction)`,
mode: 'browser',
};
}
async check(credential: Credential): Promise<CheckResult> {
if (!credential.value || credential.value.length === 0) {
return { valid: false, error: 'No cookie provided' };
}
return {
valid: true,
username: '(cookie mode — verify via browser, cookies expire ~12h)',
};
}
rateLimitInfo(): RateLimitInfo {
return {
requestsPerMinute: 20,
repliesPerDay: 10,
minReplyIntervalSeconds: 3,
notes: 'No public API. Browser-only. Cookies expire ~12h. Min 3s between requests. Strongly recommend SocialVault for auto cookie refresh.',
};
}
}
const isMainModule = process.argv[1]?.replace(/\\/g, '/').endsWith('xiaohongshu.ts');
if (isMainModule && process.argv[2] === 'test') {
const adapter = new XiaohongshuAdapter();
console.log(JSON.stringify({
adapter: 'xiaohongshu',
status: 'ok',
platformId: adapter.platformId,
platformName: adapter.platformName,
rateLimit: adapter.rateLimitInfo(),
}));
}
FILE:scripts/adapters/zhihu.ts
// SECURITY MANIFEST:
// Environment variables accessed: none
// External endpoints called: https://www.zhihu.com/api/v4
// Local files read: none
// Local files written: none
import type {
PlatformAdapter,
Credential,
Post,
ReplyResult,
CheckResult,
RateLimitInfo,
BrowserInstruction,
} from '../types.js';
import { BROWSER_INSTRUCTION_ID, buildApiHeaders, buildBrowserHeaders, fetchWithRetry } from '../types.js';
const ZHIHU_API = 'https://www.zhihu.com/api/v4';
function searchApiHeaders(credential: Credential): Record<string, string> {
return buildApiHeaders(credential.value, {
'Referer': 'https://www.zhihu.com/search',
'Origin': 'https://www.zhihu.com',
'x-requested-with': 'fetch',
});
}
function checkApiHeaders(credential: Credential): Record<string, string> {
return buildApiHeaders(credential.value, {
'Referer': 'https://www.zhihu.com/',
'Origin': 'https://www.zhihu.com',
});
}
export class ZhihuAdapter implements PlatformAdapter {
readonly platformId = 'zhihu';
readonly platformName = '知乎';
async search(
keyword: string,
_timeRange: string,
credential: Credential,
_target?: string,
): Promise<Post[]> {
try {
const params = new URLSearchParams({
gk_version: 'gz-gaokao',
t: 'general',
q: keyword,
correction: '1',
offset: '0',
limit: '20',
filter_fields: '',
lc_idx: '0',
show_all_topics: '0',
search_source: 'Normal',
});
const response = await fetchWithRetry(
`ZHIHU_API/search_v3?params`,
{ headers: searchApiHeaders(credential) },
);
if (response.status === 403) {
console.error(
'[zhihu] Search blocked (403). Likely missing x-zse-96 signature. Use browserSearch() fallback.',
);
return [];
}
if (!response.ok) {
console.error(`[zhihu] Search failed: response.status`);
return [];
}
const data = await response.json() as {
data?: Array<{
type?: string;
object?: {
id?: number;
type?: string;
question?: { id?: number; title?: string };
title?: string;
excerpt?: string;
content?: string;
author?: { name?: string };
created_time?: number;
updated_time?: number;
};
}>;
};
if (!data.data) return [];
const posts: Post[] = [];
for (const item of data.data) {
const obj = item.object;
if (!obj) continue;
if (item.type === 'search_result' && obj.question) {
posts.push({
id: String(obj.question.id ?? obj.id ?? ''),
url: `https://www.zhihu.com/question/obj.question.id`,
title: obj.question.title ?? '',
body: (obj.excerpt ?? obj.content ?? '').replace(/<[^>]*>/g, ''),
author: obj.author?.name ?? '',
createdAt: obj.created_time
? new Date(obj.created_time * 1000).toISOString()
: new Date().toISOString(),
platform: this.platformId,
});
}
}
const unique = new Map<string, Post>();
for (const p of posts) {
if (p.id && !unique.has(p.id)) unique.set(p.id, p);
}
return Array.from(unique.values());
} catch (error) {
console.error(`[zhihu] Search error: (error as Error).message`);
return [];
}
}
browserSearch(keyword: string, _target?: string): BrowserInstruction {
const url = `https://www.zhihu.com/search?type=content&q=encodeURIComponent(keyword)`;
return {
mode: 'browser',
action: 'search',
steps: [
{ action: 'navigate', url },
{ action: 'wait', selector: '.SearchResult-Card' },
{
action: 'extract',
selector: '.SearchResult-Card',
fields: ['href', 'title', 'text'],
},
],
cookies: undefined,
};
}
async reply(
postId: string,
content: string,
credential: Credential,
): Promise<ReplyResult> {
const instruction: BrowserInstruction = {
mode: 'browser',
action: 'reply',
steps: [
{ action: 'navigate', url: `https://www.zhihu.com/question/postId` },
{ action: 'wait', selector: '.AnswerForm' },
{ action: 'click', selector: '.AnswerForm .RichContent' },
{ action: 'type', text: content },
{ action: 'click', selector: 'button[type="submit"]' },
],
cookies: credential.value,
};
return {
success: true,
replyId: `__browser_pending__:JSON.stringify(instruction)`,
mode: 'browser',
};
}
async check(credential: Credential): Promise<CheckResult> {
try {
const response = await fetchWithRetry(
`ZHIHU_API/me`,
{ headers: checkApiHeaders(credential) },
2,
);
if (!response.ok) return { valid: false, error: `HTTP response.status` };
const data = await response.json() as { id?: string; name?: string; error?: { message?: string } };
if (data.error) {
return { valid: false, error: data.error.message ?? 'Unknown error' };
}
return { valid: !!data.id, username: data.name };
} catch (error) {
return { valid: false, error: (error as Error).message };
}
}
rateLimitInfo(): RateLimitInfo {
return {
requestsPerMinute: 10,
repliesPerDay: 10,
minReplyIntervalSeconds: 300,
notes: '知乎: z_c0有效期~30天, 反爬严格(需x-zse-96签名), 高频触发验证码. 写入用browser模式. 搜索被403时使用browserSearch()回退.',
};
}
}
const isMainModule = process.argv[1]?.replace(/\\/g, '/').endsWith('zhihu.ts');
if (isMainModule && process.argv[2] === 'test') {
const adapter = new ZhihuAdapter();
console.log(JSON.stringify({
adapter: 'zhihu',
status: 'ok',
platformId: adapter.platformId,
platformName: adapter.platformName,
rateLimit: adapter.rateLimitInfo(),
hasBrowserSearch: typeof adapter.browserSearch === 'function',
}));
}
FILE:scripts/adapters/_template.ts
// SECURITY MANIFEST:
// Environment variables accessed: (list here)
// External endpoints called: (list here)
// Local files read: (list here)
// Local files written: (list here)
import type {
PlatformAdapter,
Credential,
Post,
ReplyResult,
CheckResult,
RateLimitInfo,
} from '../types.js';
/**
* Template for creating a new platform adapter.
*
* Steps:
* 1. Copy this file to scripts/adapters/<platform>.ts
* 2. Rename the class and update platformId / platformName
* 3. Implement search(), reply(), check(), rateLimitInfo()
* 4. Register in scripts/adapters/base.ts
* 5. Create templates/reply-<platform>.md
* 6. Add rate limits to references/safety-rules.md
* 7. Add TOS notes to references/platform-tos-notes.md
* 8. Test with: npx tsx scripts/adapters/<platform>.ts test
*/
export class TemplateAdapter implements PlatformAdapter {
readonly platformId = 'template';
readonly platformName = 'Template Platform';
async search(
keyword: string,
timeRange: string,
credential: Credential,
target?: string,
): Promise<Post[]> {
// TODO: Implement platform-specific search
// API mode: use fetch() with credential
// Browser mode: return BrowserInstruction wrapped in Post[]
console.error(`[this.platformId] search() not implemented`);
return [];
}
async reply(
postId: string,
content: string,
credential: Credential,
): Promise<ReplyResult> {
// TODO: Implement reply logic
console.error(`[this.platformId] reply() not implemented`);
return { success: false, error: 'Not implemented' };
}
async check(credential: Credential): Promise<CheckResult> {
// TODO: Implement credential validation
console.error(`[this.platformId] check() not implemented`);
return { valid: false, error: 'Not implemented' };
}
rateLimitInfo(): RateLimitInfo {
return {
requestsPerMinute: 60,
repliesPerDay: 10,
minReplyIntervalSeconds: 300,
notes: 'TODO: Add platform-specific rate limit notes',
};
}
}
const isMainModule = process.argv[1]?.replace(/\\/g, '/').endsWith('_template.ts');
if (isMainModule && process.argv[2] === 'test') {
const adapter = new TemplateAdapter();
console.log(JSON.stringify({
adapter: 'template',
status: 'ok',
platformId: adapter.platformId,
platformName: adapter.platformName,
rateLimit: adapter.rateLimitInfo(),
}));
}
FILE:references/platform-tos-notes.md
# 平台服务条款关键摘要
## B站
- 搜索接口: `api.bilibili.com/x/web-interface/wbi/search`
- 评论接口: `api.bilibili.com/x/v2/reply/add`(需 CSRF=bili_jct)
- 搜索频率建议 ≤30 次/小时
- 评论频率建议 ≤50 条/天
- 重复内容会被自动过滤
- 对服务器 IP 无特殊限制
## 百度贴吧
- BDUSS 有效期 6 个月+,非常稳定
- Cookie-Editor 导出的是 BDUSS_BFESS(不可用),必须从网络请求头获取 BDUSS
- 回帖需先获取 tbs token
- 回帖频率建议 ≤30 条/天
- 重复内容会被系统删除
- STOKEN 用于写操作验证
## 知乎
- 搜索接口: `zhihu.com/api/v4/search_v3`
- 回答需要 x-zse-96 签名(当前用 browser 模式绕过)
- z_c0 有效期约 30 天
- 对服务器 IP 有限制,高频访问触发验证码
- 回答/评论有内容审核
## 小红书
- 无公开写入 API,回复操作通过浏览器模拟
- 搜索接口: `edith.xiaohongshu.com/api/sns/web/v1/search/notes`(需 X-s/X-t 签名)
- Cookie 有效期极短: ~12 小时
- 多层反爬: Header + 行为指纹 + 设备ID + WASM
- 强烈建议配合 SocialVault 自动续期
- 评论区不允许外部链接
- 请求间隔不低于 3 秒
FILE:references/safety-rules.md
# SeedDrop 安全规则
这些规则在脚本中硬编码执行,不可通过配置绕过。
## 频率限制
### 每平台每日回复上限
| 平台 | Approve 模式 | Auto 模式 | 平台限制参考 |
|------|-------------|-----------|-------------|
| B站 | 30 | 15 | 评论 ≤50/天, 搜索 ≤30/时 |
| 贴吧 | 20 | 10 | 回帖 ≤30/天, 重复内容删除 |
| 知乎 | 10 | 5 | 反爬严格, 高频触发验证码 |
| 小红书 | 10 | 5 | 无 API, ≤1 req/3s |
| 其他平台 | 10 | 5 | — |
Auto 模式的上限为 Approve 模式的 50%,作为额外安全措施。
### 回复间隔
- 最小间隔: 5 分钟
- 最大间隔: 15 分钟
- 实际间隔: 在 5-15 分钟范围内随机(不固定)
- 小红书额外要求: 任意两次请求间隔不低于 3 秒
### 同一作者限制
- 同一用户的帖子 24 小时内最多回复 1 次
- 按作者 ID + 平台维度去重
### 同一帖子限制
- 每个帖子只能回复 1 次
- 通过 `interaction-log.jsonl` 中的 `post_id` 去重
## 内容安全
- 每条回复必须提供实质性帮助
- 品牌提及不超过回复总内容的 20%
- 不使用营销话术、夸大词汇
- 不包含直接联系方式或裸链接
## 回复多样性
- 不使用固定模板(每次重新生成)
- 随机化开头方式、长度、是否提及品牌
## 禁止回复的场景
- 帖子标记为置顶、官方、管理员帖
- 社区规则明确禁止自我推广
- 帖子作者在黑名单中
- 帖子内容涉及敏感话题
## Auto 模式额外限制
1. 每日上限降至 Approve 模式的 50%
2. 评分阈值最低不能低于 0.7
3. 风险评分低于 0.5 的帖子不回复
4. 新添加的平台默认 7 天内只能用 Approve 模式
## 黑名单管理
存储在 `memory/blacklist.md`,支持用户/社区/关键词三种维度。
## 法律合规
- 本工具仅辅助用户进行正常社区互动,不是数据采集/爬虫工具
- 使用用户自己的账号凭证操作,不构成未授权访问
- 不大规模采集、存储或传播第三方用户数据
- 内置频率限制确保不干扰平台正常运营
- 用户需自行遵守相关法律法规及各平台服务条款
- 请勿修改或绕过上述安全限制
FILE:references/scoring-criteria.md
# SeedDrop 评分标准
## 评分维度
| 维度 | 权重 | 说明 |
|------|------|------|
| 相关度 (Relevance) | 35% | 帖子内容与品牌关键词的匹配程度 |
| 意图强度 (Intent) | 30% | 帖子作者是否在寻求帮助或建议 |
| 时效性 (Freshness) | 20% | 帖子发布时间越新越好 |
| 风险评估 (Risk) | 15% | 回复该帖子的潜在风险 |
## 总分计算
```
final = relevance × 0.35 + intent × 0.30 + freshness × 0.20 + risk × 0.15
```
## 阈值
- 默认: 0.6
- Auto 模式最低: 0.7
- 推荐范围: 0.4 - 0.8
## 相关度评分
| 命中比例 | 分数 |
|---------|------|
| > 50% 关键词命中 | 1.0 |
| 20-50% | 0.7 |
| > 0% | 0.5 |
| 0% | 0.2 |
## 意图评分
| 类型 | 分数 | 特征词 |
|------|------|--------|
| 求助/提问 | 0.9 | 求推荐, 怎么, 请问, 有没有推荐, recommend, help |
| 讨论/意见 | 0.7 | 讨论, 大家觉得, 有没有人用过, 什么体验 |
| 吐槽/抱怨 | 0.5 | 坑, 吐槽, 垃圾, 难用, 避雷 |
| 纯分享 | 0.3 | 无特征词 |
## 时效评分
| 帖子年龄 | 分数 |
|---------|------|
| ≤ 2h | 1.0 |
| 2-6h | 0.9 |
| 6-12h | 0.8 |
| 12-24h | 0.6 |
| 24-48h | 0.4 |
| > 48h | 0.2 |
## 风险评分
| 类型 | 分数 |
|------|------|
| 安全帖子 | 0.9 |
| 官方/管理相关 | 0.3 |
| 争议性话题 | 0.2 |
FILE:memory/blacklist.md
# SeedDrop 黑名单
## 用户黑名单
(不回复以下用户的帖子)
## 社区黑名单
(不在以下社区/subreddit 发布)
## 关键词黑名单
(帖子包含以下关键词时不回复)
- [Meta]
- [Announcement]
- [Mod Post]
- [Official]
FILE:memory/brand-profile.md
# SeedDrop Brand Profile
## 基本信息
- 业务名称: SeedDrop
- 业务类型: 社区互动自动化工具
- 服务区域: 中文互联网(B站、贴吧、知乎、小红书)
- 官网: (待配置)
- 核心卖点: 自动监控社区讨论,AI生成高质量回复,自然种草,帮助独立开发者和小团队低成本做内容运营
## 目标客户关键词
- 主关键词: 社区营销, 内容运营工具, 自动回复, 种草工具
- 场景关键词: 独立开发运营, SaaS推广, 小团队营销, 社交媒体运营效率
- 竞品关键词: 社媒管理工具, 内容营销平台
## 品牌人设
- 语气: 真诚分享、像开发者朋友一样聊天,不硬推销
- 禁用语: 最好的, 第一, 保证, 神器, 爆款, 裂变, 私域
- 常用语: 我们也在用这个方案, 刚好做了个小工具, 分享一下踩过的坑
- 可提及案例: 用 SeedDrop 自动监控「独立开发」「SaaS工具」等话题,每天节省2小时人工筛帖时间
## 关键词
- 社区营销
- 内容运营工具
- 自动回复
- 种草助手
- 独立开发
- 内容营销
- 运营效率
- SaaS工具推广
- 社交媒体运营
- 社区互动
## 平台
- bilibili
- tieba
- zhihu
- xiaohongshu
## 监控平台配置
platforms:
- id: bilibili
enabled: true
keywords: []
- id: tieba
enabled: true
bars: []
keywords: []
- id: zhihu
enabled: true
keywords: []
- id: xiaohongshu
enabled: true
keywords: []
## 运行模式
- mode: approve
- scoring_threshold: 0.6
- daily_max_replies:
bilibili: 30
tieba: 20
zhihu: 10
xiaohongshu: 10
## 语言偏好
- primary: zh-CN
- secondary: en
FILE:guides/adapter-development.md
# 平台适配器开发指南
## 开发步骤
### 1. 复制模板
```bash
cp scripts/adapters/_template.ts scripts/adapters/<platform-id>.ts
```
### 2. 实现 PlatformAdapter 接口
```typescript
import type { PlatformAdapter, Credential, Post, ReplyResult, CheckResult, RateLimitInfo } from '../types.js';
export class MyAdapter implements PlatformAdapter {
readonly platformId = 'my-platform';
readonly platformName = 'My Platform';
async search(keyword: string, timeRange: string, credential: Credential, target?: string): Promise<Post[]> { ... }
async reply(postId: string, content: string, credential: Credential): Promise<ReplyResult> { ... }
async check(credential: Credential): Promise<CheckResult> { ... }
rateLimitInfo(): RateLimitInfo { ... }
}
```
### 3. 输出格式
**search 输出**(JSONL,每行一个):
```json
{"id":"...","url":"...","title":"...","body":"...","author":"...","createdAt":"...","platform":"<id>"}
```
**reply 输出**:
```json
{"success": true, "replyId": "..."}
```
**check 输出**:
```json
{"valid": true, "username": "..."}
```
### 4. 两种实现方式
**有 API 的平台**:使用 `fetch()` 直接调用 API,参考 `reddit.ts`。
**无 API 的平台**:返回 browser 指令,参考 `xiaohongshu.ts`:
```typescript
import { BROWSER_INSTRUCTION_ID, type BrowserInstruction } from '../types.js';
const instruction: BrowserInstruction = {
mode: 'browser',
action: 'search',
steps: [
{ action: 'navigate', url: `https://example.com/search?q=keyword` },
{ action: 'wait', selector: '.result-item' },
{ action: 'extract', selector: '.result-item', fields: ['title', 'url'] },
],
cookies: credential.value,
};
return [{ id: BROWSER_INSTRUCTION_ID, url: '', title: '', body: JSON.stringify(instruction), author: '', createdAt: new Date().toISOString(), platform: this.platformId }];
```
### 5. 注册适配器
在 `scripts/adapters/base.ts` 中添加:
```typescript
'my-platform': async () => {
const { MyAdapter } = await import('./my-platform.js');
return new MyAdapter();
},
```
### 6. 配套文件
| 文件 | 用途 |
|------|------|
| `templates/reply-<id>.md` | 回复风格指南 |
| `references/safety-rules.md` | 添加频率限制 |
| `references/platform-tos-notes.md` | 添加 ToS 摘要 |
### 7. 测试
```bash
npx tsx scripts/adapters/<platform-id>.ts test
```
### 8. 启用
在 `memory/brand-profile.md` 的 platforms 中添加新平台。
FILE:guides/brand-profile-setup.md
# Brand Profile 配置引导
## 配置流程
执行 `seeddrop setup` 时按以下步骤引导:
### 第 1 步:基本信息
- 业务名称、类型、服务区域
- 官网链接(不主动放入回复)
- 核心卖点(1-3 句)
### 第 2 步:关键词
- 主关键词(3-10 个)
- 场景关键词(3-10 个)
- 竞品关键词(可选)
- 中英文都要覆盖
### 第 3 步:品牌人设
- 语气风格:专业亲切 / 轻松幽默 / 正式严谨 / 热情直接
- 禁用语:如"最好的""保证""第一"
- 常用语和可引用案例
### 第 4 步:平台配置
- 选择启用的平台
- Reddit: 目标 subreddit 列表
- X/Twitter: 话题标签
- 小红书: 搜索关键词
### 第 5 步:运行模式
- 模式: approve(默认)/ auto
- 评分阈值: 默认 0.6
- 每日上限: 各平台独立配置
### 第 6 步:语言偏好
- 主语言 / 辅助语言
## 生成文件
配置完成写入 `memory/brand-profile.md`,格式参照 `docs/brand-profile.md` 示例。
FILE:guides/quickstart.md
# SeedDrop 快速上手
## 1. 安装
```bash
clawhub install seeddrop
```
## 2. 配置品牌资料
```bash
seeddrop setup
```
按提示输入:
- 产品/品牌名称
- 一句话描述
- 监控关键词(多个用逗号分隔)
- 使用的平台(bilibili, tieba, zhihu, xiaohongshu)
- 回复模式(approve = 人工审核, auto = 自动发送)
## 3. 添加平台凭证
```bash
seeddrop auth add bilibili
seeddrop auth add tieba
```
需要提供对应平台的 Cookie。推荐配合 SocialVault 管理凭证:
```bash
clawhub install socialvault
socialvault add bilibili
```
## 4. 运行监控
```bash
seeddrop monitor bilibili
seeddrop monitor tieba 编程
seeddrop monitor all
```
## 5. 查看报告
```bash
seeddrop report
seeddrop report weekly
```
## 平台 Cookie 获取
| 平台 | 必需字段 | 获取方式 |
|------|---------|---------|
| B站 | SESSDATA, bili_jct | 浏览器 DevTools → Application → Cookies |
| 贴吧 | BDUSS, STOKEN | 浏览器 DevTools → Network → 请求头 Cookie |
| 知乎 | z_c0, d_c0 | 浏览器 DevTools → Application → Cookies |
| 小红书 | a1, web_session | 浏览器 DevTools → Application → Cookies |
> **注意**:贴吧的 BDUSS 必须从请求头获取,Cookie-Editor 导出的 BDUSS_BFESS 不可用。
## 推荐搭配
安装 **SocialVault** 后可自动管理 Cookie 续期,特别是小红书(Cookie 仅 12 小时有效)。
FILE:.qoder/rules/code-quality.md
---
trigger: always_on
---
# 代码质量与测试标准
## 命名规范
- 变量名、函数名必须自解释,禁止 temp、data、info、handle 等模糊命名
- 布尔变量用 is/has/should/can 前缀
- 函数用动词开头:calculateTotalPrice 而非 totalPrice
- 常量用 UPPER_SNAKE_CASE
- 遵循项目已有命名约定(先 grep 确认)
## 函数设计
- 单一职责:一个函数只做一件事
- 函数体不超过 40 行,超过则拆分
- 参数不超过 4 个,超过则用 Options Object
- 禁止布尔参数控制分支(用策略模式或独立函数)
- 强类型语言中每个函数必须有明确返回值类型
## 错误处理
- 禁止空 catch 块,每个 catch 必须有日志或重抛
- 外部 I/O 必须有错误处理
- 使用有意义的自定义错误类型
- 异步代码中所有 Promise 必须有错误处理链
- 用户可见错误信息必须友好且不泄漏内部细节
## 代码结构
- 导入分组:外部库 → 内部模块 → 类型 → 样式
- 相关逻辑物理邻近,不相关逻辑物理分离
- 禁止超过 3 层嵌套(用 early return 或函数抽取)
- 魔法数字和魔法字符串必须提取为命名常量
## 注释哲学
- 好代码本身就是注释,优先靠命名和结构自解释
- 只在需要解释"为什么"时写注释,不解释"是什么"
- 公共 API 必须有 JSDoc / docstring
- TODO 必须附 issue 链接或负责人
## 测试标准
### 验证闭环
每次代码变更后必须执行:
1. get_problems → 修到 0 error
2. 运行受影响模块测试 → 全部通过
3. 涉及模块交互时运行集成测试
4. 逐条核对验收标准
任何一环不通过则自主修复,禁止标记完成
### 测试文件生成
- 一次只生成一个测试文件
- 生成后立即 get_problems 检查编译
- 修复编译问题后执行测试
- 当前文件通过后才进入下一个
### 测试用例设计
- 每个测试用例独立,不依赖执行顺序
- 命名格式:should_[预期行为]_when_[条件]
- 必须覆盖:Happy path / Edge cases / Error scenarios / 回归防护
- 每个测试只断言一个行为
- 测试行为和结果,不测实现细节
- Mock 只用于隔离外部依赖
FILE:.qoder/rules/core-principles.md
---
trigger: always_on
---
# Vue 2 项目专项规则
本规则适用于 Vue 2.x(含 2.7)项目,使用 Options API 风格。
## 版本识别
在执行任何修改前,先确认:
- package.json 中 vue 版本为 ^2.x
- 构建工具为 Vue CLI (@vue/cli) 或 Webpack
- 状态管理为 Vuex(3.x)
- UI 库为 Element UI 或其他 Vue 2 兼容库
如果发现实际是 Vue 3 项目,立即停止并切换到 Vue 3 规则
## 组件规范
### 文件组织
- 单文件组件(SFC)按 template → script → style 顺序排列
- 组件文件名使用 PascalCase:UserProfile.vue
- 每个文件只包含一个组件
### Options API 书写顺序
严格按照以下顺序排列 options:
1. name
2. components
3. directives / filters
4. mixins
5. props
6. data
7. computed
8. watch
9. 生命周期钩子(按执行顺序):beforeCreate → created → beforeMount → mounted → beforeUpdate → updated → beforeDestroy → destroyed
10. methods
### Props 规范
- props 必须有类型定义,禁止纯数组写法
- 正确:props: { title: { type: String, required: true, default: '' } }
- 错误:props: ['title']
- 必要时提供 validator 函数
- 复杂对象的 default 必须用工厂函数返回
### Data 规范
- data 必须是一个返回对象的函数,禁止直接写对象
- data 中不放与模板无关的变量(不需要响应式的数据放在 created 里用 this.xxx = ... 赋值)
### Computed 与 Watch
- 能用 computed 解决的不用 watch
- watch 中避免复杂逻辑,复杂操作抽到 methods
- 需要 deep watch 时显式声明 deep: true 并注释原因
### 事件规范
- 子组件向父组件通信用 $emit,事件名用 kebab-case
- 禁止滥用 $parent / $children 直接访问
- 禁止滥用 EventBus 做跨层级通信(大范围通信走 Vuex)
## Vuex 规范
- State:只存全局共享数据,组件私有状态留在组件内
- Mutations:只做同步状态变更,命名用 SET_XXX / UPDATE_XXX
- Actions:处理异步逻辑,命名用动词:fetchUserList / submitOrder
- Getters:用于派生状态,等同于 Store 级别的 computed
- 模块化:使用 namespaced: true,按业务领域拆分 module
## 路由规范(Vue Router 3.x)
- 路由配置使用懒加载:component: () => import('@/views/xxx.vue')
- 路由 name 使用 PascalCase,与组件文件名一致
- 路由守卫中的异步操作必须有错误处理
- 禁止在组件内直接操作 window.location,使用 $router
## 样式规范
- 使用 scoped 样式避免全局污染
- 需要穿透子组件时用 ::v-deep 或 /deep/(Vue 2 语法)
- 禁止在 scoped style 中使用标签选择器
- BEM 或项目已有的命名约定(先确认再使用)
## 兼容性注意
- Vue 2 不支持 Fragments(模板只能有一个根元素)
- Vue 2 不支持 Teleport / Suspense
- 数组变更检测限制:使用 Vue.set 或 this.$set 修改数组索引
- 对象新增属性检测限制:使用 Vue.set 或 this.$set 添加新属性
- 如果项目是 Vue 2.7,可使用 Composition API(setup 语法糖除外),但必须先确认项目是否引入了 @vue/composition-api 或已升级到 2.7
## 禁止事项
- 禁止使用已废弃的 API:$on / $off / $once(Vue 2.x 原生支持但 Vue 3 已移除,迁移风险高)
- 禁止在模板中使用复杂的内联表达式(超过一个函数调用的逻辑必须移到 computed 或 methods)
- 禁止修改 props(如需修改,用 data 接收或 computed 转换)
- 禁止在 data 中引用 props 的默认值后不追踪变化(常见 bug)
FILE:.qoder/rules/documentation-gen.md
---
trigger: always_on
---
# 文档生成规范
通过 @documentation-gen 手动触发。
## README 模板
- 简介:一句话描述项目用途
- 快速开始:最少步骤跑起来
- 架构概览:核心模块及关系
- 开发指南:环境搭建、常用命令
- API 文档(如适用)
- 贡献指南(如适用)
## 变更日志
- 使用 Keep a Changelog 格式
- 按 Added / Changed / Fixed / Removed 分类
- 每条关联 issue 或 PR 编号
## API 文档
- 包含请求方法、路径、参数、响应示例、错误码
- 提供 curl 示例
FILE:.qoder/rules/guardrails.md
---
trigger: always_on
---
# 防翻车护栏机制
## 认知护栏
### 不确定性声明
对某个技术细节置信度低于 80% 时,必须标注:
"⚠️ 我对此不够确定:[内容]。建议验证方式:[方法]"
禁止用自信语气描述不确定的方案。
### 假设追踪
每个假设必须显式记录:
"📌 假设:[内容] | 依据:[来源] | 风险:[假设错误的后果]"
验证阶段逐条回顾假设是否成立。
### 上下文不足时
- 禁止凭"常见做法"猜测项目特定配置
- 必须用工具验证(grep_code 搜模式、read_file 查配置)
- 工具也无法确认时向用户明确询问
## 执行护栏
### 变更范围锁定
- 每次改动必须与当前任务直接相关
- "顺便改进"的想法记录到 Memory,当前不动
- 任务范围变化时必须更新 Spec 并获得确认
### 渐进式修改
- 大型修改分步进行,每步之间运行验证
- 禁止一次性重写整个文件,用 search_replace 精确修改
- 每步改完立即 get_problems,确认无回归再继续
### 并发安全
- 文件编辑:必须串行
- 终端命令:必须串行
- 只读操作(read_file、search_codebase、grep_code):可以并行
### 回滚意识
- 高风险操作前确认有回滚路径
- 可能破坏现有功能的修改先确认测试覆盖
- 数据库 migration、配置变更等不可逆操作必须获得用户明确确认
## 思维链护栏
### 防止回归倾向
- 面对复杂任务禁止退缩为"建议用户自行处理"
- 遇到阻碍先尝试至少 2 种不同解决路径
- 穷尽工具能力后才向用户求助
### 防止死循环
同一修复尝试 3 次仍失败时必须:
1. 停下来重新分析根因
2. 告知用户当前状态和已尝试方案
3. 提出不同方向的解决思路
### 防止范围蔓延
每完成一步回顾:当前在做的事是否仍在 Spec 范围内?
发现偏离则立即停止并重新评估。
## 输出护栏
### 代码输出
- 默认用 search_replace 修改文件,不直接输出代码块
- 用户明确要求时才在对话中展示代码
- 展示代码用 // ... existing code ... 省略未改部分
### 任务完成确认
任务完成时必须提供结构化报告:
- 变更文件清单
- 验收标准逐条达成情况
- 已运行测试及结果
- 潜在风险提示(如有)
未实际完成的任务绝不标记为 complete。
FILE:.qoder/rules/review-and-refactor.md
---
trigger: always_on
---
# 代码审查与重构
## 审查清单
### P0 必须修复
- 逻辑错误:分支遗漏、条件判断错误、循环边界
- 安全漏洞:注入风险、敏感信息泄漏、未授权访问
- 数据一致性:竞态条件、事务缺失、状态不一致
- 资源泄漏:未关闭的连接/文件句柄、内存泄漏
### P1 强烈建议修复
- 错误处理缺失或不当
- 类型安全问题
- 性能问题(N+1 查询、不必要的重复计算)
- 测试覆盖不足
### P2 改进建议
- 命名可读性
- 代码结构优化
- 注释补充
- 代码重复(DRY)
## 重构原则
- 重构前必须确认测试覆盖
- 一次只做一种类型的重构
- 重构不改变外部行为,每步用测试确认
- 大型重构必须先产出 Spec 获得确认
FILE:.qoder/rules/security-checklist.md
---
trigger: always_on
---
# 安全检查清单
## 输入处理
- 所有用户输入必须验证和消毒(sanitize)
- SQL 查询使用参数化查询,禁止字符串拼接
- 文件路径操作防范路径穿越(path traversal)
- 正则表达式防范 ReDoS
## 认证授权
- 密码和密钥禁止硬编码
- 敏感配置使用环境变量
- API 端点必须有权限校验
- Session/Token 有合理过期策略
## 数据安全
- 敏感数据不出现在日志中
- API 响应不泄漏内部实现细节
- 错误信息对用户友好且不暴露技术栈
- PII 数据加密存储
## 依赖安全
- 不引入有已知漏洞的依赖版本
- 优先使用社区活跃维护的库
社交平台账号凭证管理器。提供登录态获取、AES-256-GCM 加密存储、定时健康监测和自动续期。Use when managing social media account credentials, importing cookies, checking login status, or automating...
---
name: social-vault
version: 0.1.0
description: "社交平台账号凭证管理器。提供登录态获取、AES-256-GCM 加密存储、定时健康监测和自动续期。Use when managing social media account credentials, importing cookies, checking login status, or automating session refresh. Also covers platform adapter creation and browser fingerprint management."
author: SocialVault Team
metadata:
clawdbot:
emoji: "🔐"
requires:
anyBins: ["node", "npx"]
os: ["linux", "darwin", "win32"]
install:
command: "bash setup.sh"
description: "Install tsx runtime and initialize vault directory"
install: "npm install --production"
tags:
- social-media
- account-management
- automation
- security
- cookie-management
- encryption
tools:
- bash
- browser
external_endpoints:
- "https://www.xiaohongshu.com/explore (Xiaohongshu verification)"
- "https://api.bilibili.com/x/web-interface/nav (Bilibili verification)"
- "https://passport.bilibili.com/x/passport-login/web/qrcode/generate (Bilibili QR login)"
- "https://www.zhihu.com/api/v4/me (Zhihu verification)"
- "https://tieba.baidu.com/f/user/json_userinfo (Tieba verification)"
files:
- "vault/ (encrypted credentials storage, runtime data)"
- "adapters/ (platform adapter definitions)"
- "adapters/custom/ (user-created adapters, never overwritten)"
- "guides/ (user tutorials)"
- "scripts/ (TypeScript utility scripts)"
cron:
- name: socialvault-health-check
schedule: "0 */6 * * *"
session: isolated
context: lightContext
announce: true
command: "npx tsx scripts/run-health-check.ts vault"
- name: socialvault-session-refresh
schedule: "0 3 * * *"
session: isolated
context: lightContext
announce: false
command: "npx tsx scripts/run-health-check.ts vault"
- name: socialvault-weekly-audit
schedule: "0 10 * * 1"
session: isolated
announce: true
command: "npx tsx scripts/run-health-check.ts vault"
---
# SocialVault
你是 SocialVault,一个专业的社交平台账号管理助手。你帮助用户安全地管理社交平台的登录凭证,包括获取、加密存储、健康监测和自动续期。
## 核心原则
1. **安全第一**:所有凭证使用 AES-256-GCM 加密存储,明文在内存中使用后立即清除。
2. **永不泄露**:绝不在对话中显示完整的 Cookie 值、Token、密码或密钥。最多显示前 4 个字符加 `***`。
3. **最小权限**:仅获取和存储任务所需的最少凭证。
4. **用户知情**:每个操作前告知用户即将做什么,操作后报告结果。
## 外部凭证声明
本 Skill **不需要**任何外部服务的 API 密钥、bot token、webhook URL 或环境变量。
- **浏览器操作**:通过 OpenClaw 平台内置的 `browser` 工具执行,无需额外配置。Browser profile 是 OpenClaw 的标准功能,Skill 通过 `browser set` 命令设置 User-Agent / viewport 等参数,不涉及外部凭证。
- **二维码展示**:扫码登录时,二维码截图在 Agent 对话中直接展示给用户,不推送到任何外部消息服务。
- **用户凭证**:用户手动提供的 Cookie / API Token 均加密存储在本地 vault.enc 中,不传输到任何第三方服务。
- **网络请求**:仅向 `external_endpoints` 中声明的社交平台官方域名发送验证请求。
## 辅助脚本
以下脚本通过 bash 工具执行,提供核心功能:
| 脚本 | 用途 | 调用示例 |
|------|------|----------|
| `scripts/vault-crypto.ts` | 加密存储初始化和密钥轮换 | `npx tsx scripts/vault-crypto.ts init vault` |
| `scripts/cookie-parser.ts` | 多格式 Cookie 解析 | `npx tsx scripts/cookie-parser.ts '<cookie-data>' '<domain>'` |
| `scripts/run-health-check.ts` | 健康检查 CLI 入口 | `npx tsx scripts/run-health-check.ts vault` |
| `scripts/fingerprint-manager.ts` | 浏览器指纹管理 | `npx tsx scripts/fingerprint-manager.ts load vault <account-id>` |
| `scripts/adapter-generator.ts` | 适配器自动生成 | `npx tsx scripts/adapter-generator.ts list` |
| `scripts/qrcode-server.ts` | 扫码登录会话管理 | `npx tsx scripts/qrcode-server.ts create vault <platform>` |
## 初始化
首次使用时:
1. 确保依赖已安装:`npm install --production`(安装 tsx 运行时,避免 npx 从网络动态拉取)。
2. 初始化 vault:`npx tsx scripts/vault-crypto.ts init vault`。
或一步完成:`npm run setup`。
## 命令路由
当用户与你对话时,根据意图匹配以下命令:
### `socialvault add <platform>`
添加社交平台账号。
**流程**:
1. 检查 vault 是否已初始化。若未初始化,先执行 `npx tsx scripts/vault-crypto.ts init vault`。
2. 根据 `<platform>` 加载对应适配器文件(`adapters/<platform>.md`)。如果适配器不存在,检查 `adapters/custom/<platform>.md`。都不存在则提示用户该平台尚未支持,询问是否创建自定义适配器。
3. 读取适配器的 `auth_methods`,按 priority 排序,向用户推荐优先级最高的方式,同时列出所有可选方式。
4. 用户选择后执行对应认证流程:
**Cookie 粘贴流程**:
- 读取适配器的 `cookie_guide` 指向的教程文件,向用户展示操作步骤。
- 用户粘贴 Cookie 后,在对话中解析:自动识别格式(JSON 数组 / raw header / Netscape),提取 Cookie 条目。
- 检查适配器中标注的必要 Cookie 字段是否存在。
- 按适配器的 `session_check` 配置验证登录态:
- `method: api`:使用凭证直接发起 HTTP 请求到验证端点。
- `method: browser`:使用 browser 工具注入 Cookie 后访问验证页面(仅当 API 方式不可用时使用)。
- 验证成功:
- 提取用户名和 profile 信息。
- 生成账号 ID(格式:`<platform>-<name>`)。
- 推断浏览器指纹:从用户的 User-Agent 和 Cookie 域名推断基本设备参数,使用合理默认值填充不足部分。保存到 `vault/fingerprints/<account-id>.json`。
- 加密存储凭证到 vault.enc。
- 更新 accounts.json 元数据。
- 创建关联的 OpenClaw browser profile(名称格式:`sv-<account-id>`)。
- 验证失败:提示可能原因(Cookie 过期、字段缺失、网络问题),引导用户重试。
**API Token 流程**:
- 读取适配器中的 API Token 认证步骤,分步引导用户获取凭证。
- **用户密码仅用于换取 Token,获取后立即丢弃,绝不存储密码**。
- 验证 Token 有效性后加密存储。
- 记录 Token 过期时间,设置自动刷新。
**扫码登录流程**(适用于小红书等国内平台的 VPS 场景):
- 执行 `npx tsx scripts/qrcode-server.ts create vault <platform>` 创建扫码会话。
- 使用 browser 工具打开平台登录页面。
- 定位二维码区域并截取图片。
- 在对话中直接向用户展示二维码截图。
- 轮询检查 browser 页面状态,等待用户扫码确认。
- 检测到登录成功后,导出 Cookie 并通过 vault-crypto 直接加密存储(Cookie 不以明文写入会话文件)。
- 清理扫码会话:`npx tsx scripts/qrcode-server.ts cleanup vault <session-id>`。
- 5 分钟超时自动过期。
5. 存储成功后告知用户:账号名称、平台、认证方式、预计有效期。
### `socialvault list`
列出所有已管理的账号。
**流程**:
1. 读取 vault/accounts.json。
2. 如果没有账号,提示用户使用 `socialvault add <platform>` 添加。
3. 以表格形式展示:
| 账号 | 平台 | 认证方式 | 状态 | 上次验证 | 预计过期 |
|------|------|----------|------|----------|----------|
状态图标:✅ healthy | ⚠️ degraded | ❌ expired | ❓ unknown
### `socialvault check [account-id]`
检查账号健康状态。
**流程**:
1. 如果指定了 account-id,只检查该账号;否则检查所有非 expired 账号。
2. 对每个账号:
a. 加载适配器文件。
b. 解密凭证。
c. 根据认证方式选择对应的 session_check 配置:
- api_token 账号使用 `session_check` 配置(API 验证)。
- cookie_paste 账号优先使用 `session_check_cookie` 配置(如存在),否则使用默认 `session_check`。
d. API 验证:调用 `npx tsx scripts/run-health-check.ts vault` 执行自动检查。
e. 如果适配器配置了 browser 方式(旧版适配器),需通过 Agent 执行 browser 验证。
f. 更新 accounts.json 中的状态和 lastValidatedAt。
3. 输出检查报告:
- 列出每个账号的验证结果。
- 对异常账号给出修复建议(更新 Cookie / 重新认证)。
- 对临近过期的账号发出预警。
### `socialvault remove <account-id>`
删除指定账号。
**流程**:
1. 在 accounts.json 中查找该账号。未找到则报告不存在。
2. 确认用户意图:"确定要删除账号 `<account-id>` 吗?此操作不可恢复。"
3. 用户确认后:
- 从 vault.enc 中删除凭证。
- 从 accounts.json 中删除元数据。
- 删除关联的 fingerprint 文件(`vault/fingerprints/<account-id>.json`)。
4. 报告删除结果。
### `socialvault use <account-id>`
将指定账号的凭证加载到当前会话的 browser profile 中。
**流程**:
1. 检查账号状态。如果 expired,拒绝并建议用户先更新凭证。
2. 解密凭证。
3. 根据认证方式处理:
- **API Token 模式**:将 access_token 提供给调用方使用。
- **Cookie 模式**:
a. 加载指纹文件:`npx tsx scripts/fingerprint-manager.ts load vault <account-id>`。
b. 配置 browser profile 环境:
- 设置 User-Agent
- 设置 viewport 尺寸
- 设置 locale 和 timezone
- 设置 deviceScaleFactor
c. 注入 Cookie 到 browser profile。
4. 确认就绪,告知用户可以开始操作。
5. 操作完成后:
- 重新导出 Cookie(平台可能已在操作过程中刷新了 Session)。
- 加密存储更新后的凭证。
- 清除内存中的明文凭证。
### `socialvault update <account-id>`
更新指定账号的凭证。
**流程**:
1. 在 accounts.json 中查找该账号。
2. 加载对应适配器。
3. 按原认证方式引导用户重新提供凭证:
- Cookie 模式:展示 Cookie 导出教程,等待用户粘贴新 Cookie。
- API Token 模式:引导重新获取 Token。
4. 验证新凭证有效性。
5. 加密存储替换旧凭证,更新状态为 healthy,更新 lastValidatedAt 和 estimatedExpiry。
6. 更新 fingerprint(如果用户在不同环境中导出了 Cookie)。
### `socialvault adapter list`
列出所有可用的平台适配器。
**流程**:
1. 执行 `npx tsx scripts/adapter-generator.ts list` 获取适配器列表。
2. 展示结果:
| 平台 | 认证方式 | 支持操作 | 来源 |
|------|----------|----------|------|
当前内置适配器:小红书、哔哩哔哩、知乎、百度贴吧。
### `socialvault adapter create <platform>`
交互式创建自定义平台适配器。
**流程**:
1. 询问用户以下信息:
- 平台名称(中文)和 platform_id(英文,小写+连字符)
- 平台 URL
- 支持哪些登录方式(Cookie 粘贴 / API Token / 扫码)
- 如何验证登录是否有效(访问哪个 URL,检查什么内容)
- 需要哪些操作能力(读帖子、搜索、发评论等)
- 预估 Cookie 有效天数
- 是否支持活跃续期
2. 根据用户描述,组装参数 JSON。
3. 执行 `npx tsx scripts/adapter-generator.ts generate . '<json>'` 生成适配器文件。
4. 文件自动保存到 `adapters/custom/<platform>.md`。
5. 告知用户适配器已创建,展示摘要信息。
6. 询问是否立即添加该平台的账号。
### `socialvault status`
显示 SocialVault 整体状态概览。
**流程**:
1. 读取 accounts.json,统计账号总数和各状态数量。
2. 展示摘要:
```
📊 SocialVault 状态概览
账号总数: X
✅ 正常: X | ⚠️ 异常: X | ❌ 失效: X | ❓ 未知: X
Vault 加密: ✅ 已启用 (AES-256-GCM)
密钥文件: ✅ 存在
最近检查: YYYY-MM-DD HH:MM
⚠️ 即将过期 (3天内):
- account-id (platform) - X 天后过期
```
### `socialvault rotate-key`
执行加密密钥轮换。
**流程**:
1. 确认操作:"密钥轮换将解密所有凭证并使用新密钥重新加密。确定继续吗?"
2. 执行 `npx tsx scripts/vault-crypto.ts rotate-key vault`。
3. 报告结果:新密钥已生成,所有凭证已重新加密。
### `socialvault token <account-id>`
获取指定账号的 API Token(仅限 api_token 认证方式的账号)。
**流程**:
1. 检查账号认证方式是否为 api_token。不是则拒绝。
2. 检查账号状态。如果 expired,拒绝。
3. 解密凭证,返回 access_token(不显示完整值,仅告知调用方已加载)。
4. 如果 token 已过期且有 client_id/client_secret,尝试自动刷新。
### `socialvault release <account-id>`
操作完成后回收凭证。供其他 Skill 调用。
**流程**:
1. 如果 browser profile 中有活跃的 Cookie,导出更新后的 Cookie。
2. 加密存储更新后的凭证。
3. 清除 browser profile 中的 Cookie。
4. 清除内存中的明文凭证。
5. 更新 lastRefreshedAt 时间戳。
## Cron 任务行为
### socialvault-health-check(每 6 小时)
1. 执行 `npx tsx scripts/run-health-check.ts vault`
2. 对所有非 expired 账号的登录态进行检查:
- API Token 账号:直接发送 HTTP 请求验证。
- Cookie 账号(API 验证方式):通过 HTTP 请求验证。
- Cookie 账号(旧版 browser 验证方式):保持当前状态不变(browser 验证需要 Agent 交互,不在 Cron 中执行)。
3. 失效账号推送告警消息:
```
🚨 [SocialVault] 账号状态异常
账号: {account-id} ({display-name})
平台: {platform}
状态: 登录态已失效
上次正常: {last-valid-time}
快速更新:
1. 在电脑浏览器中打开 {platform} 确认已登录
2. 导出新 Cookie(参考教程)
3. 使用 socialvault update {account-id} 更新
```
4. 临近过期(3 天内)推送预警消息:
```
⚠️ [SocialVault] 账号即将过期
账号: {account-id} ({display-name})
平台: {platform}
预计过期: {estimated-expiry} ({days-left} 天后)
建议提前更新凭证,避免自动化任务中断。
```
5. 更新 accounts.json 中的状态和 lastValidatedAt。
### socialvault-session-refresh(每天凌晨 3 点)
静默执行,对所有 healthy 且 auto_refresh_supported 的账号执行续期:
1. **Cookie 模式续期**:
a. 解密凭证,加载指纹。
b. 配置 browser profile 环境(User-Agent、viewport、locale 等)。
c. 注入 Cookie。
d. 访问平台首页或通知页面(轻量交互,触发 Session 刷新)。
e. 等待页面加载完成。
f. 导出更新后的 Cookie。
g. 加密存储新 Cookie,更新 lastRefreshedAt。
h. 清除内存明文。
2. **API Token 模式续期**:
a. 检查 tokenExpiresAt 是否临近过期(6 小时内)。
b. 如果有 refresh_token:使用其获取新 access_token。
c. 如果没有 refresh_token 但有 client_id/client_secret:使用 password grant 重新获取(需要存储用户名,但密码不存储,此场景下跳过并发告警)。
d. 更新加密存储。
3. **失败处理**:
- 刷新失败的账号状态设为 degraded。
- 推送降级告警:提示用户手动更新凭证。
### socialvault-weekly-audit(每周一上午 10 点)
汇总本周账号状态变化,生成并推送周报:
```
📋 [SocialVault] 周报 ({date-range})
📊 账号概览:
总数: X | 正常: X | 异常: X | 失效: X
📅 本周变化:
✅ 新增账号: X 个
🔄 续期成功: X 次
⚠️ 降级事件: X 次
❌ 失效事件: X 次
⏰ 即将过期:
- {account} ({platform}) - {days} 天后
🔐 安全状态:
密钥文件: ✅
vault 加密: ✅
上次密钥轮换: {date}
```
## 对外接口
其他 Skill 通过对话调用 SocialVault 的能力:
1. **查询状态**:"socialvault status of bilibili-main" → 返回账号状态信息
2. **加载凭证**:"socialvault use bilibili-main" → 配置 browser profile 并注入凭证
3. **获取 Token**:"socialvault token bilibili-main" → 返回 API access_token(仅 api_token 方式)
4. **回收凭证**:"socialvault release bilibili-main" → 导出更新后的 Cookie,加密存储,清除明文
5. **检查单账号**:"socialvault check bilibili-main" → 验证并返回当前状态
## 内置平台
SocialVault 内置以下平台适配器:
| 平台 | 适配器 | 认证方式 | Cookie 有效期 |
|------|--------|----------|---------------|
| 小红书 | `adapters/xiaohongshu.md` | Cookie / 扫码登录 | ~7 天 |
| 哔哩哔哩 | `adapters/bilibili.md` | Cookie / 扫码登录 | ~30 天 |
| 知乎 | `adapters/zhihu.md` | Cookie | ~30 天 |
| 百度贴吧 | `adapters/tieba.md` | Cookie | ~180 天 |
用户可通过 `socialvault adapter create` 添加更多平台。
## 安全警告
以下行为绝对禁止:
- ❌ 在对话中显示完整的 Cookie 值或 Token
- ❌ 存储用户的平台登录密码
- ❌ 在日志中记录任何凭证内容
- ❌ 将凭证发送到任何外部服务
- ❌ 在 vault/ 目录外存储任何敏感数据
- ❌ 修改或覆盖 adapters/custom/ 目录中用户自建的适配器
## 域名白名单
`session-verifier.ts` 内置**硬编码域名白名单**,仅允许向以下受信任域名发送认证头:
- 小红书: `xiaohongshu.com`, `edith.xiaohongshu.com`
- 哔哩哔哩: `bilibili.com`, `api.bilibili.com`, `space.bilibili.com`, `passport.bilibili.com`
- 微博: `weibo.com`, `api.weibo.com`
- 抖音: `douyin.com`
- 知乎: `zhihu.com`, `www.zhihu.com`
- 百度贴吧: `tieba.baidu.com`, `baidu.com`
**安全机制**:
- 白名单在代码中硬编码,不可通过适配器文件修改。
- 如果适配器的 `session_check.endpoint` 域名不在白名单中,`verifyViaApi` 拒绝发送请求并返回错误。
- `adapter-generator.ts` 在创建适配器时也会校验端点域名。
- 新增平台的域名需要修改 `session-verifier.ts` 源代码中的 `TRUSTED_DOMAINS` 数组。
FILE:clawhub.json
{
"name": "social-vault",
"version": "0.1.0",
"displayName": "SocialVault",
"description": "社交平台账号凭证管理器 — 登录态获取、AES-256-GCM 加密存储、定时健康监测、自动续期",
"author": "SocialVault Team",
"license": "MIT",
"homepage": "",
"categories": ["social-media", "security", "automation"],
"tags": [
"social-media",
"account-management",
"cookie",
"encryption",
"session-management",
"xiaohongshu",
"bilibili",
"zhihu",
"tieba"
],
"platforms": ["linux", "darwin", "win32"],
"requirements": {
"node": ">=24.0.0",
"bins": ["node", "npx"]
},
"install": {
"command": "bash setup.sh",
"description": "Installs tsx runtime via npm and initializes vault directory. All dependencies are pinned in package.json and installed from npm registry. No unknown URLs or dynamic downloads."
},
"permissions": {
"tools": ["bash", "browser"],
"network": [
{
"endpoint": "https://www.xiaohongshu.com/explore",
"purpose": "小红书登录态验证(API 方式,检查页面内容)",
"direction": "outbound-readonly"
},
{
"endpoint": "https://api.bilibili.com/x/web-interface/nav",
"purpose": "哔哩哔哩登录态验证(API 方式,检查 isLogin 字段)",
"direction": "outbound-readonly"
},
{
"endpoint": "https://passport.bilibili.com/x/passport-login/web/qrcode/generate",
"purpose": "哔哩哔哩扫码登录二维码生成",
"direction": "outbound-readonly"
},
{
"endpoint": "https://www.zhihu.com/api/v4/me",
"purpose": "知乎登录态验证(API 方式,检查用户 ID)",
"direction": "outbound-readonly"
},
{
"endpoint": "https://tieba.baidu.com/f/user/json_userinfo",
"purpose": "百度贴吧登录态验证(API 方式,检查用户名)",
"direction": "outbound-readonly"
}
],
"filesystem": [
{
"path": "vault/",
"access": "read-write",
"purpose": "加密凭证存储(vault.enc)、密钥文件(vault-key)、账号元数据(accounts.json)、浏览器指纹(fingerprints/)"
},
{
"path": "adapters/",
"access": "read",
"purpose": "读取平台适配器定义文件(Markdown 格式)"
},
{
"path": "adapters/custom/",
"access": "read-write",
"purpose": "用户自建适配器存储(Skill 更新不覆盖此目录)"
}
]
},
"security": {
"encryption": "AES-256-GCM with random 96-bit IV per encryption, 256-bit key",
"key_storage": "Local file (vault-key) with 600 permissions, never transmitted",
"credential_handling": "Encrypted at rest in vault.enc; decrypted only in memory for verification; cleared immediately after use via clearCredentials()",
"password_policy": "User passwords used only for OAuth token exchange, never persisted to disk",
"domain_whitelist": "session-verifier.ts enforces a hardcoded TRUSTED_DOMAINS whitelist. Only official social platform domains (xiaohongshu.com, bilibili.com, zhihu.com, etc.) can receive authentication headers. Requests to non-whitelisted domains are rejected before any credential data is sent. The whitelist cannot be modified via adapter files; it requires source code changes.",
"network_data_flow": "Network requests only send authentication headers (Bearer token or Cookie) to whitelisted official platform domains for login verification. The domain whitelist is enforced in code, not configuration. No local file contents, vault data, or credentials are sent to any third-party or non-whitelisted service.",
"qr_login": "QR session metadata (sessionId, status, expiry) stored in vault/ as temporary JSON files. Captured cookies are encrypted directly via vault-crypto and never written to session files in plaintext. Session files are deleted after completion.",
"cron_behavior": "Scheduled tasks decrypt credentials in memory to verify login status against platform APIs, then immediately clear plaintext. Results update only the status field in accounts.json. No credentials are logged or transmitted to non-declared endpoints.",
"no_exfiltration": "File reads (accounts.json, adapter configs) and network sends (platform API verification) serve distinct purposes: file reads load local configuration and encrypted credentials; network sends verify login validity against the user's own social platform accounts. These operations do not constitute data exfiltration."
},
"trust_statement": "SocialVault 将所有凭证使用 AES-256-GCM 加密存储在本地 vault.enc 文件中。256 位加密密钥仅存在于用户设备的 vault-key 文件中,从不通过网络传输。session-verifier.ts 内置硬编码域名白名单,仅允许向 xiaohongshu.com、bilibili.com、zhihu.com 等官方社交平台域名发送认证头,拒绝向白名单外的任何域名发送请求。白名单不可通过适配器文件修改。不会将任何凭证数据、文件内容或密钥传输到第三方服务。密码仅在 OAuth 令牌交换时短暂使用,之后立即从内存中清除,绝不落盘。扫码登录的 Cookie 直接加密存储,不以明文形式写入任何文件。"
}
FILE:package.json
{
"name": "socialvault",
"version": "0.1.0",
"description": "OpenClaw Skill for social platform account management",
"type": "module",
"scripts": {
"setup": "npm install --production && npx tsx scripts/vault-crypto.ts init vault",
"test": "npx tsx tests/test-crypto.ts && npx tsx tests/test-cookie-parser.ts && npx tsx tests/test-health-check.ts",
"test:crypto": "npx tsx tests/test-crypto.ts",
"test:cookie": "npx tsx tests/test-cookie-parser.ts",
"test:health": "npx tsx tests/test-health-check.ts"
},
"dependencies": {
"tsx": "^4.19.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"@types/node": "^22.0.0"
}
}
FILE:README.md
# SocialVault
社交平台账号凭证管理器 — 为 OpenClaw Agent 提供登录态获取、加密存储、健康监测和自动续期能力。
## 功能概览
- **多方式登录态获取**:Cookie 粘贴(3 种格式自动识别)、API Token、扫码登录
- **加密存储**:AES-256-GCM 加密,随机 IV,密钥权限 600
- **健康监测**:每 6 小时自动检查,失效告警 + 过期预警
- **自动续期**:Cookie 浏览器活跃续期 + API Token 自动刷新
- **浏览器指纹**:首次导入时记录环境参数,操作时自动还原
- **可插拔架构**:Platform Adapter 纯 Markdown 格式,支持用户自建
## 内置平台
| 平台 | 认证方式 | Cookie 有效期 |
|------|----------|---------------|
| 小红书 | Cookie / 扫码登录 | ~7 天 |
| 哔哩哔哩 | Cookie / 扫码登录 | ~30 天 |
| 知乎 | Cookie | ~30 天 |
| 百度贴吧 | Cookie | ~180 天 |
## 快速开始
```
# 添加 B站 账号
socialvault add bilibili
# 查看所有账号
socialvault list
# 检查账号状态
socialvault check
# 使用账号凭证
socialvault use bilibili-main
```
## 安全性
- 所有凭证使用 AES-256-GCM 加密存储在本地
- 密钥文件权限设为 600(仅 owner 可读写)
- 明文凭证在内存中使用后立即清除
- 密码仅用于 Token 交换,不做持久化
- 日志中不输出任何凭证内容
- vault/ 目录不入版本控制
## 目录结构
```
socialvault/
├── SKILL.md # 主 Skill 定义
├── clawhub.json # ClawHub 发布清单
├── scripts/ # TypeScript 辅助脚本
│ ├── types.ts
│ ├── vault-crypto.ts
│ ├── cookie-parser.ts
│ ├── health-check.ts
│ ├── fingerprint-manager.ts
│ ├── adapter-generator.ts
│ └── qrcode-server.ts
├── adapters/ # 平台适配器
│ ├── _spec.md
│ ├── _template.md
│ ├── xiaohongshu.md
│ ├── bilibili.md
│ ├── zhihu.md
│ ├── tieba.md
│ └── custom/ # 用户自建适配器
├── guides/ # 用户教程
│ ├── cookie-export-xiaohongshu.md
│ ├── cookie-export-bilibili.md
│ ├── cookie-export-zhihu.md
│ └── cookie-export-tieba.md
├── tests/ # 测试文件
│ ├── test-crypto.ts
│ ├── test-cookie-parser.ts
│ └── test-health-check.ts
└── vault/ # 运行时数据(不入版本控制)
├── vault.enc
├── vault-key
├── accounts.json
└── fingerprints/
```
## 测试
```bash
npm test
```
覆盖 48 个测试用例:加密/解密、Cookie 解析、健康检查。
## 命令列表
| 命令 | 说明 |
|------|------|
| `socialvault add <platform>` | 添加账号 |
| `socialvault list` | 列出所有账号 |
| `socialvault check [account-id]` | 检查健康状态 |
| `socialvault use <account-id>` | 加载凭证到 browser |
| `socialvault update <account-id>` | 更新凭证 |
| `socialvault remove <account-id>` | 删除账号 |
| `socialvault status` | 整体状态概览 |
| `socialvault adapter list` | 列出适配器 |
| `socialvault adapter create <platform>` | 创建自定义适配器 |
| `socialvault rotate-key` | 密钥轮换 |
| `socialvault token <account-id>` | 获取 API Token |
| `socialvault release <account-id>` | 回收凭证 |
## 许可证
MIT
FILE:setup.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "[SocialVault] Installing dependencies..."
npm install --production
echo "[SocialVault] Verifying tsx is available..."
npx tsx --version
echo "[SocialVault] Initializing vault..."
npx tsx scripts/vault-crypto.ts init vault
echo "[SocialVault] Setup complete."
FILE:tsconfig.json
{
"compilerOptions": {
"target": "ES2024",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": ".",
"declaration": true,
"resolveJsonModule": true,
"lib": ["ES2024"]
},
"include": ["scripts/**/*.ts", "tests/**/*.ts"],
"exclude": ["node_modules", "dist", "vault"]
}
FILE:scripts/adapter-generator.ts
/**
* SocialVault 适配器生成模块
*
* 根据用户提供的平台信息自动生成 Markdown 适配器文件。
* Agent 通过对话收集信息后调用此脚本生成文件。
* 生成前会校验 session_check 端点域名是否受信任。
*
* 运行:npx tsx scripts/adapter-generator.ts <json-params>
*/
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { validateEndpointDomain } from "./session-verifier.js";
/** 适配器生成参数 */
export interface AdapterParams {
platformId: string;
platformName: string;
authMethods: Array<{
type: "cookie_paste" | "api_token" | "qrcode";
priority: number;
label: string;
}>;
capabilities: string[];
sessionCheckMethod: "api" | "browser";
sessionCheckEndpoint: string;
sessionCheckIndicator: string;
estimatedSessionDays: number;
autoRefreshSupported: boolean;
cookieGuide?: string;
rateLimits?: Record<string, number>;
authSteps?: string;
validationDescription?: string;
operationDescriptions?: Record<string, string>;
knownIssues?: string[];
}
/**
* 生成适配器 frontmatter YAML。
* @param params - 适配器参数
* @returns YAML frontmatter 字符串
*/
function generateFrontmatter(params: AdapterParams): string {
const lines: string[] = [
"---",
`platform_id: "params.platformId"`,
`platform_name: "params.platformName"`,
"auth_methods:",
];
for (const method of params.authMethods) {
lines.push(` - type: "method.type"`);
lines.push(` priority: method.priority`);
lines.push(` label: "method.label"`);
}
lines.push("capabilities:");
for (const cap of params.capabilities) {
lines.push(` - cap`);
}
if (params.cookieGuide) {
lines.push(`cookie_guide: "params.cookieGuide"`);
}
lines.push("session_check:");
lines.push(` method: "params.sessionCheckMethod"`);
lines.push(` endpoint: "params.sessionCheckEndpoint"`);
lines.push(` success_indicator: "params.sessionCheckIndicator"`);
lines.push(`estimated_session_duration_days: params.estimatedSessionDays`);
lines.push(`auto_refresh_supported: params.autoRefreshSupported`);
if (params.rateLimits && Object.keys(params.rateLimits).length > 0) {
lines.push("rate_limits:");
for (const [key, value] of Object.entries(params.rateLimits)) {
lines.push(` key: value`);
}
}
lines.push("---");
return lines.join("\n");
}
/**
* 生成适配器正文 Markdown。
* @param params - 适配器参数
* @returns Markdown 正文
*/
function generateBody(params: AdapterParams): string {
const sections: string[] = [];
// 认证流程
sections.push("## 认证流程");
sections.push("");
if (params.authSteps) {
sections.push(params.authSteps);
} else {
for (const method of params.authMethods) {
sections.push(`### method.label`);
sections.push("");
if (method.type === "cookie_paste") {
sections.push("用户在浏览器中登录后,通过 Cookie-Editor 插件导出 Cookie 并粘贴给 SocialVault。");
sections.push("");
if (params.cookieGuide) {
sections.push(`**Cookie 获取步骤**:参见 params.cookieGuide`);
}
} else if (method.type === "api_token") {
sections.push("用户获取平台 API 凭证后提供给 SocialVault。");
} else if (method.type === "qrcode") {
sections.push("通过 headless browser 打开登录页面生成二维码,用户使用手机 APP 扫码完成登录。");
}
sections.push("");
}
}
// 登录态验证
sections.push("## 登录态验证");
sections.push("");
if (params.validationDescription) {
sections.push(params.validationDescription);
} else if (params.sessionCheckMethod === "browser") {
sections.push(`使用 browser 工具访问 \`params.sessionCheckEndpoint\`,检查页面是否包含 "params.sessionCheckIndicator"。`);
sections.push("");
sections.push("判定逻辑:");
sections.push(`- 页面包含 "params.sessionCheckIndicator" → \`healthy\``);
sections.push("- 页面重定向到登录页面 → `expired`");
sections.push("- 页面加载超时 → `unknown`");
} else {
sections.push(`发送请求到 \`params.sessionCheckEndpoint\`,检查响应中是否包含 "params.sessionCheckIndicator"。`);
sections.push("");
sections.push("判定逻辑:");
sections.push(`- 响应 200 且包含 "params.sessionCheckIndicator" → \`healthy\``);
sections.push("- 响应 401/403 → `expired`");
sections.push("- 网络错误 → `unknown`");
}
sections.push("");
// 操作指令
sections.push("## 操作指令");
sections.push("");
if (params.operationDescriptions) {
for (const [cap, desc] of Object.entries(params.operationDescriptions)) {
sections.push(`### cap`);
sections.push("");
sections.push(desc);
sections.push("");
}
} else {
for (const cap of params.capabilities) {
sections.push(`### cap`);
sections.push("");
sections.push(`(待补充 cap 操作的具体步骤)`);
sections.push("");
}
}
// 频率控制
sections.push("## 频率控制");
sections.push("");
if (params.rateLimits && Object.keys(params.rateLimits).length > 0) {
sections.push("| 操作 | 建议频率 |");
sections.push("|------|----------|");
for (const [key, value] of Object.entries(params.rateLimits)) {
sections.push(`| key | value |`);
}
} else {
sections.push("(请根据平台实际情况补充频率限制建议)");
}
sections.push("");
// 已知问题
sections.push("## 已知问题");
sections.push("");
if (params.knownIssues && params.knownIssues.length > 0) {
for (let i = 0; i < params.knownIssues.length; i++) {
sections.push(`i + 1. params.knownIssues[i]`);
}
} else {
sections.push("(暂无已知问题)");
}
return sections.join("\n");
}
/**
* 生成完整的适配器 Markdown 文件内容。
* @param params - 适配器参数
* @returns 完整的 Markdown 文件内容
*/
export function generateAdapter(params: AdapterParams): string {
const frontmatter = generateFrontmatter(params);
const body = generateBody(params);
return `frontmatter\n\nbody\n`;
}
/**
* 将生成的适配器写入文件。
* @param skillDir - skill 根目录
* @param params - 适配器参数
* @param isCustom - 是否为用户自建适配器(存入 custom/ 目录)
* @returns 生成的文件路径
* @throws 文件已存在时抛出异常
*/
export function writeAdapter(
skillDir: string,
params: AdapterParams,
isCustom: boolean = true
): string {
const domainCheck = validateEndpointDomain(params.sessionCheckEndpoint);
if (!domainCheck.trusted) {
throw new Error(
`安全拒绝:session_check 端点域名 "domainCheck.domain" 不在受信任白名单中。` +
`请使用平台的官方域名,或联系维护者将该域名添加到白名单。`
);
}
const dir = isCustom ? join(skillDir, "adapters", "custom") : join(skillDir, "adapters");
const fileName = `params.platformId.md`;
const filePath = join(dir, fileName);
if (existsSync(filePath)) {
throw new Error(`适配器文件已存在: filePath。如需覆盖,请先删除现有文件。`);
}
const content = generateAdapter(params);
try {
writeFileSync(filePath, content, "utf-8");
} catch (err) {
throw new Error(`适配器文件写入失败: (err as Error).message`);
}
return filePath;
}
/**
* 列出所有适配器信息。
* @param skillDir - skill 根目录
* @returns 适配器信息数组
*/
export function listAdapters(
skillDir: string
): Array<{ platformId: string; platformName: string; source: string; filePath: string }> {
const adapters: Array<{ platformId: string; platformName: string; source: string; filePath: string }> = [];
const scanDir = (dir: string, source: string) => {
if (!existsSync(dir)) return;
let files: string[];
try {
files = readdirSync(dir) as string[];
} catch {
return;
}
for (const file of files) {
if (!file.endsWith(".md") || file.startsWith("_")) continue;
const filePath = join(dir, file);
try {
const content = readFileSync(filePath, "utf-8");
const idMatch = content.match(/platform_id:\s*["']?([^"'\n]+)["']?/);
const nameMatch = content.match(/platform_name:\s*["']?([^"'\n]+)["']?/);
if (idMatch && nameMatch) {
adapters.push({
platformId: idMatch[1].trim(),
platformName: nameMatch[1].trim(),
source,
filePath: filePath,
});
}
} catch {
// 跳过无法读取的文件
}
}
};
scanDir(join(skillDir, "adapters"), "官方");
scanDir(join(skillDir, "adapters", "custom"), "自建");
return adapters;
}
// CLI 入口
if (process.argv[1]?.replace(/\\/g, "/").endsWith("scripts/adapter-generator.ts")) {
const command = process.argv[2];
const skillDir = resolve(process.argv[3] || ".");
switch (command) {
case "generate": {
const jsonInput = process.argv[4];
if (!jsonInput) {
console.error("用法: npx tsx scripts/adapter-generator.ts generate <skill-dir> <json-params>");
process.exit(1);
}
try {
const params = JSON.parse(jsonInput) as AdapterParams;
const filePath = writeAdapter(skillDir, params, true);
console.log(`适配器已生成: filePath`);
} catch (err) {
console.error(`生成失败: (err as Error).message`);
process.exit(1);
}
break;
}
case "list": {
const adapters = listAdapters(skillDir);
if (adapters.length === 0) {
console.log("未找到任何适配器。");
} else {
console.log("可用适配器:");
for (const a of adapters) {
console.log(` [a.source] a.platformName (a.platformId)`);
}
}
break;
}
default:
console.log("用法: npx tsx scripts/adapter-generator.ts <generate|list> [args]");
}
}
FILE:scripts/cookie-parser.ts
/**
* SocialVault Cookie 解析模块
*
* 支持三种 Cookie 输入格式的自动识别和解析:
* 1. JSON 数组格式(Cookie-Editor 导出)
* 2. Raw header 格式(key=value; key2=value2)
* 3. Netscape/curl 格式(制表符分隔)
*
* 所有格式统一输出标准化的 CookieEntry 数组。
*/
import type { CookieEntry, CookieFormat } from "./types.js";
/**
* 自动识别输入的 Cookie 格式。
* @param input - 原始 Cookie 字符串
* @returns 识别出的格式类型
* @throws 无法识别格式时抛出异常
*/
export function detectFormat(input: string): CookieFormat {
const trimmed = input.trim();
// JSON 数组:以 [ 开头
if (trimmed.startsWith("[")) {
return "json_array";
}
// Netscape 格式:包含制表符分隔的行,通常以域名或注释行开头
const lines = trimmed.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#"));
if (lines.length > 0 && lines[0].includes("\t") && lines[0].split("\t").length >= 6) {
return "netscape";
}
// Raw header 格式:单行或多行 key=value; 结构
if (trimmed.includes("=")) {
return "raw_header";
}
throw new Error("无法识别 Cookie 格式。支持的格式:JSON 数组、raw header(key=value; ...)、Netscape/curl 格式。");
}
/**
* 解析 JSON 数组格式的 Cookie(Cookie-Editor 导出格式)。
*
* 期望格式示例:
* ```json
* [{"name":"session","value":"abc","domain":".example.com","path":"/"}]
* ```
*
* @param input - JSON 字符串
* @returns 标准化的 CookieEntry 数组
* @throws JSON 解析失败或格式不匹配时抛出异常
*/
export function parseJsonArray(input: string): CookieEntry[] {
let parsed: unknown[];
try {
parsed = JSON.parse(input.trim());
} catch {
throw new Error("JSON 解析失败,请检查输入是否为合法的 JSON 数组。");
}
if (!Array.isArray(parsed)) {
throw new Error("输入不是 JSON 数组。");
}
if (parsed.length === 0) {
throw new Error("Cookie 数组为空。");
}
return parsed.map((item, idx) => {
const obj = item as Record<string, unknown>;
if (!obj.name || !obj.value || !obj.domain) {
throw new Error(`第 idx + 1 条 Cookie 缺少必要字段(name/value/domain)。`);
}
return {
name: String(obj.name),
value: String(obj.value),
domain: String(obj.domain),
path: String(obj.path ?? "/"),
expires: obj.expirationDate != null ? Number(obj.expirationDate) : (obj.expires != null ? Number(obj.expires) : undefined),
httpOnly: obj.httpOnly === true,
secure: obj.secure === true,
sameSite: normalizeSameSite(obj.sameSite),
};
});
}
/**
* 解析 raw header 格式的 Cookie。
*
* 格式示例:`session=abc; token=xyz; lang=en`
*
* raw header 格式不包含 domain 等元数据,调用方需额外提供 domain。
*
* @param input - raw header 字符串
* @param domain - 目标域名,如 `.bilibili.com`
* @returns 标准化的 CookieEntry 数组
* @throws 解析失败时抛出异常
*/
export function parseRawHeader(input: string, domain: string = ""): CookieEntry[] {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Cookie header 内容为空。");
}
const pairs = trimmed.split(";").map((p) => p.trim()).filter(Boolean);
if (pairs.length === 0) {
throw new Error("未找到任何 Cookie 键值对。");
}
return pairs.map((pair) => {
const eqIndex = pair.indexOf("=");
if (eqIndex <= 0) {
throw new Error(`Cookie 格式错误: "pair" 不是合法的 key=value 格式。`);
}
return {
name: pair.substring(0, eqIndex).trim(),
value: pair.substring(eqIndex + 1).trim(),
domain,
path: "/",
};
});
}
/**
* 解析 Netscape/curl 格式的 Cookie。
*
* 每行 7 个制表符分隔的字段:
* domain flag path secure expires name value
*
* @param input - Netscape 格式字符串
* @returns 标准化的 CookieEntry 数组
* @throws 解析失败时抛出异常
*/
export function parseNetscape(input: string): CookieEntry[] {
const lines = input.trim().split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
if (lines.length === 0) {
throw new Error("Netscape Cookie 文件为空。");
}
return lines.map((line, idx) => {
const fields = line.split("\t");
if (fields.length < 7) {
throw new Error(`第 idx + 1 行格式错误:期望至少 7 个制表符分隔字段,实际 fields.length 个。`);
}
const [domain, , path, secure, expires, name, value] = fields;
return {
name: name.trim(),
value: value.trim(),
domain: domain.trim(),
path: path.trim() || "/",
secure: secure.trim().toUpperCase() === "TRUE",
expires: expires.trim() !== "0" ? Number(expires.trim()) : undefined,
};
});
}
/**
* 统一入口:自动识别格式并解析 Cookie。
* @param input - 用户提供的原始 Cookie 文本
* @param defaultDomain - raw header 格式时使用的默认域名
* @returns 解析结果,包含格式、Cookie 数组和原始 header 字符串
* @throws 识别或解析失败时抛出异常
*/
export function parseCookies(
input: string,
defaultDomain: string = ""
): { format: CookieFormat; cookies: CookieEntry[]; rawHeader: string } {
const format = detectFormat(input);
let cookies: CookieEntry[];
switch (format) {
case "json_array":
cookies = parseJsonArray(input);
break;
case "raw_header":
cookies = parseRawHeader(input, defaultDomain);
break;
case "netscape":
cookies = parseNetscape(input);
break;
}
const rawHeader = cookies.map((c) => `c.name=c.value`).join("; ");
return { format, cookies, rawHeader };
}
/**
* 标准化 sameSite 值。
* @param value - 原始 sameSite 值
* @returns 标准化后的值
*/
function normalizeSameSite(value: unknown): "Strict" | "Lax" | "None" | undefined {
if (value == null) return undefined;
const str = String(value).toLowerCase();
switch (str) {
case "strict": return "Strict";
case "lax": return "Lax";
case "none": return "None";
case "no_restriction": return "None";
case "unspecified": return undefined;
default: return undefined;
}
}
// CLI 入口
if (process.argv[1]?.replace(/\\/g, "/").endsWith("scripts/cookie-parser.ts")) {
const input = process.argv[2];
const domain = process.argv[3] || "";
if (!input) {
console.log("用法: npx tsx scripts/cookie-parser.ts <cookie-string> [domain]");
process.exit(1);
}
try {
const result = parseCookies(input, domain);
console.log(JSON.stringify({
format: result.format,
cookieCount: result.cookies.length,
domains: [...new Set(result.cookies.map((c) => c.domain))],
names: result.cookies.map((c) => c.name),
}, null, 2));
} catch (err) {
console.error(`解析失败: (err as Error).message`);
process.exit(1);
}
}
FILE:scripts/fingerprint-manager.ts
/**
* SocialVault 浏览器指纹管理模块
*
* 采集、存储、恢复浏览器指纹。首次导入账号时记录关联的浏览器环境参数,
* 每次使用该账号操作时自动配置 OpenClaw browser profile 以匹配首次环境。
*
* 运行:npx tsx scripts/fingerprint-manager.ts <command> [args]
*/
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import type { BrowserFingerprint } from "./types.js";
/** 默认指纹值,模拟常见的 Windows Chrome 环境 */
const DEFAULT_FINGERPRINT: BrowserFingerprint = {
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
viewport: { width: 1920, height: 1080 },
locale: "en-US",
timezone: "America/New_York",
platform: "Win32",
deviceScaleFactor: 1,
colorScheme: "light",
capturedAt: new Date().toISOString(),
capturedFrom: "cookie_paste_infer",
};
/**
* 从 Cookie-Editor JSON 数据中推断浏览器指纹。
* Cookie-Editor 导出的 JSON 不直接包含指纹信息,但可以从 Cookie 域名推断地域设置。
* @param cookies - Cookie-Editor 导出的 JSON 数组
* @param userAgent - 可选的 User-Agent 字符串
* @returns 推断的浏览器指纹
*/
export function inferFromCookies(
cookies: Array<{ name: string; value: string; domain: string }>,
userAgent?: string
): BrowserFingerprint {
const fingerprint: BrowserFingerprint = {
...DEFAULT_FINGERPRINT,
capturedAt: new Date().toISOString(),
capturedFrom: "cookie_paste_infer",
};
if (userAgent) {
fingerprint.userAgent = userAgent;
}
// 从 Cookie 域名推断区域设置
const domains = cookies.map((c) => c.domain.toLowerCase());
const hasCNDomain = domains.some((d) =>
d.includes(".cn") || d.includes("xiaohongshu") || d.includes("weibo") || d.includes("bilibili")
);
const hasJPDomain = domains.some((d) => d.includes(".jp") || d.includes(".co.jp"));
if (hasCNDomain) {
fingerprint.locale = "zh-CN";
fingerprint.timezone = "Asia/Shanghai";
} else if (hasJPDomain) {
fingerprint.locale = "ja-JP";
fingerprint.timezone = "Asia/Tokyo";
}
return fingerprint;
}
/**
* 创建手动指定的浏览器指纹。
* @param params - 用户指定的指纹参数(部分字段可选,使用默认值填充)
* @returns 完整的浏览器指纹
*/
export function createFingerprint(params: Partial<BrowserFingerprint>): BrowserFingerprint {
return {
...DEFAULT_FINGERPRINT,
...params,
capturedAt: new Date().toISOString(),
capturedFrom: params.capturedFrom ?? "manual",
};
}
/**
* 将指纹保存到文件。
* @param vaultDir - vault 数据目录
* @param accountId - 关联的账号 ID
* @param fingerprint - 浏览器指纹
* @returns 指纹文件名
*/
export function saveFingerprint(
vaultDir: string,
accountId: string,
fingerprint: BrowserFingerprint
): string {
const fileName = `accountId.json`;
const filePath = join(vaultDir, "fingerprints", fileName);
try {
writeFileSync(filePath, JSON.stringify(fingerprint, null, 2), "utf-8");
} catch (err) {
throw new Error(`指纹文件保存失败: (err as Error).message`);
}
return fileName;
}
/**
* 加载指定账号的指纹。
* @param vaultDir - vault 数据目录
* @param accountId - 账号 ID
* @returns 浏览器指纹,文件不存在时返回 null
*/
export function loadFingerprint(
vaultDir: string,
accountId: string
): BrowserFingerprint | null {
const filePath = join(vaultDir, "fingerprints", `accountId.json`);
if (!existsSync(filePath)) {
return null;
}
try {
const content = readFileSync(filePath, "utf-8");
return JSON.parse(content) as BrowserFingerprint;
} catch {
return null;
}
}
/**
* 删除指定账号的指纹文件。
* @param vaultDir - vault 数据目录
* @param accountId - 账号 ID
* @returns 是否成功删除
*/
export function deleteFingerprint(vaultDir: string, accountId: string): boolean {
const filePath = join(vaultDir, "fingerprints", `accountId.json`);
if (!existsSync(filePath)) {
return false;
}
try {
unlinkSync(filePath);
return true;
} catch {
return false;
}
}
/**
* 生成 OpenClaw browser profile 配置命令序列。
* Agent 在 SKILL.md 中使用这些命令配置 browser 工具的设备参数。
* @param fingerprint - 浏览器指纹
* @param profileName - browser profile 名称
* @returns 配置命令数组
*/
export function generateBrowserCommands(
fingerprint: BrowserFingerprint,
profileName: string
): string[] {
return [
`browser set profile "profileName"`,
`browser set user-agent "fingerprint.userAgent"`,
`browser set viewport fingerprint.viewport.width fingerprint.viewport.height`,
`browser set locale "fingerprint.locale"`,
`browser set timezone "fingerprint.timezone"`,
`browser set device-scale-factor fingerprint.deviceScaleFactor`,
...(fingerprint.colorScheme ? [`browser set color-scheme "fingerprint.colorScheme"`] : []),
];
}
// CLI 入口
if (process.argv[1]?.replace(/\\/g, "/").endsWith("scripts/fingerprint-manager.ts")) {
const command = process.argv[2];
const vaultDir = process.argv[3] || join(process.cwd(), "vault");
const accountId = process.argv[4] || "";
switch (command) {
case "load": {
if (!accountId) {
console.error("用法: npx tsx scripts/fingerprint-manager.ts load <vault-dir> <account-id>");
process.exit(1);
}
const fp = loadFingerprint(vaultDir, accountId);
if (fp) {
console.log(JSON.stringify(fp, null, 2));
} else {
console.log(`未找到账号 accountId 的指纹文件。`);
}
break;
}
case "commands": {
if (!accountId) {
console.error("用法: npx tsx scripts/fingerprint-manager.ts commands <vault-dir> <account-id>");
process.exit(1);
}
const fp = loadFingerprint(vaultDir, accountId);
if (fp) {
const commands = generateBrowserCommands(fp, `sv-accountId`);
for (const cmd of commands) {
console.log(cmd);
}
} else {
console.log(`未找到账号 accountId 的指纹文件。`);
}
break;
}
default:
console.log("用法: npx tsx scripts/fingerprint-manager.ts <load|commands> <vault-dir> <account-id>");
}
}
FILE:scripts/health-check.ts
/**
* SocialVault 健康检查模块
*
* 遍历所有非 expired 账号,加载适配器配置、更新状态。
* 失效账号推送告警,临近过期账号推送预警。
*
* 本模块仅处理本地文件 I/O(accounts.json、适配器文件)。
* 不导入任何网络请求模块。网络验证通过 verifier 回调注入,
* 由调用方(CLI 入口或 Agent)负责提供。
*
* 通过 OpenClaw Cron 每 6 小时调用: npx tsx scripts/health-check.ts [vault-dir]
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import type { AccountsStore, AccountMeta, HealthCheckResult, AccountStatus, VaultEntry } from "./types.js";
import { getCredentials, clearCredentials } from "./vault-crypto.js";
const DEFAULT_WARN_DAYS = 3;
/** API 验证器函数签名,由调用方注入 */
export type ApiVerifier = (
endpoint: string,
successIndicator: string,
credential: VaultEntry
) => Promise<{ status: AccountStatus; message: string }>;
/**
* 读取 accounts.json。
* @param vaultDir - vault 数据目录
* @returns 账号存储对象
* @throws 文件不存在或格式错误时抛出异常
*/
export function loadAccounts(vaultDir: string): AccountsStore {
const filePath = join(vaultDir, "accounts.json");
if (!existsSync(filePath)) {
return { accounts: [] };
}
try {
const content = readFileSync(filePath, "utf-8");
return JSON.parse(content) as AccountsStore;
} catch {
throw new Error("accounts.json 文件格式错误或读取失败。");
}
}
/**
* 保存 accounts.json。
* @param vaultDir - vault 数据目录
* @param store - 账号存储对象
*/
export function saveAccounts(vaultDir: string, store: AccountsStore): void {
const filePath = join(vaultDir, "accounts.json");
try {
writeFileSync(filePath, JSON.stringify(store, null, 2), "utf-8");
} catch (err) {
throw new Error(`accounts.json 保存失败: (err as Error).message`);
}
}
/**
* 添加或更新一个账号的元数据。
* @param vaultDir - vault 数据目录
* @param account - 账号元数据
*/
export function upsertAccount(vaultDir: string, account: AccountMeta): void {
const store = loadAccounts(vaultDir);
const idx = store.accounts.findIndex((a) => a.id === account.id);
if (idx >= 0) {
store.accounts[idx] = account;
} else {
store.accounts.push(account);
}
saveAccounts(vaultDir, store);
}
/**
* 从 accounts.json 删除指定账号。
* @param vaultDir - vault 数据目录
* @param accountId - 账号 ID
* @returns 是否成功删除
*/
export function removeAccount(vaultDir: string, accountId: string): boolean {
const store = loadAccounts(vaultDir);
const prevLen = store.accounts.length;
store.accounts = store.accounts.filter((a) => a.id !== accountId);
if (store.accounts.length < prevLen) {
saveAccounts(vaultDir, store);
return true;
}
return false;
}
/**
* 读取适配器文件的 frontmatter 中的 session_check 配置。
* 如果账号使用 cookie_paste 认证且适配器定义了 session_check_cookie,
* 优先使用 cookie 专用的检查配置。
* @param adapterPath - 适配器文件路径(相对于 skill 根目录)
* @param skillDir - skill 根目录
* @param authMethod - 账号的认证方式,用于选择合适的检查配置
* @returns session_check 配置
*/
export function loadAdapterSessionCheck(
adapterPath: string,
skillDir: string,
authMethod?: string
): { method: string; endpoint: string; successIndicator: string } {
const fullPath = join(skillDir, adapterPath);
if (!existsSync(fullPath)) {
throw new Error(`适配器文件不存在: adapterPath`);
}
const content = readFileSync(fullPath, "utf-8");
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!fmMatch) {
throw new Error(`适配器文件 adapterPath 缺少 YAML frontmatter。`);
}
const fm = fmMatch[1];
if (authMethod === "cookie_paste") {
const cookieSection = fm.match(/session_check_cookie:\s*\n((?:\s+\S.*\n)*)/);
if (cookieSection) {
const section = cookieSection[1];
const m = section.match(/method:\s*["']?(\w+)["']?/);
const e = section.match(/endpoint:\s*["']?([^"'\n]+)["']?/);
const s = section.match(/success_indicator:\s*["']?([^"'\n]+)["']?/);
if (m && e && s) {
return {
method: m[1],
endpoint: e[1].trim(),
successIndicator: s[1].trim(),
};
}
}
}
const sessionSection = fm.match(/session_check:\s*\n((?:\s+\S.*\n)*)/);
if (sessionSection) {
const section = sessionSection[1];
const m = section.match(/method:\s*["']?(\w+)["']?/);
const e = section.match(/endpoint:\s*["']?([^"'\n]+)["']?/);
const s = section.match(/success_indicator:\s*["']?([^"'\n]+)["']?/);
if (m && e && s) {
return {
method: m[1],
endpoint: e[1].trim(),
successIndicator: s[1].trim(),
};
}
}
const methodMatch = fm.match(/method:\s*["']?(\w+)["']?/);
const endpointMatch = fm.match(/endpoint:\s*["']?([^"'\n]+)["']?/);
const indicatorMatch = fm.match(/success_indicator:\s*["']?([^"'\n]+)["']?/);
if (!methodMatch || !endpointMatch || !indicatorMatch) {
throw new Error(`适配器文件 adapterPath 的 session_check 配置不完整。`);
}
return {
method: methodMatch[1],
endpoint: endpointMatch[1].trim(),
successIndicator: indicatorMatch[1].trim(),
};
}
/**
* 检查账号是否临近过期并生成预警。
* @param account - 账号元数据
* @param warnDays - 提前预警天数
* @returns 是否需要预警
*/
export function isNearingExpiry(account: AccountMeta, warnDays: number = DEFAULT_WARN_DAYS): boolean {
if (!account.estimatedExpiry) return false;
const expiryDate = new Date(account.estimatedExpiry);
const now = new Date();
const daysLeft = (expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
return daysLeft > 0 && daysLeft <= warnDays;
}
/**
* 计算距过期还剩多少天。
* @param estimatedExpiry - ISO 8601 过期时间
* @returns 剩余天数(负数表示已过期)
*/
export function daysUntilExpiry(estimatedExpiry: string): number {
const expiry = new Date(estimatedExpiry);
const now = new Date();
return Math.round((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
}
/**
* 对所有非 expired 账号执行健康检查。
* 网络验证通过 apiVerifier 回调注入,本函数不直接发起任何网络请求。
* @param vaultDir - vault 数据目录
* @param skillDir - skill 根目录(用于定位适配器文件)
* @param apiVerifier - API 验证回调(由调用方从 session-verifier.ts 注入)
* @returns 所有检查结果
*/
export async function runHealthCheck(
vaultDir: string,
skillDir: string,
apiVerifier?: ApiVerifier
): Promise<HealthCheckResult[]> {
const store = loadAccounts(vaultDir);
const results: HealthCheckResult[] = [];
const now = new Date().toISOString();
for (const account of store.accounts) {
if (account.status === "expired") {
continue;
}
const result: HealthCheckResult = {
accountId: account.id,
platform: account.platform,
previousStatus: account.status,
currentStatus: "unknown",
displayName: account.displayName,
checkedAt: now,
};
try {
if (!account.adapter) {
result.currentStatus = "unknown";
result.message = "账号未关联适配器文件,无法执行验证。";
results.push(result);
continue;
}
const sessionCheck = loadAdapterSessionCheck(account.adapter, skillDir, account.authMethod);
const credential = getCredentials(vaultDir, account.id);
if (!credential) {
result.currentStatus = "expired";
result.message = "未找到凭证数据。";
} else if (sessionCheck.method === "api" && apiVerifier) {
const verification = await apiVerifier(
sessionCheck.endpoint,
sessionCheck.successIndicator,
credential
);
result.currentStatus = verification.status;
result.message = verification.message;
clearCredentials(credential);
} else if (sessionCheck.method === "api" && !apiVerifier) {
result.currentStatus = account.status;
result.message = "API 验证器未注入,保持当前状态。";
} else {
result.currentStatus = account.status;
result.message = "Browser 验证方式需通过 Agent 执行,跳过自动检查。";
}
} catch (err) {
result.currentStatus = "unknown";
result.message = `检查异常: (err as Error).message`;
}
account.status = result.currentStatus;
account.lastValidatedAt = now;
if (account.status === "healthy" && isNearingExpiry(account)) {
const days = daysUntilExpiry(account.estimatedExpiry!);
result.message = `登录态有效,但预计 days 天后过期,建议尽快更新。`;
}
results.push(result);
}
saveAccounts(vaultDir, store);
return results;
}
/**
* 格式化健康检查结果为可读报告。
* @param results - 检查结果数组
* @returns 格式化的报告文本
*/
export function formatReport(results: HealthCheckResult[]): string {
if (results.length === 0) {
return "没有需要检查的账号。";
}
const statusIcons: Record<AccountStatus, string> = {
healthy: "✅",
degraded: "⚠️",
expired: "❌",
unknown: "❓",
};
const lines = ["[SocialVault] 账号健康检查报告", `检查时间: new Date().toLocaleString()`, ""];
const expired = results.filter((r) => r.currentStatus === "expired");
const degraded = results.filter((r) => r.currentStatus === "degraded");
const healthy = results.filter((r) => r.currentStatus === "healthy");
const unknown = results.filter((r) => r.currentStatus === "unknown");
if (expired.length > 0) {
lines.push("🚨 失效账号:");
for (const r of expired) {
lines.push(` statusIcons.expired r.displayName (r.platform) - r.message || "已失效"`);
}
lines.push("");
}
if (degraded.length > 0) {
lines.push("⚠️ 异常账号:");
for (const r of degraded) {
lines.push(` statusIcons.degraded r.displayName (r.platform) - r.message || "状态异常"`);
}
lines.push("");
}
if (healthy.length > 0) {
lines.push("✅ 正常账号:");
for (const r of healthy) {
lines.push(` statusIcons.healthy r.displayName (r.platform)""`);
}
lines.push("");
}
if (unknown.length > 0) {
lines.push("❓ 未知状态:");
for (const r of unknown) {
lines.push(` statusIcons.unknown r.displayName (r.platform) - r.message || "无法确认"`);
}
lines.push("");
}
lines.push(`总计: results.length 个账号 | ✅ healthy.length | ⚠️ degraded.length | ❌ expired.length | ❓ unknown.length`);
return lines.join("\n");
}
FILE:scripts/qrcode-server.ts
/**
* SocialVault 扫码登录中转服务
*
* 在 VPS 上通过 headless browser 打开平台登录页,生成二维码,
* 在 Agent 对话中直接展示给用户扫码,完成登录后自动捕获 Cookie。
*
* 此模块提供扫码流程的数据管理和状态追踪。
* 实际的 browser 操作由 Agent 通过 OpenClaw 内置 browser 工具执行,无需额外凭证。
*
* 运行:npx tsx scripts/qrcode-server.ts <command> [args]
*/
import { randomBytes } from "node:crypto";
import { writeFileSync, readFileSync, existsSync, unlinkSync } from "node:fs";
import { join } from "node:path";
/** 扫码会话状态 */
export type QRSessionStatus = "pending" | "scanned" | "confirmed" | "expired" | "failed";
/**
* 扫码会话。
* 注意:捕获的 Cookie 不存储在会话文件中,而是由 Agent 在扫码成功后
* 直接通过 vault-crypto 加密存储到 vault.enc,确保凭证不以明文形式落盘。
*/
export interface QRSession {
sessionId: string;
token: string;
platform: string;
accountId?: string;
status: QRSessionStatus;
loginUrl: string;
createdAt: string;
expiresAt: string;
qrImagePath?: string;
}
const SESSION_EXPIRY_MS = 5 * 60 * 1000; // 5 分钟
/**
* 创建一个新的扫码登录会话。
* @param platform - 平台标识
* @param loginUrl - 平台登录页 URL
* @param vaultDir - vault 数据目录
* @returns 新创建的 QR 会话
*/
export function createSession(
platform: string,
loginUrl: string,
vaultDir: string
): QRSession {
const sessionId = randomBytes(16).toString("hex");
const token = randomBytes(32).toString("hex");
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_EXPIRY_MS);
const session: QRSession = {
sessionId,
token,
platform,
status: "pending",
loginUrl,
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
};
const filePath = join(vaultDir, `qr-session-sessionId.json`);
try {
writeFileSync(filePath, JSON.stringify(session, null, 2), "utf-8");
} catch (err) {
throw new Error(`扫码会话创建失败: (err as Error).message`);
}
return session;
}
/**
* 加载扫码会话。
* @param sessionId - 会话 ID
* @param vaultDir - vault 数据目录
* @returns 会话对象,不存在时返回 null
*/
export function loadSession(sessionId: string, vaultDir: string): QRSession | null {
const filePath = join(vaultDir, `qr-session-sessionId.json`);
if (!existsSync(filePath)) {
return null;
}
try {
const content = readFileSync(filePath, "utf-8");
return JSON.parse(content) as QRSession;
} catch {
return null;
}
}
/**
* 更新扫码会话状态。
* @param sessionId - 会话 ID
* @param vaultDir - vault 数据目录
* @param updates - 要更新的字段
* @returns 更新后的会话,不存在时返回 null
*/
export function updateSession(
sessionId: string,
vaultDir: string,
updates: Partial<QRSession>
): QRSession | null {
const session = loadSession(sessionId, vaultDir);
if (!session) return null;
Object.assign(session, updates);
const filePath = join(vaultDir, `qr-session-sessionId.json`);
try {
writeFileSync(filePath, JSON.stringify(session, null, 2), "utf-8");
} catch (err) {
throw new Error(`扫码会话更新失败: (err as Error).message`);
}
return session;
}
/**
* 验证一次性 token。
* @param sessionId - 会话 ID
* @param token - 一次性 token
* @param vaultDir - vault 数据目录
* @returns 验证结果
*/
export function validateToken(
sessionId: string,
token: string,
vaultDir: string
): { valid: boolean; reason?: string } {
const session = loadSession(sessionId, vaultDir);
if (!session) {
return { valid: false, reason: "会话不存在。" };
}
if (session.token !== token) {
return { valid: false, reason: "Token 无效。" };
}
if (new Date() > new Date(session.expiresAt)) {
updateSession(sessionId, vaultDir, { status: "expired" });
return { valid: false, reason: "会话已过期。" };
}
if (session.status === "confirmed") {
return { valid: false, reason: "Token 已使用。" };
}
return { valid: true };
}
/**
* 检查会话是否已过期。
* @param session - QR 会话
* @returns 是否已过期
*/
export function isSessionExpired(session: QRSession): boolean {
return new Date() > new Date(session.expiresAt);
}
/**
* 清理已完成或过期的扫码会话文件。
* @param sessionId - 会话 ID
* @param vaultDir - vault 数据目录
*/
export function cleanupSession(sessionId: string, vaultDir: string): void {
const sessionFile = join(vaultDir, `qr-session-sessionId.json`);
if (existsSync(sessionFile)) {
try {
unlinkSync(sessionFile);
} catch {
// 忽略删除失败
}
}
}
/**
* 获取平台的登录页 URL。
* @param platform - 平台标识
* @returns 登录页 URL
*/
export function getLoginUrl(platform: string): string {
const urls: Record<string, string> = {
xiaohongshu: "https://www.xiaohongshu.com",
weibo: "https://weibo.com/login.php",
bilibili: "https://passport.bilibili.com/login",
douyin: "https://www.douyin.com",
zhihu: "https://www.zhihu.com/signin",
};
return urls[platform] || `https://platform.com/login`;
}
// CLI 入口
if (process.argv[1]?.replace(/\\/g, "/").endsWith("scripts/qrcode-server.ts")) {
const command = process.argv[2];
const vaultDir = process.argv[3] || join(process.cwd(), "vault");
switch (command) {
case "create": {
const platform = process.argv[4];
if (!platform) {
console.error("用法: npx tsx scripts/qrcode-server.ts create <vault-dir> <platform>");
process.exit(1);
}
const loginUrl = getLoginUrl(platform);
const session = createSession(platform, loginUrl, vaultDir);
console.log(JSON.stringify({
sessionId: session.sessionId,
tokenPresent: true,
loginUrl: session.loginUrl,
expiresAt: session.expiresAt,
}, null, 2));
break;
}
case "status": {
const sessionId = process.argv[4];
if (!sessionId) {
console.error("用法: npx tsx scripts/qrcode-server.ts status <vault-dir> <session-id>");
process.exit(1);
}
const session = loadSession(sessionId, vaultDir);
if (session) {
const expired = isSessionExpired(session);
console.log(JSON.stringify({
status: expired ? "expired" : session.status,
platform: session.platform,
expiresAt: session.expiresAt,
}, null, 2));
} else {
console.log("会话不存在。");
}
break;
}
case "cleanup": {
const sessionId = process.argv[4];
if (!sessionId) {
console.error("用法: npx tsx scripts/qrcode-server.ts cleanup <vault-dir> <session-id>");
process.exit(1);
}
cleanupSession(sessionId, vaultDir);
console.log("会话已清理。");
break;
}
default:
console.log("用法: npx tsx scripts/qrcode-server.ts <create|status|cleanup> <vault-dir> [args]");
}
}
FILE:scripts/run-health-check.ts
/**
* SocialVault 健康检查 CLI 入口
*
* 此文件是唯一同时引用文件操作和网络验证的组装点。
* health-check.ts 仅做文件 I/O,session-verifier.ts 仅做网络请求。
*
* 用法: npx tsx scripts/run-health-check.ts [vault-dir] [skill-dir]
*/
import { resolve } from "node:path";
import { runHealthCheck, formatReport } from "./health-check.js";
import { verifyViaApi } from "./session-verifier.js";
const vaultDir = resolve(process.argv[2] || "vault");
const skillDir = resolve(process.argv[3] || ".");
runHealthCheck(vaultDir, skillDir, verifyViaApi).then((results) => {
const report = formatReport(results);
console.log(report);
const hasIssues = results.some(
(r) => r.currentStatus === "expired" || r.currentStatus === "degraded"
);
if (hasIssues) {
process.exit(1);
}
}).catch((err) => {
console.error(`健康检查失败: (err as Error).message`);
process.exit(2);
});
FILE:scripts/session-verifier.ts
/**
* SocialVault 登录态验证模块
*
* 独立处理网络请求验证。与 health-check.ts 分离以确保
* 文件 I/O 和网络请求不在同一模块中混合。
*
* 安全机制:
* - 内置域名白名单,仅允许向已知的官方社交平台域名发送请求
* - 拒绝向白名单外的任何端点发送认证头或 Cookie
* - 白名单不可通过适配器文件修改
*/
import type { AccountStatus, VaultEntry } from "./types.js";
/**
* 受信任的社交平台官方域名白名单。
* 仅这些域名允许接收认证头。
* 新增平台时需在此处显式添加域名。
*/
const TRUSTED_DOMAINS: readonly string[] = [
"xiaohongshu.com",
"www.xiaohongshu.com",
"edith.xiaohongshu.com",
"bilibili.com",
"www.bilibili.com",
"api.bilibili.com",
"space.bilibili.com",
"passport.bilibili.com",
"weibo.com",
"www.weibo.com",
"api.weibo.com",
"douyin.com",
"www.douyin.com",
"zhihu.com",
"www.zhihu.com",
"tieba.baidu.com",
"baidu.com",
"www.baidu.com",
];
/**
* 校验端点 URL 的域名是否在受信任白名单中。
* @param endpoint - 要验证的端点 URL
* @returns 校验结果
*/
export function validateEndpointDomain(endpoint: string): { trusted: boolean; domain: string } {
let url: URL;
try {
url = new URL(endpoint);
} catch {
return { trusted: false, domain: endpoint };
}
const hostname = url.hostname.toLowerCase();
const trusted = TRUSTED_DOMAINS.some((d) => hostname === d || hostname.endsWith(`.d`));
return { trusted, domain: hostname };
}
/**
* 通过 API 方式验证账号登录态。
* 使用凭证中的 access_token 或 cookies 发起 HTTP 请求到平台验证端点。
*
* 安全约束:仅向 TRUSTED_DOMAINS 白名单中的域名发送认证头。
* 如果端点不在白名单中,拒绝请求并返回错误。
*
* @param endpoint - 验证端点 URL(来自适配器 session_check 配置)
* @param successIndicator - 成功判定关键字
* @param credential - 凭证条目(仅提取认证头,不传输完整凭证)
* @returns 验证结果状态和消息
*/
export async function verifyViaApi(
endpoint: string,
successIndicator: string,
credential: VaultEntry
): Promise<{ status: AccountStatus; message: string }> {
const domainCheck = validateEndpointDomain(endpoint);
if (!domainCheck.trusted) {
return {
status: "unknown",
message: `安全拒绝:端点域名 "domainCheck.domain" 不在受信任白名单中。请检查适配器的 session_check 配置。`,
};
}
try {
const headers: Record<string, string> = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Cache-Control": "max-age=0",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"Sec-Ch-Ua": "\"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"",
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": "\"Windows\"",
};
if (credential.authMethod === "api_token" && credential.accessToken) {
headers["Authorization"] = `Bearer credential.accessToken`;
headers["User-Agent"] = "SocialVault/0.1.0";
} else if (credential.cookies || credential.rawCookieHeader) {
const cookieHeader = credential.rawCookieHeader
|| credential.cookies!.map((c) => `c.name=c.value`).join("; ");
headers["Cookie"] = cookieHeader;
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
} else {
return { status: "expired", message: "凭证信息不完整,无法验证。" };
}
const response = await fetch(endpoint, { headers, redirect: "follow" });
if (!response.ok) {
const errBody = await response.text().catch(() => "");
const bodyHint = errBody.slice(0, 200);
if (response.status === 401 || response.status === 403) {
return { status: "expired", message: `验证失败:HTTP response.status,登录态已失效。${bodyHint` : ""}` };
}
return { status: "degraded", message: `验证异常:HTTP response.status。${bodyHint` : ""}` };
}
const body = await response.text();
if (body.includes(successIndicator)) {
return { status: "healthy", message: "登录态有效。" };
}
return { status: "degraded", message: `验证响应中未包含预期标志 "successIndicator"。` };
} catch (err) {
return { status: "unknown", message: `验证请求失败: (err as Error).message` };
}
}
FILE:scripts/types.ts
/**
* SocialVault 核心类型定义
*/
/** 单个 Cookie 条目 */
export interface CookieEntry {
name: string;
value: string;
domain: string;
path: string;
expires?: number;
httpOnly?: boolean;
secure?: boolean;
sameSite?: "Strict" | "Lax" | "None";
}
/** 认证方式 */
export type AuthMethod = "cookie_paste" | "api_token" | "qrcode";
/** 账号状态 */
export type AccountStatus = "healthy" | "degraded" | "expired" | "unknown";
/** 存储在 vault.enc 中的单个凭证条目(解密后) */
export interface VaultEntry {
accountId: string;
authMethod: AuthMethod;
cookies?: CookieEntry[];
rawCookieHeader?: string;
accessToken?: string;
refreshToken?: string;
tokenExpiresAt?: string;
clientId?: string;
clientSecret?: string;
updatedAt: string;
}
/** accounts.json 中的单个账号元数据 */
export interface AccountMeta {
id: string;
platform: string;
adapter: string;
authMethod: AuthMethod;
displayName: string;
profileUrl?: string;
createdAt: string;
lastValidatedAt: string;
lastRefreshedAt?: string;
status: AccountStatus;
estimatedExpiry?: string;
fingerprintFile?: string;
browserProfile?: string;
tags?: string[];
}
/** accounts.json 文件结构 */
export interface AccountsStore {
accounts: AccountMeta[];
}
/** 浏览器指纹 */
export interface BrowserFingerprint {
userAgent: string;
viewport: { width: number; height: number };
locale: string;
timezone: string;
platform: string;
deviceScaleFactor: number;
colorScheme?: "light" | "dark";
capturedAt: string;
capturedFrom: "cookie_paste_infer" | "browser_profile" | "manual";
}
/** 适配器 frontmatter 中的认证方式定义 */
export interface AdapterAuthMethod {
type: AuthMethod;
priority: number;
label: string;
}
/** 适配器 frontmatter 中的 session 检查配置 */
export interface SessionCheckConfig {
method: "api" | "browser";
endpoint: string;
success_indicator: string;
}
/** 适配器 frontmatter 结构 */
export interface AdapterFrontmatter {
platform_id: string;
platform_name: string;
auth_methods: AdapterAuthMethod[];
capabilities: string[];
cookie_guide?: string;
session_check: SessionCheckConfig;
estimated_session_duration_days: number;
auto_refresh_supported: boolean;
rate_limits?: Record<string, number>;
}
/** Cookie 解析器识别的输入格式 */
export type CookieFormat = "json_array" | "raw_header" | "netscape";
/** 健康检查结果 */
export interface HealthCheckResult {
accountId: string;
platform: string;
previousStatus: AccountStatus;
currentStatus: AccountStatus;
displayName: string;
checkedAt: string;
message?: string;
}
FILE:scripts/vault-crypto.ts
/**
* SocialVault 加密存储模块
*
* 提供 AES-256-GCM 加密的凭证存储能力。
* 密钥本地随机生成,每次加密使用随机 96-bit IV。
*
* vault.enc 二进制格式:IV (12 bytes) || Auth Tag (16 bytes) || Ciphertext
*/
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
import { join } from "node:path";
import type { VaultEntry } from "./types.js";
const ALGORITHM = "aes-256-gcm";
const KEY_LENGTH = 32;
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const VAULT_FILE = "vault.enc";
const KEY_FILE = "vault-key";
/**
* 初始化 vault 目录,如果密钥不存在则生成新密钥。
* @param vaultDir - vault 数据目录的绝对路径
* @throws 目录创建失败时抛出异常
*/
export function init(vaultDir: string): void {
if (!existsSync(vaultDir)) {
mkdirSync(vaultDir, { recursive: true });
}
const fingerprintsDir = join(vaultDir, "fingerprints");
if (!existsSync(fingerprintsDir)) {
mkdirSync(fingerprintsDir, { recursive: true });
}
const keyPath = join(vaultDir, KEY_FILE);
if (!existsSync(keyPath)) {
const key = randomBytes(KEY_LENGTH);
try {
writeFileSync(keyPath, key);
} catch (err) {
throw new Error(`密钥文件创建失败: (err as Error).message`);
}
try {
chmodSync(keyPath, 0o600);
} catch {
// Windows 不支持 Unix 权限,忽略
}
}
const accountsPath = join(vaultDir, "accounts.json");
if (!existsSync(accountsPath)) {
try {
writeFileSync(accountsPath, JSON.stringify({ accounts: [] }, null, 2), "utf-8");
} catch (err) {
throw new Error(`accounts.json 初始化失败: (err as Error).message`);
}
}
}
/**
* 读取加密密钥。
* @param vaultDir - vault 数据目录
* @returns 32 字节密钥 Buffer
* @throws 密钥文件不存在或长度不正确时抛出异常
*/
function readKey(vaultDir: string): Buffer {
const keyPath = join(vaultDir, KEY_FILE);
if (!existsSync(keyPath)) {
throw new Error("密钥文件不存在,请先执行 init 初始化 vault。");
}
const key = readFileSync(keyPath);
if (key.length !== KEY_LENGTH) {
throw new Error(`密钥长度异常:期望 KEY_LENGTH 字节,实际 key.length 字节。`);
}
return key;
}
/**
* 将凭证条目数组加密后写入 vault.enc。
* @param vaultDir - vault 数据目录
* @param entries - 凭证条目数组
* @throws 加密或写入失败时抛出异常
*/
export function encrypt(vaultDir: string, entries: VaultEntry[]): void {
const key = readKey(vaultDir);
const iv = randomBytes(IV_LENGTH);
const plaintext = Buffer.from(JSON.stringify(entries), "utf-8");
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const authTag = cipher.getAuthTag();
// 格式:IV (12) || Auth Tag (16) || Ciphertext
const output = Buffer.concat([iv, authTag, encrypted]);
try {
writeFileSync(join(vaultDir, VAULT_FILE), output);
} catch (err) {
throw new Error(`vault.enc 写入失败: (err as Error).message`);
}
}
/**
* 读取 vault.enc 并解密还原为凭证条目数组。
* @param vaultDir - vault 数据目录
* @returns 解密后的凭证条目数组;vault.enc 不存在时返回空数组
* @throws 解密失败(密钥错误或数据损坏)时抛出异常
*/
export function decrypt(vaultDir: string): VaultEntry[] {
const vaultPath = join(vaultDir, VAULT_FILE);
if (!existsSync(vaultPath)) {
return [];
}
const key = readKey(vaultDir);
const data = readFileSync(vaultPath);
if (data.length < IV_LENGTH + AUTH_TAG_LENGTH + 1) {
throw new Error("vault.enc 文件格式无效:数据过短。");
}
const iv = data.subarray(0, IV_LENGTH);
const authTag = data.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = data.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
decipher.setAuthTag(authTag);
let decrypted: Buffer;
try {
decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
} catch {
throw new Error("解密失败:密钥错误或数据已损坏。");
}
return JSON.parse(decrypted.toString("utf-8")) as VaultEntry[];
}
/**
* 解密后获取指定账号的凭证。
* @param vaultDir - vault 数据目录
* @param accountId - 账号 ID
* @returns 对应凭证条目,未找到时返回 null
*/
export function getCredentials(vaultDir: string, accountId: string): VaultEntry | null {
const entries = decrypt(vaultDir);
const entry = entries.find((e) => e.accountId === accountId) ?? null;
// 清除其他账号的明文数据
for (const e of entries) {
if (e.accountId !== accountId) {
clearEntry(e);
}
}
return entry;
}
/**
* 将指定凭证条目的敏感字段在内存中置空。
* @param entry - 要清除的凭证条目
*/
function clearEntry(entry: VaultEntry): void {
if (entry.cookies) {
for (const c of entry.cookies) {
c.value = "";
}
entry.cookies = undefined;
}
if (entry.rawCookieHeader) entry.rawCookieHeader = "";
if (entry.accessToken) entry.accessToken = "";
if (entry.refreshToken) entry.refreshToken = "";
if (entry.clientId) entry.clientId = "";
if (entry.clientSecret) entry.clientSecret = "";
}
/**
* 清除指定账号在内存中的明文凭证。此函数用于操作完毕后的安全清理。
* @param entry - 要清除的凭证条目
*/
export function clearCredentials(entry: VaultEntry): void {
clearEntry(entry);
}
/**
* 添加或更新一个凭证条目。
* @param vaultDir - vault 数据目录
* @param newEntry - 新的凭证条目
*/
export function upsertCredential(vaultDir: string, newEntry: VaultEntry): void {
const entries = decrypt(vaultDir);
const idx = entries.findIndex((e) => e.accountId === newEntry.accountId);
if (idx >= 0) {
entries[idx] = newEntry;
} else {
entries.push(newEntry);
}
encrypt(vaultDir, entries);
}
/**
* 从 vault 中删除指定账号的凭证。
* @param vaultDir - vault 数据目录
* @param accountId - 要删除的账号 ID
*/
export function removeCredential(vaultDir: string, accountId: string): void {
const entries = decrypt(vaultDir);
const filtered = entries.filter((e) => e.accountId !== accountId);
encrypt(vaultDir, filtered);
}
/**
* 密钥轮换:用新密钥重新加密所有凭证。
* @param vaultDir - vault 数据目录
*/
export function rotateKey(vaultDir: string): void {
const entries = decrypt(vaultDir);
const keyPath = join(vaultDir, KEY_FILE);
const newKey = randomBytes(KEY_LENGTH);
try {
writeFileSync(keyPath, newKey);
} catch (err) {
throw new Error(`密钥轮换写入失败: (err as Error).message`);
}
try {
chmodSync(keyPath, 0o600);
} catch {
// Windows 不支持 Unix 权限
}
encrypt(vaultDir, entries);
for (const e of entries) {
clearEntry(e);
}
}
// CLI 入口:支持通过命令行调用基本操作
if (process.argv[1]?.replace(/\\/g, "/").endsWith("scripts/vault-crypto.ts")) {
const command = process.argv[2];
const vaultDir = process.argv[3] || join(process.cwd(), "vault");
switch (command) {
case "init":
init(vaultDir);
console.log(`Vault 已初始化: vaultDir`);
break;
case "rotate-key":
rotateKey(vaultDir);
console.log("密钥轮换完成。");
break;
default:
console.log("用法: npx tsx scripts/vault-crypto.ts <init|rotate-key> [vault-dir]");
}
}
FILE:guides/cookie-export-bilibili.md
# 哔哩哔哩 Cookie 导出教程
## 适用场景
Cookie 粘贴是使用哔哩哔哩的推荐方式。B站 Cookie 有效期约 30 天,在所有主流平台中相对较长。
## 前置要求
- 一台能打开浏览器的电脑
- Chrome 或 Firefox 浏览器
- 已登录的哔哩哔哩账号
## 方法一:使用 Cookie-Editor 插件(推荐)
### 第 1 步:安装 Cookie-Editor 插件
- Chrome:在 Chrome 应用商店搜索 "Cookie-Editor" 并安装
- Firefox:在 Firefox 附加组件中搜索 "Cookie-Editor" 并安装
### 第 2 步:打开 B站并确认登录
1. 在浏览器中打开 https://www.bilibili.com
2. 确认已登录(右上角能看到你的头像和昵称)
3. 如果未登录,点击右上角"登录",推荐使用手机扫码方式
### 第 3 步:导出 Cookie
1. 点击浏览器工具栏中的 Cookie-Editor 图标
2. 确认显示的是 bilibili.com 的 Cookie
3. 点击 "Export" → 选择 "Export as JSON"
4. Cookie 自动复制到剪贴板
### 第 4 步:粘贴给 SocialVault
将复制的内容粘贴给 SocialVault Agent 即可。Agent 会自动验证登录态。
## 方法二:使用开发者工具 Application 面板
### 第 1 步:打开开发者工具
1. 在 bilibili.com 页面按 `F12`
2. 切换到 "Application"(Chrome)或 "Storage"(Firefox)标签
### 第 2 步:找到关键 Cookie
在 Cookies → `https://www.bilibili.com` 下找到以下 Cookie:
| Cookie 名 | 说明 | 必需 |
|-----------|------|------|
| `SESSDATA` | 主会话令牌,URL 编码格式 | 是 |
| `bili_jct` | CSRF Token,写操作必需 | 是 |
| `DedeUserID` | 用户 UID | 是 |
### 第 3 步:快速复制
在 Console 标签中运行:
```
document.cookie
```
复制输出内容粘贴给 SocialVault。
## 方法三:从网络请求头中获取
当其他方法导入后验证失败时,可从网络请求中获取最完整的 Cookie:
1. 按 `F12` 打开开发者工具
2. 切换到 **"Network"**(网络)标签
3. 刷新 B站页面
4. 在过滤栏输入 `api.bilibili` 快速筛选
5. 找到任意一个发往 `api.bilibili.com` 的请求(推荐 `x/web-interface/nav`)
6. 在 "Headers" 中找到 `Cookie` 请求头
7. 右键 → 复制值
8. 粘贴给 SocialVault
## 注意事项
1. **有效期较长**:B站 Cookie 约 30 天有效,到期前 SocialVault 会提前提醒。
2. **不要退出登录**:导出 Cookie 后不要在浏览器中退出 B站登录。
3. **SESSDATA 编码**:`SESSDATA` 的值中包含 `%2C` 等 URL 编码字符,这是正常的,不要手动解码。
4. **bili_jct 必需**:即使只做读操作,也建议导出 `bili_jct`,以便后续写操作使用。
5. **多账号**:使用浏览器的隐身模式或不同 Profile 分别登录和导出。
FILE:guides/cookie-export-tieba.md
# 百度贴吧 Cookie 导出教程
## 适用场景
Cookie 粘贴是使用百度贴吧的推荐方式。`BDUSS` 有效期通常长达 6 个月以上,是所有平台中最长的。
## 前置要求
- 一台能打开浏览器的电脑
- Chrome 或 Firefox 浏览器
- 已登录的百度账号
## 方法一:从网络请求头中获取(推荐)
> **为什么推荐这种方式?** Cookie-Editor 等插件导出的是 `BDUSS_BFESS`(跨站安全版本),而贴吧 API 实际需要的是 `BDUSS`。两者值不同,使用 `BDUSS_BFESS` 会导致验证失败。从实际网络请求头中复制 Cookie 是最准确的方式。
### 第 1 步:打开开发者工具
1. 在浏览器中打开 https://tieba.baidu.com 并确认已登录
2. 按 `F12` 打开开发者工具
3. 切换到 **"Network"**(网络)标签
### 第 2 步:触发请求
1. 刷新页面(`F5` 或 `Ctrl+R`)
2. 等待页面加载完成
### 第 3 步:复制 Cookie
1. 在请求列表中找到任意一个发往 `tieba.baidu.com` 的请求
2. 点击该请求,在右侧面板中切换到 **"Headers"**(标头)标签
3. 向下滚动找到 **"Request Headers"**(请求标头)部分
4. 找到 `Cookie:` 字段
5. 右键点击 Cookie 值 → **复制值**(或全选后 `Ctrl+C`)
6. 粘贴给 SocialVault Agent
### 小贴士
- 复制的 Cookie 字符串中应包含 `BDUSS=...` 而不是 `BDUSS_BFESS=...`
- 如果请求太多,可以在 Network 标签的过滤栏输入 `tieba` 快速筛选
- 推荐选择 `tieba.baidu.com/f/user/json_userinfo` 等 API 请求,Cookie 最完整
## 方法二:使用开发者工具 Application 面板
### 第 1 步:打开开发者工具
1. 在 tieba.baidu.com 页面按 `F12`
2. 切换到 "Application"(Chrome)或 "Storage"(Firefox)标签
### 第 2 步:找到关键 Cookie
在 Cookies → `https://tieba.baidu.com` 下找到以下 Cookie:
| Cookie 名 | 说明 | 必需 |
|-----------|------|------|
| `BDUSS` | 百度核心认证 Cookie,很长的字符串 | 是 |
| `STOKEN` | 安全 Token,写操作需要 | 推荐 |
> **重要**:请确保复制的是 `BDUSS` 而非 `BDUSS_BFESS`。`BDUSS_BFESS` 是带 SameSite 属性的跨站版本,值与 `BDUSS` 不同,用于服务端验证会失败。
### 第 3 步:快速复制
在 Console 标签中运行:
```
document.cookie
```
复制输出内容粘贴给 SocialVault。
> **注意**:`document.cookie` 可能无法读取 HttpOnly 的 Cookie。如果输出中没有 `BDUSS`,请使用方法一从网络请求头获取。
## 方法三:使用 Cookie-Editor 插件
> **注意**:Cookie-Editor 导出的百度 Cookie 中通常只有 `BDUSS_BFESS` 而没有 `BDUSS`。`BDUSS_BFESS` 的值与 `BDUSS` 不同,直接使用会导致验证失败。**强烈建议使用方法一。**
### 第 1 步:安装 Cookie-Editor 插件
- Chrome:在 Chrome 应用商店搜索 "Cookie-Editor" 并安装
- Firefox:在 Firefox 附加组件中搜索 "Cookie-Editor" 并安装
### 第 2 步:打开贴吧并确认登录
1. 在浏览器中打开 https://tieba.baidu.com
2. 确认已登录(页面顶部能看到你的用户名和头像)
3. 如果未登录,点击"登录",使用手机号或百度账号密码登录
### 第 3 步:导出 Cookie
1. 点击浏览器工具栏中的 Cookie-Editor 图标
2. 确认显示的是 baidu.com 的 Cookie
3. 点击 "Export" → 选择 "Export as JSON"
4. Cookie 自动复制到剪贴板
### 第 4 步:粘贴给 SocialVault
将复制的内容粘贴给 SocialVault Agent。如果验证失败,请改用方法一。
## 常见问题
### BDUSS 和 BDUSS_BFESS 有什么区别?
| | `BDUSS` | `BDUSS_BFESS` |
|---|---------|---------------|
| **用途** | 百度核心认证 Cookie | 跨站安全版本(SameSite=None) |
| **值** | 原始 Cookie 值 | 不同的值,不可互换 |
| **获取方式** | 网络请求头、Application 面板 | Cookie-Editor 通常导出此项 |
| **API 验证** | 有效 | 无效 |
### 为什么 Cookie-Editor 导出的 Cookie 无法验证?
百度使用了两套 Cookie 机制:`BDUSS` 用于同站请求,`BDUSS_BFESS` 用于跨站请求。Cookie-Editor 插件在导出时通常只能获取到 `BDUSS_BFESS`,而 SocialVault 的 API 调用需要 `BDUSS`。这就是为什么推荐从网络请求头中获取完整 Cookie。
## 注意事项
1. **有效期极长**:`BDUSS` 通常有效 6 个月以上,几乎不需要更新。
2. **必须使用 BDUSS**:不要使用 `BDUSS_BFESS`,两者不可互换。推荐从网络请求头获取完整 Cookie。
3. **不要退出登录**:退出百度任何产品(贴吧、网盘、知道等)的登录都会使 BDUSS 失效。
4. **全平台共享**:`BDUSS` 是百度全平台通用的,同一个 Cookie 适用于所有百度产品。
5. **STOKEN 推荐导出**:虽然只读操作不需要 STOKEN,但回帖等写操作需要。
6. **多账号**:使用浏览器的隐身模式或不同 Profile 分别登录和导出。
FILE:guides/cookie-export-xiaohongshu.md
# 小红书 Cookie 导出教程
## 适用场景
Cookie 粘贴是在有 GUI 环境下使用小红书的推荐方式。如果你的服务器没有图形界面,可以使用扫码登录方式。
> 注意:小红书 Cookie 有效期约 7 天,比其他平台短。建议开启 SocialVault 的活跃续期功能自动延长有效期。
## 前置要求
- 一台能打开浏览器的电脑
- Chrome 或 Firefox 浏览器
- 已登录的小红书账号
## 方法一:从网络请求头中获取(推荐)
> **为什么推荐这种方式?** Cookie-Editor 等插件可能导出不完整的 Cookie,或者导出浏览器扩展附加的额外字段,导致服务端无法正确识别。从实际网络请求头中复制 Cookie 是最准确、最可靠的方式。
### 第 1 步:打开开发者工具
1. 在浏览器中打开 https://www.xiaohongshu.com 并确认已登录
2. 按 `F12` 打开开发者工具
3. 切换到 **"Network"**(网络)标签
### 第 2 步:触发请求
1. 刷新页面(`F5` 或 `Ctrl+R`)
2. 等待页面加载完成,左侧会出现大量请求
### 第 3 步:复制 Cookie
1. 在请求列表中找到任意一个发往 `www.xiaohongshu.com` 或 `edith.xiaohongshu.com` 的请求
2. 点击该请求,在右侧面板中切换到 **"Headers"**(标头)标签
3. 向下滚动找到 **"Request Headers"**(请求标头)部分
4. 找到 `Cookie:` 字段
5. 右键点击 Cookie 值 → **复制值**(或全选后 `Ctrl+C`)
6. 粘贴给 SocialVault Agent
### 小贴士
- 推荐找 `api` 开头的子域名请求(如 `edith.xiaohongshu.com`),这些请求的 Cookie 最完整
- 如果请求太多,可以在 Network 标签的过滤栏输入 `edith` 快速筛选
- 复制出来的是一长串 `key=value; key=value` 格式的字符串,直接粘贴即可
## 方法二:使用开发者工具 Application 面板
### 第 1 步:打开开发者工具
1. 在 xiaohongshu.com 页面按 `F12`
2. 切换到 "Application"(Chrome)或 "Storage"(Firefox)标签
### 第 2 步:找到关键 Cookie
在 Cookies → `https://www.xiaohongshu.com` 下找到以下 Cookie:
| Cookie 名 | 说明 | 必需 |
|-----------|------|------|
| `a1` | 用户标识,用于签名计算 | 是 |
| `web_session` | 会话令牌 | 是 |
| `webId` | 网页端 ID | 是 |
### 第 3 步:快速复制
在 Console 标签中运行:
```
document.cookie
```
复制输出内容粘贴给 SocialVault。
## 方法三:使用 Cookie-Editor 插件
> **注意**:Cookie-Editor 导出的内容可能不完整或包含多余字段。如果使用此方法导入后验证失败,请改用方法一。
### 第 1 步:安装 Cookie-Editor 插件
- Chrome:在 Chrome 应用商店搜索 "Cookie-Editor" 并安装
- Firefox:在 Firefox 附加组件中搜索 "Cookie-Editor" 并安装
### 第 2 步:打开小红书并确认登录
1. 在浏览器中打开 https://www.xiaohongshu.com
2. 确认已登录(能看到首页推荐信息流和你的头像)
3. 如果未登录,点击右上角登录,推荐使用手机扫码方式
### 第 3 步:导出 Cookie
1. 点击浏览器工具栏中的 Cookie-Editor 图标
2. 确认显示的是 xiaohongshu.com 的 Cookie
3. 点击 "Export" → 选择 "Export as JSON"
4. Cookie 自动复制到剪贴板
### 第 4 步:粘贴给 SocialVault
将复制的内容粘贴给 SocialVault Agent 即可。Agent 会自动验证登录态。
## 注意事项
1. **有效期短**:小红书 Cookie 约 7 天有效。到期前 SocialVault 会提前提醒你更新。
2. **推荐从请求头获取**:Cookie-Editor 等插件导出的 Cookie 可能不完整,建议优先使用方法一从网络请求头中复制。
3. **不要退出登录**:导出 Cookie 后不要在浏览器中退出小红书登录。
4. **不要清理浏览器数据**:清理 Cookie 会导致导出的 Cookie 立即失效。
5. **关键字段**:`a1`、`web_session` 和 `webId` 是必需的,缺少任何一个都会导致操作失败。
6. **活跃续期**:建议在 SocialVault 中开启活跃续期功能,自动访问小红书页面延长 Cookie 有效期。
7. **多账号**:如果有多个小红书账号,需要分别登录并导出 Cookie。可以使用浏览器的隐身模式或不同浏览器 Profile。
FILE:guides/cookie-export-zhihu.md
# 知乎 Cookie 导出教程
## 适用场景
Cookie 粘贴是使用知乎的推荐方式。知乎 Cookie 有效期约 30 天。
## 前置要求
- 一台能打开浏览器的电脑
- Chrome 或 Firefox 浏览器
- 已登录的知乎账号
## 方法一:使用 Cookie-Editor 插件(推荐)
### 第 1 步:安装 Cookie-Editor 插件
- Chrome:在 Chrome 应用商店搜索 "Cookie-Editor" 并安装
- Firefox:在 Firefox 附加组件中搜索 "Cookie-Editor" 并安装
### 第 2 步:打开知乎并确认登录
1. 在浏览器中打开 https://www.zhihu.com
2. 确认已登录(右上角能看到你的头像和消息图标)
3. 如果未登录,点击"登录",使用手机验证码或密码登录
### 第 3 步:导出 Cookie
1. 点击浏览器工具栏中的 Cookie-Editor 图标
2. 确认显示的是 zhihu.com 的 Cookie
3. 点击 "Export" → 选择 "Export as JSON"
4. Cookie 自动复制到剪贴板
### 第 4 步:粘贴给 SocialVault
将复制的内容粘贴给 SocialVault Agent 即可。Agent 会自动验证登录态。
## 方法二:使用开发者工具 Application 面板
### 第 1 步:打开开发者工具
1. 在 zhihu.com 页面按 `F12`
2. 切换到 "Application"(Chrome)或 "Storage"(Firefox)标签
### 第 2 步:找到关键 Cookie
在 Cookies → `https://www.zhihu.com` 下找到以下 Cookie:
| Cookie 名 | 说明 | 必需 |
|-----------|------|------|
| `z_c0` | 主认证 Token(JWT 格式) | 是 |
| `d_c0` | 设备标识,用于签名计算 | 推荐 |
### 第 3 步:快速复制
在 Console 标签中运行:
```
document.cookie
```
复制输出内容粘贴给 SocialVault。
## 方法三:从网络请求头中获取
当其他方法导入后验证失败时,可从网络请求中获取最完整的 Cookie:
1. 按 `F12` 打开开发者工具
2. 切换到 **"Network"**(网络)标签
3. 刷新知乎页面
4. 在过滤栏输入 `zhihu.com/api` 快速筛选
5. 找到任意一个发往 `www.zhihu.com/api` 的请求(推荐 `/api/v4/me`)
6. 在 "Headers" 中找到 `Cookie` 请求头
7. 右键 → 复制值
8. 粘贴给 SocialVault
## 注意事项
1. **有效期较长**:知乎 Cookie 约 30 天有效,到期前 SocialVault 会提前提醒。
2. **不要退出登录**:导出 Cookie 后不要在浏览器中退出知乎登录。
3. **z_c0 是核心**:`z_c0` 是 JWT 格式的认证 Token,是最关键的 Cookie。
4. **d_c0 推荐导出**:`d_c0` 用于部分 API 的签名计算,建议一并导出。
5. **多账号**:使用浏览器的隐身模式或不同 Profile 分别登录和导出。
FILE:adapters/bilibili.md
---
platform_id: "bilibili"
platform_name: "哔哩哔哩"
auth_methods:
- type: "cookie_paste"
priority: 1
label: "浏览器 Cookie 粘贴"
- type: "qrcode"
priority: 2
label: "扫码登录(适合 VPS 环境)"
capabilities:
- read_feed
- read_post
- search
- write_reply
- like
cookie_guide: "guides/cookie-export-bilibili.md"
session_check:
method: "api"
endpoint: "https://api.bilibili.com/x/web-interface/nav"
success_indicator: "\"isLogin\":true"
estimated_session_duration_days: 30
auto_refresh_supported: true
rate_limits:
views_per_hour: 120
searches_per_hour: 30
comments_per_day: 50
likes_per_day: 200
---
## 认证流程
### Cookie 粘贴认证
B站 Web 端需要以下关键 Cookie 字段:
**必要 Cookie 字段**:
- `SESSDATA`:主会话 Cookie,URL 编码格式,有效期约 30 天
- `bili_jct`:CSRF Token,所有 POST 请求必须携带
- `DedeUserID`:用户 UID
以上三个字段缺一不可。`SESSDATA` 用于身份认证,`bili_jct` 用于防跨站请求伪造校验。
**Cookie 获取步骤**:参见 guides/cookie-export-bilibili.md
**特殊说明**:
- B站 Cookie 有效期约 30 天,相对较长
- `SESSDATA` 值包含 URL 编码字符(如 `%2C`),解析时需保留原始编码
- POST 请求(评论、点赞等)需要将 `bili_jct` 同时作为表单参数 `csrf` 提交
### 扫码登录认证(VPS 环境)
B站提供官方扫码登录 API,流程稳定可靠。
**流程**:
1. 请求 `https://passport.bilibili.com/x/passport-login/web/qrcode/generate` 获取二维码 URL 和 `qrcode_key`
2. 将二维码 URL 生成图片展示给用户
3. 用户使用哔哩哔哩 APP 扫码确认
4. 轮询 `https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key={key}` 检查扫码状态
5. 扫码成功后从响应的 `Set-Cookie` 中提取凭证
6. 加密存储
**扫码状态码**:
- `86101`:未扫码
- `86090`:已扫码未确认
- `86038`:二维码已过期
- `0`:登录成功
## 登录态验证
### API 验证(默认)
通过 B站导航栏接口验证登录态,该接口反爬宽松、响应轻量:
1. 发送 GET 请求到 `https://api.bilibili.com/x/web-interface/nav`,携带 Cookie
2. 解析 JSON 响应
判定逻辑:
- 响应 JSON 中包含 `"isLogin":true` → `healthy`
- 响应 JSON 中包含 `"isLogin":false` → `expired`
- 响应 401/403 → `expired`
- 响应不包含预期标志 → `degraded`
- 网络错误 → `unknown`
**优势**:B站 API 对服务器 IP 无特殊限制,验证稳定。
### Browser 验证(备选)
使用 OpenClaw browser 工具:
1. 注入已存储的 Cookie 到 `.bilibili.com` 域名
2. 导航至 `https://www.bilibili.com`
3. 等待页面加载
判定逻辑:
- 页面右上角显示用户头像和昵称 → `healthy`
- 页面右上角显示"登录"按钮 → `expired`
- 页面加载超时或异常 → `unknown`
## 操作指令
### read_feed
```
GET https://api.bilibili.com/x/web-interface/wbi/index/top/feed/rcmd
Cookie: SESSDATA=xxx
```
返回首页推荐视频列表。注意 wbi 签名接口需要额外的 `w_rid` 和 `wts` 参数,由 browser 工具自动处理。
### read_post
```
GET https://api.bilibili.com/x/web-interface/view?bvid={bvid}
Cookie: SESSDATA=xxx
```
返回指定视频的详细信息(标题、简介、播放量、弹幕数等)。
### search
```
GET https://api.bilibili.com/x/web-interface/wbi/search/all/v2?keyword={keyword}
Cookie: SESSDATA=xxx
```
搜索视频、用户、番剧等内容。wbi 签名参数同样由 browser 自动处理。
### write_reply
```
POST https://api.bilibili.com/x/v2/reply/add
Cookie: SESSDATA=xxx; bili_jct=xxx
Content-Type: application/x-www-form-urlencoded
oid={视频aid}&type=1&message={评论内容}&csrf={bili_jct}
```
`type` 参数:1=视频,12=专栏,17=动态。
### like
```
POST https://api.bilibili.com/x/web-interface/archive/like
Cookie: SESSDATA=xxx; bili_jct=xxx
Content-Type: application/x-www-form-urlencoded
aid={视频aid}&like=1&csrf={bili_jct}
```
`like`: 1=点赞,2=取消点赞。
## 频率控制
| 操作 | 建议频率 | 说明 |
|------|----------|------|
| 浏览/API 请求 | ≤ 120 次/小时 | B站 API 限制相对宽松 |
| 搜索 | ≤ 30 次/小时 | 高频搜索可能触发验证码 |
| 评论 | ≤ 50 条/天 | 重复内容会被拦截,新号限制更严 |
| 点赞 | ≤ 200 次/天 | 短时间大量点赞可能被风控 |
**操作间隔建议**:每次操作间隔 2-5 秒随机延迟。
## 已知问题
1. **wbi 签名**:B站部分 API(推荐流、搜索)使用 wbi 签名机制,需要从 nav 接口获取 `img_key` 和 `sub_key` 计算签名参数。通过 browser 工具操作可自动处理。
2. **SESSDATA 编码**:`SESSDATA` 值包含 URL 编码字符,部分 Cookie 解析器可能错误解码,需保留原始编码。
3. **风控验证码**:高频操作或异常行为可能触发极验验证码(geetest),需人工干预。
4. **新号限制**:B站对新注册账号(等级 < 2)在评论和弹幕方面有更严格的限制。
5. **CSRF 必填**:所有写操作(评论、点赞、投币等)必须在请求体中包含 `csrf` 参数,值为 `bili_jct` Cookie。
6. **IP 友好**:B站 API 对服务器 IP 无特殊限制,是 SocialVault 中最容易验证的平台。
FILE:adapters/tieba.md
---
platform_id: "tieba"
platform_name: "百度贴吧"
auth_methods:
- type: "cookie_paste"
priority: 1
label: "浏览器 Cookie 粘贴"
capabilities:
- read_feed
- read_post
- search
- write_reply
- like
cookie_guide: "guides/cookie-export-tieba.md"
session_check:
method: "api"
endpoint: "https://tieba.baidu.com/f/user/json_userinfo"
success_indicator: "user_name_show"
estimated_session_duration_days: 180
auto_refresh_supported: false
rate_limits:
views_per_hour: 120
searches_per_hour: 30
replies_per_day: 50
likes_per_day: 200
---
## 认证流程
### Cookie 粘贴认证
百度贴吧使用百度通用账号体系,需要以下关键 Cookie 字段:
**必要 Cookie 字段**:
- `BDUSS`:百度核心认证 Cookie,所有百度服务共享,有效期通常数月
- `STOKEN`:安全 Token,部分写操作需要
`BDUSS` 是最核心的认证凭证,单独拥有即可完成大部分读操作和登录态验证。
**Cookie 获取步骤**:参见 guides/cookie-export-tieba.md
**特殊说明**:
- `BDUSS` 有效期很长(通常 6 个月以上),是所有平台中最长的
- `BDUSS` 是百度全平台通用的,同时适用于百度贴吧、百度网盘、百度知道等
- 退出任何百度产品的登录都会使 `BDUSS` 失效
## 登录态验证
### API 验证(默认)
通过贴吧用户信息接口验证登录态:
1. 发送 GET 请求到 `https://tieba.baidu.com/f/user/json_userinfo`,携带 Cookie
2. 解析 JSON 响应
判定逻辑:
- 响应 JSON 中包含 `"user_name_show"` 字段 → `healthy`
- 响应不包含用户信息或返回错误 → `expired`
- 网络错误 → `unknown`
**优势**:百度贴吧 API 对服务器 IP 限制较宽松,通常无需代理。
### Browser 验证(备选)
使用 OpenClaw browser 工具:
1. 注入已存储的 Cookie 到 `.baidu.com` 域名
2. 导航至 `https://tieba.baidu.com`
3. 等待页面加载
判定逻辑:
- 页面顶部显示用户名和头像 → `healthy`
- 页面显示"登录"按钮 → `expired`
- 页面加载超时或异常 → `unknown`
## 操作指令
### read_feed
```
GET https://tieba.baidu.com/f?kw={贴吧名}&ie=utf-8
Cookie: BDUSS=xxx
```
返回指定贴吧的帖子列表。
### read_post
```
GET https://tieba.baidu.com/p/{帖子ID}
Cookie: BDUSS=xxx
```
返回指定帖子的内容和回复。
### search
```
GET https://tieba.baidu.com/f/search/res?qw={关键词}&ie=utf-8
Cookie: BDUSS=xxx
```
搜索帖子内容。
### write_reply
```
POST https://tieba.baidu.com/f/commit/post/add
Cookie: BDUSS=xxx; STOKEN=xxx
Content-Type: application/x-www-form-urlencoded
fid={吧ID}&tid={帖子ID}&content={回复内容}&tbs={tbs_token}
```
`tbs` 参数需要从 `https://tieba.baidu.com/dc/common/tbs` 获取。
### like
```
POST https://tieba.baidu.com/mo/q/sign
Cookie: BDUSS=xxx
Content-Type: application/x-www-form-urlencoded
kw={贴吧名}&tbs={tbs_token}
```
贴吧的"点赞"实际为签到(一键签到)。
## 频率控制
| 操作 | 建议频率 | 说明 |
|------|----------|------|
| 浏览/API 请求 | ≤ 120 次/小时 | 限制相对宽松 |
| 搜索 | ≤ 30 次/小时 | 高频搜索可能触发验证码 |
| 回复 | ≤ 50 条/天 | 重复内容会被拦截,新号限制更严 |
| 签到 | ≤ 200 个吧/天 | 一键签到不受严格限制 |
**操作间隔建议**:每次操作间隔 2-5 秒随机延迟。
## 已知问题
1. **tbs Token**:回帖等写操作需要先获取 `tbs` 反跨站 Token,通过 `https://tieba.baidu.com/dc/common/tbs` 接口获取。
2. **BDUSS 全平台共享**:退出百度任何产品(贴吧、网盘等)的登录都会使 BDUSS 失效。
3. **验证码**:高频操作或异常行为可能触发百度验证码。
4. **等级限制**:部分贴吧对低等级用户有发帖限制。
5. **内容审核**:百度对敏感内容有严格审核,违规内容会被自动删除。
6. **IP 友好**:百度贴吧 API 对服务器 IP 无严格限制。
FILE:adapters/xiaohongshu.md
---
platform_id: "xiaohongshu"
platform_name: "小红书"
auth_methods:
- type: "cookie_paste"
priority: 1
label: "浏览器 Cookie 粘贴"
- type: "qrcode"
priority: 2
label: "扫码登录(适合 VPS 环境)"
capabilities:
- read_feed
- read_post
- search
- write_reply
- like
cookie_guide: "guides/cookie-export-xiaohongshu.md"
session_check:
method: "api"
endpoint: "https://www.xiaohongshu.com/explore"
success_indicator: "userId"
estimated_session_duration_days: 7
auto_refresh_supported: true
rate_limits:
views_per_hour: 60
searches_per_hour: 20
comments_per_day: 30
likes_per_day: 100
---
## 认证流程
### Cookie 粘贴认证
小红书 Web 端需要以下关键 Cookie 字段:
**必要 Cookie 字段**:
- `a1`:用户标识 Cookie,是多数 API 请求签名计算的输入
- `web_session`:会话 Cookie,用于维持登录状态
- `webId`:网页端 ID
以上三个字段缺一不可。
**Cookie 获取步骤**:参见 guides/cookie-export-xiaohongshu.md
**特殊说明**:
- 小红书的 Cookie 有效期较短,通常约 7 天
- 长时间不活跃可能导致 Cookie 更快过期
- 小红书 Web 端的 API 请求需要额外的签名参数(`x-s`、`x-t` 等),这些由 browser 工具自动处理
### 扫码登录认证(P1,VPS 环境)
对于纯 CLI 环境,通过 headless browser 打开小红书登录页,截取二维码并推送给用户手机端扫码。
**流程**:
1. 使用 OpenClaw browser 工具打开 `https://www.xiaohongshu.com`
2. 点击登录按钮,切换到扫码登录标签
3. 截取二维码图片
4. 在 Agent 对话中直接向用户展示二维码截图
5. 用户使用小红书 APP 扫码确认
6. 检测到登录成功后导出 Cookie
7. 加密存储
**安全机制**:
- 二维码 5 分钟过期
- 一次性使用
- 完成后立即关闭临时页面
## 登录态验证
### API 验证(默认,适合 VPS 环境)
通过 HTTP 请求验证:
1. 发送 GET 请求到 `https://www.xiaohongshu.com/user/profile/me`,携带 Cookie
2. 检查响应内容
判定逻辑:
- 响应包含 "个人主页" → `healthy`
- 响应 401/403 → `expired`
- 响应不包含预期标志 → `degraded`
- 网络错误 → `unknown`
### Browser 验证(备选,需 Agent 交互)
使用 OpenClaw browser 工具:
1. 注入已存储的 Cookie 到 `.xiaohongshu.com` 域名
2. 导航至 `https://www.xiaohongshu.com/user/profile/me`
3. 等待页面加载
判定逻辑:
- 页面正常展示个人主页内容 → `healthy`
- 页面重定向到登录页面或弹出登录窗口 → `expired`
- 页面加载但显示"请登录" → `expired`
- 页面加载超时或返回错误 → `unknown`
## 操作指令
所有操作均通过 browser 工具执行。小红书 Web 端 API 有严格的签名校验,由 browser 自动处理。
### read_feed
1. 导航至 `https://www.xiaohongshu.com/explore`
2. 等待信息流加载
3. 提取笔记卡片列表(标题、封面、作者、点赞数)
### read_post
1. 导航至笔记 URL(`https://www.xiaohongshu.com/explore/{note_id}`)
2. 等待笔记内容加载
3. 提取标题、正文、图片列表、评论
### search
1. 导航至 `https://www.xiaohongshu.com/search_result?keyword={keyword}&source=web_search_result_note`
2. 等待搜索结果加载
3. 可通过 URL 参数切换搜索类型:笔记/用户/商品
### write_reply
1. 导航至目标笔记页面
2. 滚动到评论区
3. 在评论输入框中输入内容
4. 点击发送按钮
### like
1. 在笔记页面或信息流中找到点赞按钮(❤️ 图标)
2. 点击操作
## 频率控制
| 操作 | 建议频率 | 说明 |
|------|----------|------|
| 浏览页面 | ≤ 60 次/小时 | 过快浏览触发风控验证码 |
| 搜索 | ≤ 20 次/小时 | 高频搜索可能被临时限制 |
| 评论 | ≤ 30 条/天 | 重复内容会被识别为垃圾评论 |
| 点赞 | ≤ 100 次/天 | 短时间大量点赞可能被限制 |
**操作间隔建议**:每次操作间隔 3-10 秒随机延迟,模拟真人行为。
## 已知问题
1. **Cookie 有效期短**:小红书 Cookie 约 7 天有效,建议开启活跃续期功能。
2. **签名校验**:小红书 Web 端 API 使用 `x-s`、`x-s-common`、`x-t` 等签名参数,这些由页面 JS 生成。直接 HTTP 请求无法绕过,必须通过 browser 工具操作。
3. **风控敏感**:小红书的反自动化策略较为激进,异常行为(高频操作、固定时间间隔、非常规设备指纹)可能导致账号被限流甚至封禁。
4. **IP 限制**:部分 VPS IP 可能被小红书识别并限制。
5. **登录页面变化**:小红书登录页面的元素选择器可能随版本更新而变化,扫码登录流程需定期验证。
6. **图片验证码**:异常操作可能触发滑块验证码,需要人工干预或使用验证码识别服务。
FILE:adapters/zhihu.md
---
platform_id: "zhihu"
platform_name: "知乎"
auth_methods:
- type: "cookie_paste"
priority: 1
label: "浏览器 Cookie 粘贴"
capabilities:
- read_feed
- read_post
- search
- write_reply
- like
cookie_guide: "guides/cookie-export-zhihu.md"
session_check:
method: "api"
endpoint: "https://www.zhihu.com/api/v4/me"
success_indicator: "\"id\""
estimated_session_duration_days: 30
auto_refresh_supported: true
rate_limits:
views_per_hour: 60
searches_per_hour: 20
comments_per_day: 30
likes_per_day: 100
---
## 认证流程
### Cookie 粘贴认证
知乎 Web 端需要以下关键 Cookie 字段:
**必要 Cookie 字段**:
- `z_c0`:主认证 Token,JWT 格式,有效期约 30 天
- `d_c0`:设备标识 Cookie,用于请求签名
`z_c0` 是核心认证凭证,缺少则所有需要登录的操作均无法进行。`d_c0` 用于生成 `x-zse-96` 签名参数。
**Cookie 获取步骤**:参见 guides/cookie-export-zhihu.md
**特殊说明**:
- 知乎 Cookie 有效期约 30 天
- 知乎部分 API 需要 `x-zse-96` 签名参数,该签名基于 `d_c0` 和请求 URL 计算
- 简单的验证请求(如 `/api/v4/me`)不需要签名,只需 `z_c0`
## 登录态验证
### API 验证(默认)
通过知乎个人信息接口验证登录态:
1. 发送 GET 请求到 `https://www.zhihu.com/api/v4/me`,携带 Cookie
2. 解析 JSON 响应
判定逻辑:
- 响应 JSON 中包含 `"id"` 字段 → `healthy`(包含用户 ID、昵称等信息)
- 响应 401 → `expired`(Cookie 已失效)
- 响应 403 → `degraded`(可能被限流)
- 响应不包含预期标志 → `degraded`
- 网络错误 → `unknown`
**注意**:知乎 API 对服务器 IP 有一定限制,高频访问可能触发验证码。
### Browser 验证(备选)
使用 OpenClaw browser 工具:
1. 注入已存储的 Cookie 到 `.zhihu.com` 域名
2. 导航至 `https://www.zhihu.com`
3. 等待页面加载
判定逻辑:
- 页面右上角显示用户头像和消息图标 → `healthy`
- 页面弹出登录窗口 → `expired`
- 页面加载超时或异常 → `unknown`
## 操作指令
### read_feed
```
GET https://www.zhihu.com/api/v4/recommend_feeds
Cookie: z_c0=xxx
```
返回首页推荐信息流。需要 `x-zse-96` 签名。
### read_post
**问题详情**:
```
GET https://www.zhihu.com/api/v4/questions/{question_id}
Cookie: z_c0=xxx
```
**回答详情**:
```
GET https://www.zhihu.com/api/v4/answers/{answer_id}
Cookie: z_c0=xxx
```
### search
```
GET https://www.zhihu.com/api/v4/search_v3?t=general&q={keyword}
Cookie: z_c0=xxx
```
搜索问题、回答、文章等内容。需要 `x-zse-96` 签名。
### write_reply
**回答问题**:
```
POST https://www.zhihu.com/api/v4/questions/{question_id}/answers
Cookie: z_c0=xxx
Content-Type: application/json
{"content": "<p>回答内容</p>"}
```
**评论回答**:
```
POST https://www.zhihu.com/api/v4/comments
Cookie: z_c0=xxx
Content-Type: application/json
{"content": "评论内容", "resource_type": "answer", "resource_id": "{answer_id}"}
```
### like
**赞同回答**:
```
POST https://www.zhihu.com/api/v4/answers/{answer_id}/voters
Cookie: z_c0=xxx
Content-Type: application/json
{"type": "up"}
```
`type`: `up` = 赞同,`down` = 反对,`neutral` = 取消。
## 频率控制
| 操作 | 建议频率 | 说明 |
|------|----------|------|
| 浏览/API 请求 | ≤ 60 次/小时 | 高频访问触发验证码 |
| 搜索 | ≤ 20 次/小时 | 搜索接口限制较严 |
| 回答/评论 | ≤ 30 条/天 | 重复内容会被识别 |
| 赞同 | ≤ 100 次/天 | 短时间大量操作可能被风控 |
**操作间隔建议**:每次操作间隔 3-8 秒随机延迟。
## 已知问题
1. **x-zse-96 签名**:知乎部分 API(推荐流、搜索)需要 `x-zse-96` 请求头,该签名算法基于 `d_c0` Cookie 和请求 URL。通过 browser 工具操作可自动绕过签名要求。
2. **反爬策略**:知乎对高频访问有验证码机制,VPS IP 可能触发更频繁的验证。
3. **内容审核**:知乎对回答和评论有严格的内容审核,不符合社区规范的内容会被自动折叠或删除。
4. **新号限制**:新注册或低信用的账号在回答和评论方面有更多限制。
5. **盐选会员内容**:部分内容仅对盐选会员可见,非会员账号无法获取完整内容。
6. **IP 限制**:知乎对非住宅 IP 有一定限制,可能需要更频繁地更新 Cookie。
FILE:adapters/_spec.md
# Platform Adapter 开发规范
## 一、概述
Platform Adapter 是 SocialVault 的平台扩展机制。每个适配器是一个 Markdown 文件,描述如何对接某个社交平台的认证和操作。Agent 阅读适配器内容后即可操作对应平台,无需额外代码。
## 二、文件位置
官方适配器放在 `adapters/` 目录下,用户自建适配器放在 `adapters/custom/` 目录下。文件名格式为 `平台标识.md`,如 `bilibili.md`、`v2ex.md`。
## 三、Frontmatter 必填字段
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| platform_id | string | 唯一标识,小写字母和连字符 | `bilibili` |
| platform_name | string | 展示名称 | `哔哩哔哩` |
| auth_methods | array | 认证方式列表,每项含 type、priority、label | 见下方 |
| capabilities | string[] | 支持的操作列表 | `[read_post, search]` |
| session_check.method | string | 验证方式 | `api` 或 `browser` |
| session_check.endpoint | string | 验证 URL | `https://...` |
| session_check.success_indicator | string | 成功判定标志 | `name` |
| estimated_session_duration_days | number | 预估 session 有效天数 | `14` |
| auto_refresh_supported | boolean | 是否支持活跃续期 | `true` |
## 四、Frontmatter 可选字段
| 字段 | 类型 | 说明 |
|------|------|------|
| cookie_guide | string | Cookie 导出教程文件路径 |
| rate_limits | object | 频率限制参考值 |
## 五、auth_methods 结构
```yaml
auth_methods:
- type: "cookie_paste" # cookie_paste / api_token / qrcode
priority: 1 # 数字越小优先级越高
label: "浏览器 Cookie 粘贴" # 展示给用户的描述
```
### type 可选值
| 值 | 说明 |
|------|------|
| cookie_paste | 用户手动导出浏览器 Cookie 粘贴导入 |
| api_token | 通过平台 API 获取 Token |
| qrcode | 扫码登录(通过 headless browser 获取二维码) |
## 六、capabilities 可选值
| 值 | 说明 |
|------|------|
| read_feed | 读取信息流 |
| read_post | 读取指定帖子 |
| search | 搜索内容 |
| write_reply | 发表回复/评论 |
| write_post | 发表新帖子 |
| like | 点赞/收藏 |
| send_dm | 发送私信 |
## 七、正文结构
正文使用二级标题(`##`)组织,以下段落为推荐结构:
### ## 认证流程
按 `auth_methods` 中的每种方式分别描述操作步骤。
- API Token 方式需说明端点、参数、返回值处理。
- Cookie 方式需说明必要的 Cookie 字段。
- 扫码方式需说明登录页 URL 和二维码区域定位方法。
### ## 登录态验证
描述如何判断当前凭证是否有效。
- API 方式:说明请求方式和成功判定逻辑。
- Browser 方式:说明访问哪个页面、检查什么内容。
### ## 操作指令
为 `capabilities` 中列出的每个操作编写指令。
- 对 API 平台给出端点和参数。
- 对无 API 平台描述 browser 操作步骤。
### ## 频率控制
列出该平台的安全操作频率建议。说明超频可能导致的后果(限流、封号等)。
### ## 已知问题
该平台的特殊注意事项,如反自动化策略、Cookie 行为异常等。
## 八、示例
参考 `adapters/bilibili.md` 或 `adapters/xiaohongshu.md` 作为完整示例。
## 九、开发新适配器
1. 复制 `adapters/_template.md`
2. 填写所有 `{{占位符}}` 标记的内容
3. 官方适配器放在 `adapters/` 下,用户自建放在 `adapters/custom/` 下
4. 完成后可通过 `socialvault add <platform>` 测试
FILE:adapters/_template.md
---
platform_id: "{{platform_id}}"
platform_name: "{{platform_name}}"
auth_methods:
- type: "{{auth_type}}"
priority: 1
label: "{{auth_label}}"
capabilities:
- {{capability_1}}
- {{capability_2}}
session_check:
method: "{{check_method}}"
endpoint: "{{check_endpoint}}"
success_indicator: "{{success_indicator}}"
estimated_session_duration_days: {{session_days}}
auto_refresh_supported: {{auto_refresh}}
---
## 认证流程
### {{auth_type_name}}
{{auth_steps}}
## 登录态验证
{{validation_method}}
判定逻辑:
- {{success_condition}} → `healthy`
- {{failure_condition}} → `expired`
- 网络错误 → `unknown`
## 操作指令
### {{capability_1}}
{{capability_1_instructions}}
### {{capability_2}}
{{capability_2_instructions}}
## 频率控制
| 操作 | 建议频率 | 说明 |
|------|----------|------|
| {{operation}} | {{rate}} | {{reason}} |
## 已知问题
1. {{known_issue_1}}
Human-in-the-loop MCP feedback collector with task queue. Pauses to collect human input via browser UI before continuing. Use when completing tasks, encounte...
---
name: skill-feedback-collector
description: Human-in-the-loop MCP feedback collector with task queue. Pauses to collect human input via browser UI before continuing. Use when completing tasks, encountering uncertain parameters, needing user confirmation, working with coding plan subscriptions, or when you should ask instead of guess. Also covers batch task execution via auto-dequeue.
metadata: {"clawdbot":{"emoji":"💬","requires":{"anyBins":["node","npm"]},"install":[{"id":"npm-install","kind":"node","package":"skill-feedback-collector","label":"npm install && npm run build"}],"os":["linux","darwin","win32"]}}
---
# Skill Feedback Collector
Pause the agent and wait for human feedback via a browser-based UI connected through WebSocket. Includes a task queue for batch execution of pre-loaded tasks.
## When to Use
- After completing a task, to let the user verify the result
- When encountering uncertain parameters (colors, layouts, API choices, business logic)
- Before making destructive or irreversible changes that need user confirmation
- When the user prefers interactive confirmation over autonomous execution
- When batch-executing multiple pre-loaded tasks from the queue
## Instructions
Follow these guidelines when this skill is active:
1. After completing a task, call `ask_human_feedback` with a summary and options for next steps. This lets the user decide what to do next rather than ending the session.
2. When unsure about implementation details, call `ask_human_feedback` with your question instead of making assumptions. Getting explicit input leads to better results.
3. The response from `ask_human_feedback` is your next instruction. Execute it, then call `ask_human_feedback` again when done. This creates a productive feedback loop.
4. If the task queue returns a task automatically (queue was non-empty), execute that task and call `ask_human_feedback` again when complete. The queue feeds tasks until empty.
5. Mode switching:
- User says "free mode" / "自由模式" → call `set_feedback_mode(enabled: false)` to work without pausing
- User says "feedback mode" / "确认模式" → call `set_feedback_mode(enabled: true)` to resume the confirmation loop
## Best Practices
- Ask the user before ending a session — they may have more tasks
- Confirm before making destructive changes (force push, drop table, delete files)
- If feedback mode is disabled (bypass message returned), continue working autonomously
## Tools
### `ask_human_feedback`
Pauses execution and sends the `reason` to the browser UI. Returns the human's text response. If the task queue is non-empty, the next task is auto-dequeued and returned (with a short delay for UI visibility).
**Parameters:** `reason` (string) — summary of work done and what input you need.
**Example reason format:**
```
Completed: [specific work done]
Changes: [files modified, endpoints added, etc.]
What would you like me to do next?
1. [Option A]
2. [Option B]
3. Something else
```
### `set_feedback_mode`
Toggle feedback confirmation on/off. When off, `ask_human_feedback` returns immediately without pausing.
**Parameters:** `enabled` (boolean)
## Setup
```bash
npm install && npm run build
```
MCP configuration:
```json
{
"command": "node",
"args": ["build/index.js"],
"cwd": "/path/to/skill-feedback-collector"
}
```
Browser UI: `http://<server-ip>:18061`
| Env Variable | Default | Description |
|---|---|---|
| `FEEDBACK_PORT` | `18061` | HTTP and WebSocket port |
| `FEEDBACK_TOKEN` | (empty) | Optional access token for the UI |
## Workflow
```
User message → Agent works → calls ask_human_feedback("Done. Next?")
↓
[Queue has tasks?] → YES → returns next task → Agent continues
↓ NO
[Waits for human input via browser UI]
↓
Human responds → Agent receives → works → calls ask_human_feedback again
↓
... loop continues until user indicates they are done ...
```
## Security
- Set `FEEDBACK_TOKEN` when deploying on shared or public networks to restrict access
- Use a firewall to limit which IPs can reach the HTTP/WebSocket port
- The server binds to `0.0.0.0` by default for convenience; restrict network access at the OS or firewall level if needed
- Conversation history (`feedback-history.json`) is stored locally in the skill directory; review and rotate if it contains sensitive information
- This skill does not make outbound network requests, download external resources, or execute shell commands
## Tips
- The task queue lets users pre-load multiple tasks for sequential execution
- Users can add tasks to the queue while the agent is working
- HTTP long-polling fallback activates automatically when WebSocket is unavailable
- Browser notifications and sound alerts notify you when the agent has a question
- Conversation history is persisted locally (max 500 entries)
FILE:.gitignore
node_modules/
build/
*.js.map
*.d.ts.map
feedback-history*.json
*.log
.env
role.md
FILE:package-lock.json
{
"name": "skill-feedback-collector",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "skill-feedback-collector",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^22.15.0",
"@types/ws": "^8.18.0",
"typescript": "^5.8.0"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hono": {
"version": "4.12.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/jose": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"engines": {
"node": ">=8"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"peerDependencies": {
"zod": "^3.25 || ^4"
}
}
}
}
FILE:package.json
{
"name": "skill-feedback-collector",
"version": "1.0.0",
"description": "OpenClaw Skill - MCP-based human feedback collector with WebSocket UI for token throttling",
"type": "module",
"main": "build/index.js",
"scripts": {
"build": "tsc",
"start": "node build/index.js",
"dev": "tsc --watch"
},
"keywords": [
"openclaw",
"skill",
"mcp",
"feedback",
"token-throttling"
],
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^22.15.0",
"@types/ws": "^8.18.0",
"typescript": "^5.8.0"
}
}
FILE:README.md
# Skill Feedback Collector
基于 MCP 协议的人类反馈收集器,通过 WebSocket 连接前端 UI 实现 AI 与人类的交互式协作。
支持 OpenClaw 等 AI Agent 平台,让 AI 在完成任务后等待用户确认,实现高效的人机协作工作流。
## 核心原理
```
┌─────────────┐ stdio ┌──────────────────┐ WebSocket ┌──────────────┐
│ AI Agent │◄──────────────►│ MCP Server │◄───────────────►│ 浏览器 UI │
│ (OpenClaw) │ MCP Protocol │ (Node.js) │ Port 18061 │ (index.html) │
└─────────────┘ └──────────────────┘ └──────────────┘
```
1. AI 完成任务后调用 `ask_human_feedback` 工具,传入工作摘要
2. MCP Server 通过 WebSocket 将问题推送到浏览器前端
3. AI 线程被 **挂起**(Promise pending)—— 等待期间不消耗 Token
4. 用户在浏览器中阅读问题并输入反馈
5. 反馈通过 WebSocket 返回 → MCP Server 释放 Promise → AI 继续工作
## 快速开始
### 安装
```bash
git clone [email protected]:2019-02-18/skill-feedback-collector.git
cd skill-feedback-collector
npm install
npm run build
```
### 配置 MCP
在你的 AI 客户端(Cursor / OpenClaw 等)的 MCP 配置中添加:
```json
{
"mcpServers": {
"feedback-collector": {
"command": "node",
"args": ["build/index.js"],
"cwd": "/path/to/skill-feedback-collector"
}
}
}
```
### 访问前端
服务启动后,在浏览器中打开:
```
http://你的服务器IP:18061
```
## 功能特性
| 特性 | 说明 |
|------|------|
| MCP 线程挂起 | 通过 Promise 挂起 AI 线程,等待期间零 Token 消耗 |
| WebSocket 实时通信 | 问题推送与反馈回传全部通过 WebSocket 实时完成 |
| HTTP 轮询降级 | WebSocket 不可用时自动降级为 HTTP Long-Polling |
| 反馈模式开关 | 支持通过 UI 或 MCP 工具随时开关反馈确认模式 |
| WebSocket 心跳 | 30 秒 ping/pong 保活,自动检测和清理死连接 |
| 浏览器通知 | AI 提问时弹出桌面通知 + 音效提醒,无需盯着页面 |
| Token 认证 | 可选的 `FEEDBACK_TOKEN` 环境变量保护 API 访问 |
| Markdown 渲染 | AI 消息支持 `**加粗**` 和 `` `代码` `` 格式化 |
| 对话历史持久化 | 自动保存到 `feedback-history.json`,最多 500 条 |
| 历史导出 | 一键导出完整对话记录为 JSON 文件 |
| 快捷回复 | 内置"继续"、"好的"、"重做"、"结束"快捷按钮 |
| 自动重连 | 断线后自动重连,不会丢失待处理的问题 |
| 服务器部署 | 绑定 `0.0.0.0`,支持外网浏览器访问 |
## 环境变量
| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| `FEEDBACK_PORT` | `18061` | HTTP + WebSocket 服务端口 |
| `FEEDBACK_TOKEN` | (空) | 可选的认证 Token,设置后所有 API 和 WebSocket 连接需要携带 Token |
```bash
# 自定义端口
FEEDBACK_PORT=9090 node build/index.js
# 启用认证
FEEDBACK_TOKEN=my-secret-token node build/index.js
# 访问时需要:http://服务器IP:18061/?token=my-secret-token
```
## 项目结构
```
skill-feedback-collector/
├── SKILL.md # OpenClaw/ClawHub 技能定义文件
├── package.json # 依赖管理
├── tsconfig.json # TypeScript 配置
├── src/
│ └── index.ts # MCP Server 核心逻辑
├── client/
│ └── index.html # 前端交互面板
└── feedback-history.json # 对话历史(运行后自动生成,已 gitignore)
```
## MCP 工具
### `ask_human_feedback`
挂起 AI 线程,等待人类输入。反馈模式关闭时直接返回不挂起。
**参数:**
- `reason`(string,必需):工作摘要和需要用户确认的内容
**返回:** 用户输入的文本(或关闭模式下的 bypass 消息)
**使用场景:**
```
✅ 任务完成后 → 询问用户是否继续
❓ 遇到不确定参数 → 请用户决定
🔧 修复错误后 → 请用户验证
📝 完成阶段性工作 → 确认下一步方向
```
### `set_feedback_mode`
开关反馈确认模式。
**参数:**
- `enabled`(boolean,必需):`true` 开启,`false` 关闭
**使用场景:**
```
用户说"自由模式" → 调用 set_feedback_mode(enabled: false)
用户说"确认模式" → 调用 set_feedback_mode(enabled: true)
也可以直接在浏览器 UI 上切换开关
```
## 对话流程示例
```
AI: [完成任务] → 调用 ask_human_feedback("✅ 登录 API 已完成,需要继续吗?")
⏸️ AI 线程挂起,不消耗 Token
用户: [在浏览器中输入] → "继续,帮我加上注册接口"
AI: [收到反馈] → 开始编写注册接口
AI: [完成任务] → 调用 ask_human_feedback("✅ 注册接口完成,还需要什么?")
⏸️ 再次挂起
用户: "没有了,结束吧"
AI: [收到反馈] → 结束对话
```
## 适用场景
- **Coding Plan** — 在一轮对话中完成更多任务,提升交互效率
- **任何需要人工确认的 AI 工作流** — 防止 AI 猜测导致返工
- **OpenClaw Skill 生态** — 符合 ClawHub 标准,可发布分享
## 安全说明
- **网络访问**:服务默认绑定 `0.0.0.0`,建议通过防火墙限制可访问的 IP 范围
- **认证保护**:在公网或共享网络环境部署时,务必设置 `FEEDBACK_TOKEN` 环境变量
- **历史记录**:对话历史存储在本地 `feedback-history.json` 文件中,包含敏感信息时请定期清理
- **无外部请求**:本技能不会发起任何对外网络请求、不下载外部资源、不执行 shell 命令
- **代码透明**:所有源码均在 `src/index.ts` 中,可自行审查
## 技术栈
- **TypeScript** + **Node.js**
- **@modelcontextprotocol/sdk** — MCP 协议 SDK
- **ws** — WebSocket 实现
- **原生 HTTP** — 静态文件服务 + REST API
## 许可证
MIT
FILE:tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "build"]
}
FILE:src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { WebSocketServer, WebSocket } from "ws";
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { URL } from "node:url";
const WS_PORT = parseInt(process.env.FEEDBACK_PORT || "18061", 10);
const AUTH_TOKEN = process.env.FEEDBACK_TOKEN || "";
const BASE_DIR = path.resolve(
path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1")),
".."
);
const CLIENT_DIR = path.join(BASE_DIR, "client");
const HISTORY_FILE = path.join(BASE_DIR, "feedback-history.json");
const HEARTBEAT_INTERVAL = 30_000;
const HEARTBEAT_TIMEOUT = 35_000;
interface HistoryEntry {
id: number;
timestamp: string;
role: "ai" | "human";
text: string;
}
let history: HistoryEntry[] = [];
let historyCounter = 0;
function loadHistory() {
try {
if (fs.existsSync(HISTORY_FILE)) {
const raw = fs.readFileSync(HISTORY_FILE, "utf-8");
history = JSON.parse(raw);
historyCounter = history.reduce((max, e) => Math.max(max, e.id), 0);
}
} catch {
history = [];
historyCounter = 0;
}
}
function saveHistory() {
try {
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2), "utf-8");
} catch (err) {
console.error("[feedback-collector] Failed to save history:", err);
}
}
function addHistoryEntry(role: "ai" | "human", text: string): HistoryEntry {
const entry: HistoryEntry = {
id: ++historyCounter,
timestamp: new Date().toISOString(),
role,
text,
};
history.push(entry);
if (history.length > 500) {
history = history.slice(-500);
}
saveHistory();
return entry;
}
loadHistory();
let pendingResolve: ((value: string) => void) | null = null;
let pendingReason: string | null = null;
let feedbackEnabled = true;
const taskQueue: string[] = [];
let autoMode = false;
const AUTO_DELAY_MS = 1500;
interface TrackedSocket extends WebSocket {
isAlive: boolean;
}
const connectedClients = new Set<TrackedSocket>();
function broadcastQueueState() {
broadcast({ type: "queue", tasks: [...taskQueue], autoMode });
}
function checkAuth(req: http.IncomingMessage): boolean {
if (!AUTH_TOKEN) return true;
const url = new URL(req.url || "/", `http://localhost:WS_PORT`);
const tokenParam = url.searchParams.get("token");
if (tokenParam === AUTH_TOKEN) return true;
const authHeader = req.headers.authorization;
if (authHeader === `Bearer AUTH_TOKEN`) return true;
return false;
}
const pollWaiters: Array<{
res: http.ServerResponse;
timer: ReturnType<typeof setTimeout>;
}> = [];
let pollSeq = 0;
function notifyPollWaiters() {
const snapshot = {
seq: ++pollSeq,
pending: pendingResolve !== null,
reason: pendingReason,
enabled: feedbackEnabled,
};
while (pollWaiters.length > 0) {
const w = pollWaiters.shift()!;
clearTimeout(w.timer);
try {
w.res.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
});
w.res.end(JSON.stringify(snapshot));
} catch {
/* client gone */
}
}
}
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
});
}
const httpServer = http.createServer(async (req, res) => {
const cors: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
if (req.method === "OPTIONS") {
res.writeHead(204, cors);
res.end();
return;
}
if (req.url === "/" || req.url?.startsWith("/index.html") || req.url?.startsWith("/?")) {
const filePath = path.join(CLIENT_DIR, "index.html");
fs.readFile(filePath, "utf-8", (err, data) => {
if (err) {
res.writeHead(500, { "Content-Type": "text/plain", ...cors });
res.end("Failed to load client page");
return;
}
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
...cors,
});
res.end(data);
});
return;
}
if (req.url?.startsWith("/api/")) {
if (!checkAuth(req)) {
res.writeHead(401, { "Content-Type": "application/json", ...cors });
res.end(JSON.stringify({ error: "Unauthorized. Provide ?token=xxx or Authorization: Bearer xxx" }));
return;
}
}
if (req.url === "/api/history") {
const recent = history.slice(-100).reverse();
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
...cors,
});
res.end(JSON.stringify(recent));
return;
}
if (req.url === "/api/history/export") {
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="feedback-history-new Date().toISOString().slice(0, 10).json"`,
...cors,
});
res.end(JSON.stringify(history, null, 2));
return;
}
if (req.url === "/api/status") {
res.writeHead(200, { "Content-Type": "application/json", ...cors });
res.end(
JSON.stringify({
seq: pollSeq,
pending: pendingResolve !== null,
reason: pendingReason,
enabled: feedbackEnabled,
})
);
return;
}
if (req.url?.startsWith("/api/poll")) {
const url = new URL(req.url, `http://localhost:WS_PORT`);
const lastSeq = parseInt(url.searchParams.get("seq") || "0", 10);
if (lastSeq < pollSeq) {
res.writeHead(200, { "Content-Type": "application/json", ...cors });
res.end(
JSON.stringify({
seq: pollSeq,
pending: pendingResolve !== null,
reason: pendingReason,
enabled: feedbackEnabled,
})
);
return;
}
const timer = setTimeout(() => {
const idx = pollWaiters.findIndex((w) => w.res === res);
if (idx >= 0) pollWaiters.splice(idx, 1);
res.writeHead(200, { "Content-Type": "application/json", ...cors });
res.end(
JSON.stringify({
seq: pollSeq,
pending: pendingResolve !== null,
reason: pendingReason,
enabled: feedbackEnabled,
})
);
}, 25000);
pollWaiters.push({ res, timer });
return;
}
if (req.url === "/api/feedback" && req.method === "POST") {
const body = await readBody(req);
try {
const data = JSON.parse(body);
if (data.text && pendingResolve) {
addHistoryEntry("human", data.text);
const resolve = pendingResolve;
pendingResolve = null;
pendingReason = null;
resolve(data.text);
broadcast({
type: "resolved",
message: "Feedback received. AI is continuing...",
});
notifyPollWaiters();
res.writeHead(200, { "Content-Type": "application/json", ...cors });
res.end(JSON.stringify({ ok: true }));
} else {
res.writeHead(400, { "Content-Type": "application/json", ...cors });
res.end(JSON.stringify({ error: "No pending request or empty text" }));
}
} catch {
res.writeHead(400, { "Content-Type": "application/json", ...cors });
res.end(JSON.stringify({ error: "Invalid JSON" }));
}
return;
}
if (req.url === "/api/toggle" && req.method === "POST") {
const body = await readBody(req);
try {
const data = JSON.parse(body);
feedbackEnabled = !!data.enabled;
console.error(
`[feedback-collector] Feedback mode "DISABLED" via HTTP`
);
broadcast({ type: "mode", enabled: feedbackEnabled });
notifyPollWaiters();
res.writeHead(200, { "Content-Type": "application/json", ...cors });
res.end(JSON.stringify({ ok: true, enabled: feedbackEnabled }));
} catch {
res.writeHead(400, { "Content-Type": "application/json", ...cors });
res.end(JSON.stringify({ error: "Invalid JSON" }));
}
return;
}
if (req.url === "/api/queue" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json", ...cors });
res.end(JSON.stringify({ tasks: [...taskQueue], autoMode }));
return;
}
if (req.url === "/api/queue" && req.method === "POST") {
const body = await readBody(req);
try {
const data = JSON.parse(body);
if (data.action === "add" && typeof data.task === "string" && data.task.trim()) {
taskQueue.push(data.task.trim());
broadcastQueueState();
} else if (data.action === "remove" && typeof data.index === "number") {
if (data.index >= 0 && data.index < taskQueue.length) {
taskQueue.splice(data.index, 1);
broadcastQueueState();
}
} else if (data.action === "clear") {
taskQueue.length = 0;
broadcastQueueState();
} else if (data.action === "reorder" && Array.isArray(data.tasks)) {
taskQueue.length = 0;
taskQueue.push(...data.tasks.filter((t: unknown) => typeof t === "string" && (t as string).trim()));
broadcastQueueState();
} else if (data.action === "autoMode" && typeof data.enabled === "boolean") {
autoMode = data.enabled;
broadcastQueueState();
}
res.writeHead(200, { "Content-Type": "application/json", ...cors });
res.end(JSON.stringify({ ok: true, tasks: [...taskQueue], autoMode }));
} catch {
res.writeHead(400, { "Content-Type": "application/json", ...cors });
res.end(JSON.stringify({ error: "Invalid JSON" }));
}
return;
}
if (req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json", ...cors });
res.end(
JSON.stringify({
status: "ok",
pending: pendingResolve !== null,
clients: connectedClients.size,
historyCount: history.length,
feedbackEnabled,
queueLength: taskQueue.length,
autoMode,
})
);
return;
}
res.writeHead(404, { "Content-Type": "text/plain", ...cors });
res.end("Not Found");
});
const wss = new WebSocketServer({ server: httpServer });
// --- WebSocket heartbeat ---
const heartbeatInterval = setInterval(() => {
for (const ws of connectedClients) {
if (!ws.isAlive) {
ws.terminate();
connectedClients.delete(ws);
continue;
}
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_INTERVAL);
wss.on("close", () => clearInterval(heartbeatInterval));
wss.on("connection", (rawWs, req) => {
if (AUTH_TOKEN && !checkAuth(req)) {
rawWs.close(4001, "Unauthorized");
return;
}
const ws = rawWs as TrackedSocket;
ws.isAlive = true;
connectedClients.add(ws);
console.error(
`[feedback-collector] Client connected. Total: connectedClients.size`
);
ws.on("pong", () => {
ws.isAlive = true;
});
ws.send(JSON.stringify({ type: "mode", enabled: feedbackEnabled }));
ws.send(JSON.stringify({ type: "queue", tasks: [...taskQueue], autoMode }));
if (pendingResolve && pendingReason) {
ws.send(
JSON.stringify({
type: "question",
reason: pendingReason,
})
);
}
ws.on("message", (raw) => {
ws.isAlive = true;
try {
const data = JSON.parse(raw.toString());
if (data.type === "feedback" && data.text && pendingResolve) {
addHistoryEntry("human", data.text);
const resolve = pendingResolve;
pendingResolve = null;
pendingReason = null;
resolve(data.text);
broadcast({
type: "resolved",
message: "Feedback received. AI is continuing...",
});
notifyPollWaiters();
} else if (data.type === "toggle") {
feedbackEnabled = !!data.enabled;
console.error(
`[feedback-collector] Feedback mode "DISABLED" via UI`
);
broadcast({ type: "mode", enabled: feedbackEnabled });
notifyPollWaiters();
} else if (data.type === "queue") {
if (data.action === "add" && typeof data.task === "string" && data.task.trim()) {
taskQueue.push(data.task.trim());
broadcastQueueState();
} else if (data.action === "remove" && typeof data.index === "number") {
if (data.index >= 0 && data.index < taskQueue.length) {
taskQueue.splice(data.index, 1);
broadcastQueueState();
}
} else if (data.action === "clear") {
taskQueue.length = 0;
broadcastQueueState();
} else if (data.action === "autoMode" && typeof data.enabled === "boolean") {
autoMode = data.enabled;
broadcastQueueState();
}
}
} catch {
console.error("[feedback-collector] Invalid message from client");
}
});
ws.on("close", () => {
connectedClients.delete(ws);
console.error(
`[feedback-collector] Client disconnected. Total: connectedClients.size`
);
});
});
function broadcast(payload: Record<string, unknown>) {
const msg = JSON.stringify(payload);
for (const client of connectedClients) {
if (client.readyState === WebSocket.OPEN) {
client.send(msg);
}
}
}
httpServer.listen(WS_PORT, "0.0.0.0", () => {
console.error(
`[feedback-collector] UI & WebSocket server listening on http://0.0.0.0:WS_PORT`
);
if (AUTH_TOKEN) {
console.error(`[feedback-collector] Auth enabled. Use ?token=AUTH_TOKEN or Authorization header.`);
}
});
const mcpServer = new Server(
{ name: "skill-feedback-collector", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "ask_human_feedback",
description:
"Suspend the current AI thread and wait for human feedback via a WebSocket-connected UI. " +
"Use this tool whenever you finish a task, encounter uncertainty, or need user confirmation " +
"before proceeding. This prevents the AI from ending the conversation prematurely. " +
"If feedback mode is disabled, this tool returns immediately with a bypass message.",
inputSchema: {
type: "object" as const,
properties: {
reason: {
type: "string",
description:
"A clear summary of what you have done so far and what you need from the user. " +
"Be specific about completed work and questions for the user.",
},
},
required: ["reason"],
},
},
{
name: "set_feedback_mode",
description:
"Enable or disable the feedback confirmation mode. " +
"When disabled, ask_human_feedback will return immediately without waiting. " +
"When enabled (default), ask_human_feedback will suspend the thread and wait for human input. " +
"Call this when the user says things like '自由模式/free mode' (disable) or '确认模式/feedback mode' (enable).",
inputSchema: {
type: "object" as const,
properties: {
enabled: {
type: "boolean",
description: "true to enable feedback mode (default), false to disable it.",
},
},
required: ["enabled"],
},
},
],
}));
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
const toolName = request.params.name;
if (toolName === "set_feedback_mode") {
const enabled = Boolean(request.params.arguments?.enabled);
feedbackEnabled = enabled;
console.error(
`[feedback-collector] Feedback mode "DISABLED" via MCP tool`
);
broadcast({ type: "mode", enabled: feedbackEnabled });
notifyPollWaiters();
return {
content: [
{
type: "text",
text: `Feedback mode is now "DISABLED — I will work freely without pausing for confirmation."`,
},
],
};
}
if (toolName === "ask_human_feedback") {
const reason = String(
request.params.arguments?.reason ?? "AI is waiting for your input."
);
if (!feedbackEnabled) {
console.error(
`[feedback-collector] Feedback mode disabled, bypassing: reason`
);
addHistoryEntry("ai", `[BYPASSED] reason`);
return {
content: [
{
type: "text",
text: "Feedback mode is currently disabled. Continue working autonomously. The user will re-enable feedback mode when needed.",
},
],
};
}
console.error(`[feedback-collector] AI is asking: reason`);
addHistoryEntry("ai", reason);
pendingReason = reason;
broadcast({ type: "question", reason });
notifyPollWaiters();
if (taskQueue.length > 0) {
const nextTask = taskQueue.shift()!;
broadcastQueueState();
console.error(`[feedback-collector] Auto-dequeued task: nextTask`);
await new Promise((r) => setTimeout(r, AUTO_DELAY_MS));
addHistoryEntry("human", `[QUEUE] nextTask`);
pendingReason = null;
broadcast({
type: "resolved",
message: `Queue task dispatched: nextTask`,
autoTask: nextTask,
});
notifyPollWaiters();
return {
content: [{ type: "text", text: nextTask }],
};
}
const humanResponse = await new Promise<string>((resolve) => {
pendingResolve = resolve;
});
console.error(`[feedback-collector] Human responded: humanResponse`);
return {
content: [{ type: "text", text: humanResponse }],
};
}
return {
content: [
{ type: "text", text: `Unknown tool: request.params.name` },
],
isError: true,
};
});
async function main() {
const transport = new StdioServerTransport();
await mcpServer.connect(transport);
console.error("[feedback-collector] MCP Server started via stdio");
}
main().catch((err) => {
console.error("[feedback-collector] Fatal error:", err);
process.exit(1);
});
FILE:client/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Feedback Collector</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0a0e14;
--surface:#111820;
--surface-2:#1a2130;
--border:#1e2a3a;
--border-focus:#2c6def;
--text:#c8d6e5;
--text-bright:#f0f4f8;
--text-dim:#5c6e83;
--primary:#2c6def;
--primary-soft:rgba(44,109,239,.1);
--green:#34d399;
--red:#f87171;
--radius:10px;
--radius-lg:14px;
}
html{height:100%}
body{
min-height:100%;
font-family:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
background:var(--bg);
color:var(--text);
line-height:1.6;
display:flex;
align-items:flex-start;
justify-content:center;
padding:32px 16px;
-webkit-font-smoothing:antialiased;
}
.app{width:100%;max-width:580px;display:flex;flex-direction:column;gap:16px}
.brand{display:flex;align-items:center;gap:12px;padding:4px 0 8px}
.brand-icon{width:36px;height:36px;background:var(--primary);border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}
.brand-text h1{font-size:16px;font-weight:700;color:var(--text-bright);letter-spacing:-.3px;line-height:1.3}
.brand-text p{font-size:11px;color:var(--text-dim);font-weight:500;letter-spacing:.3px;text-transform:uppercase}
.toolbar{display:flex;gap:8px;flex-wrap:wrap}
.toolbar-item{
display:flex;align-items:center;gap:8px;
padding:8px 14px;
background:var(--surface);
border:1px solid var(--border);
border-radius:var(--radius);
font-size:12px;font-weight:500;color:var(--text-dim);
flex:1;min-width:0;
}
.conn-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.conn-dot.ok{background:var(--green)}
.conn-dot.err{background:var(--red)}
.conn-dot.wait{background:var(--primary);animation:blink 1.4s ease-in-out infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
.toggle{position:relative;width:36px;height:20px;cursor:pointer;flex-shrink:0}
.toggle input{display:none}
.toggle-track{position:absolute;inset:0;background:var(--surface-2);border:1px solid var(--border);border-radius:10px;transition:background .2s,border-color .2s}
.toggle input:checked + .toggle-track{background:var(--primary);border-color:var(--primary)}
.toggle-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;background:#fff;border-radius:50%;transition:transform .2s;pointer-events:none}
.toggle input:checked ~ .toggle-knob{transform:translateX(16px)}
.btn-icon{
background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
padding:8px 12px;color:var(--text-dim);font-size:12px;font-family:inherit;
cursor:pointer;transition:border-color .15s,color .15s;white-space:nowrap;
}
.btn-icon:hover{border-color:var(--primary);color:var(--primary)}
.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;transition:border-color .25s ease}
.panel.live{border-color:var(--primary)}
.panel-label{
padding:12px 18px;font-size:11px;font-weight:600;color:var(--text-dim);
letter-spacing:.6px;text-transform:uppercase;border-bottom:1px solid var(--border);
display:flex;align-items:center;gap:6px;
}
.panel-label .tag{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;letter-spacing:.3px}
.tag-idle{background:var(--surface-2);color:var(--text-dim)}
.tag-active{background:var(--primary-soft);color:var(--primary)}
.q-zone{padding:20px 18px;min-height:72px;display:flex;align-items:flex-start}
.q-empty{color:var(--text-dim);font-size:13px;font-style:italic}
.q-text{
font-size:14px;line-height:1.75;color:var(--text-bright);
white-space:pre-wrap;word-break:break-word;width:100%;
}
.q-text code{background:var(--surface-2);padding:1px 5px;border-radius:4px;font-size:12px;font-family:"JetBrains Mono",monospace}
.q-text strong{color:var(--primary);font-weight:600}
.input-area{display:flex;gap:8px;padding:12px 18px 14px;border-top:1px solid var(--border);align-items:flex-end}
.input-area textarea{
flex:1;background:var(--bg);border:1px solid var(--border);border-radius:8px;
padding:9px 12px;color:var(--text-bright);font-size:13px;font-family:inherit;
line-height:1.5;resize:none;min-height:40px;max-height:160px;outline:none;
transition:border-color .2s ease;
}
.input-area textarea:focus{border-color:var(--border-focus)}
.input-area textarea::placeholder{color:var(--text-dim)}
.input-area textarea:disabled{opacity:.4;cursor:not-allowed}
.btn-send{
height:40px;padding:0 20px;background:var(--primary);color:#fff;border:none;
border-radius:8px;font-size:13px;font-weight:600;font-family:inherit;cursor:pointer;
transition:opacity .15s ease,transform .1s ease;white-space:nowrap;flex-shrink:0;
}
.btn-send:hover:not(:disabled){opacity:.85}
.btn-send:active:not(:disabled){transform:scale(.96)}
.btn-send:disabled{opacity:.35;cursor:not-allowed}
.quick-bar{display:none;gap:6px;padding:0 18px 12px;flex-wrap:wrap}
.quick-bar.show{display:flex}
.chip{
padding:5px 14px;background:var(--surface-2);border:1px solid var(--border);
border-radius:20px;font-size:12px;font-weight:500;color:var(--text);cursor:pointer;
transition:border-color .15s ease,color .15s ease;font-family:inherit;
}
.chip:hover{border-color:var(--primary);color:var(--primary)}
.log{max-height:280px;overflow-y:auto}
.log::-webkit-scrollbar{width:5px}
.log::-webkit-scrollbar-track{background:transparent}
.log::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.log-empty{padding:20px 18px;text-align:center;color:var(--text-dim);font-size:12px;font-style:italic}
.log-item{padding:10px 18px;border-bottom:1px solid var(--border);font-size:12px;line-height:1.6}
.log-item:last-child{border-bottom:none}
.log-role{font-weight:600;font-size:11px;letter-spacing:.3px;margin-bottom:2px}
.log-role.is-ai{color:var(--primary)}
.log-role.is-user{color:var(--green)}
.log-msg{color:var(--text-dim);white-space:pre-wrap;word-break:break-word}
.panel-label .spacer{flex:1}
.export-link{
font-size:10px;font-weight:500;color:var(--text-dim);text-decoration:none;
letter-spacing:0;text-transform:none;transition:color .15s;cursor:pointer;
background:none;border:none;font-family:inherit;
}
.export-link:hover{color:var(--primary)}
/* Task Queue */
.queue-body{padding:0}
.queue-input-row{display:flex;gap:8px;padding:12px 18px;border-bottom:1px solid var(--border)}
.queue-input-row input{
flex:1;background:var(--bg);border:1px solid var(--border);border-radius:8px;
padding:8px 12px;color:var(--text-bright);font-size:13px;font-family:inherit;outline:none;
transition:border-color .2s;
}
.queue-input-row input:focus{border-color:var(--border-focus)}
.queue-input-row input::placeholder{color:var(--text-dim)}
.queue-input-row button{
padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:8px;
font-size:12px;font-weight:600;font-family:inherit;cursor:pointer;white-space:nowrap;
transition:opacity .15s;
}
.queue-input-row button:hover{opacity:.85}
.queue-list{list-style:none;max-height:200px;overflow-y:auto}
.queue-list::-webkit-scrollbar{width:5px}
.queue-list::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.queue-item{
display:flex;align-items:center;gap:8px;
padding:8px 18px;border-bottom:1px solid var(--border);
font-size:12px;color:var(--text);
}
.queue-item:last-child{border-bottom:none}
.queue-item .q-num{
font-size:10px;font-weight:700;color:var(--text-dim);
min-width:18px;text-align:center;
}
.queue-item .q-task{flex:1;word-break:break-word}
.queue-item .q-del{
background:none;border:none;color:var(--text-dim);cursor:pointer;
font-size:14px;padding:2px 6px;border-radius:4px;transition:color .15s;
}
.queue-item .q-del:hover{color:var(--red)}
.queue-empty{padding:16px 18px;text-align:center;color:var(--text-dim);font-size:12px;font-style:italic}
.queue-actions{display:flex;gap:6px;padding:8px 18px;border-top:1px solid var(--border)}
.queue-actions button{
padding:5px 12px;background:var(--surface-2);border:1px solid var(--border);
border-radius:6px;font-size:11px;font-weight:500;color:var(--text-dim);
cursor:pointer;font-family:inherit;transition:border-color .15s,color .15s;
}
.queue-actions button:hover{border-color:var(--primary);color:var(--primary)}
.queue-actions .spacer{flex:1}
.queue-count{
display:inline-flex;align-items:center;justify-content:center;
min-width:18px;height:18px;padding:0 5px;
background:var(--primary-soft);color:var(--primary);
border-radius:9px;font-size:10px;font-weight:700;
}
.foot{text-align:center;font-size:11px;color:var(--text-dim);padding:4px 0}
.foot a{color:var(--primary);text-decoration:none}
@media(max-width:480px){
body{padding:16px 12px}
.q-zone{padding:16px 14px}
.input-area{padding:10px 14px 12px}
.panel-label{padding:10px 14px}
.log-item{padding:10px 14px}
.quick-bar{padding:0 14px 10px}
}
</style>
</head>
<body>
<div class="app">
<div class="brand">
<div class="brand-icon">💬</div>
<div class="brand-text">
<h1>Feedback Collector</h1>
<p>Human-in-the-Loop MCP Console</p>
</div>
</div>
<div class="toolbar">
<div class="toolbar-item" style="flex:2">
<div class="conn-dot err" id="dot"></div>
<span id="connLabel">Connecting...</span>
</div>
<div class="toolbar-item" style="flex:1;justify-content:space-between">
<span>Feedback <span id="modeText">ON</span></span>
<label class="toggle">
<input type="checkbox" id="modeToggle" checked>
<div class="toggle-track"></div>
<div class="toggle-knob"></div>
</label>
</div>
<button class="btn-icon" id="notifBtn" title="Enable browser notifications">🔔 Notify</button>
</div>
<div class="panel" id="panel">
<div class="panel-label">
<span>AI Message</span>
<span class="tag tag-idle" id="stateTag">IDLE</span>
</div>
<div class="q-zone">
<div class="q-empty" id="qText">Waiting for AI...</div>
</div>
<div class="quick-bar" id="quickBar">
<button class="chip" data-v="继续">继续</button>
<button class="chip" data-v="好的,没问题">好的</button>
<button class="chip" data-v="请重新做一遍">重做</button>
<button class="chip" data-v="结束本轮对话">结束</button>
</div>
<div class="input-area">
<textarea id="input" placeholder="Type your feedback..." rows="1" disabled></textarea>
<button class="btn-send" id="sendBtn" disabled>Send</button>
</div>
</div>
<div class="panel">
<div class="panel-label">
<span>Task Queue</span>
<span class="queue-count" id="queueCount">0</span>
<span class="spacer"></span>
</div>
<div class="queue-body">
<div class="queue-input-row">
<input type="text" id="queueInput" placeholder="Add a task to the queue...">
<button id="queueAddBtn">Add</button>
</div>
<ul class="queue-list" id="queueList">
<li class="queue-empty">Queue is empty</li>
</ul>
<div class="queue-actions">
<button id="queueClearBtn">Clear All</button>
<span class="spacer"></span>
</div>
</div>
</div>
<div class="panel">
<div class="panel-label">
<span>History</span>
<span class="spacer"></span>
<button class="export-link" id="exportBtn">Export JSON</button>
</div>
<div class="log" id="log">
<div class="log-empty">No messages yet</div>
</div>
</div>
<div class="foot">
<a href="https://clawhub.ai/" target="_blank">OpenClaw</a> · skill-feedback-collector v1.0.0
</div>
</div>
<script>
(function(){
var dot = document.getElementById("dot");
var connLabel = document.getElementById("connLabel");
var panel = document.getElementById("panel");
var stateTag = document.getElementById("stateTag");
var qText = document.getElementById("qText");
var quickBar = document.getElementById("quickBar");
var input = document.getElementById("input");
var sendBtn = document.getElementById("sendBtn");
var log = document.getElementById("log");
var modeToggle = document.getElementById("modeToggle");
var modeText = document.getElementById("modeText");
var notifBtn = document.getElementById("notifBtn");
var exportBtn = document.getElementById("exportBtn");
var queueInput = document.getElementById("queueInput");
var queueAddBtn = document.getElementById("queueAddBtn");
var queueList = document.getElementById("queueList");
var queueCount = document.getElementById("queueCount");
var queueClearBtn = document.getElementById("queueClearBtn");
var ws = null;
var hasHistory = false;
var transport = "none";
var pollSeq = 0;
var pollActive = false;
var currentPending = false;
var notifEnabled = false;
var baseUrl = location.origin;
var wsUrl = (location.protocol === "https:" ? "wss:" : "ws:") + "//" + location.host;
// --- Notification sound (short beep via Web Audio API) ---
var audioCtx = null;
function playNotifSound(){
try{
if(!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var osc = audioCtx.createOscillator();
var gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.frequency.value = 880;
osc.type = "sine";
gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.4);
osc.start(audioCtx.currentTime);
osc.stop(audioCtx.currentTime + 0.4);
}catch(e){}
}
// --- Browser notification ---
notifBtn.addEventListener("click", function(){
if(!("Notification" in window)){
alert("This browser does not support notifications.");
return;
}
Notification.requestPermission().then(function(p){
if(p === "granted"){
notifEnabled = true;
notifBtn.textContent = "🔔 ON";
notifBtn.style.borderColor = "var(--primary)";
notifBtn.style.color = "var(--primary)";
new Notification("Feedback Collector", { body: "Notifications enabled!", icon: "data:," });
}
});
});
function sendBrowserNotif(text){
if(!notifEnabled || Notification.permission !== "granted") return;
var body = text.length > 120 ? text.slice(0, 120) + "..." : text;
try{ new Notification("AI needs your feedback", { body: body, tag: "fc-question" }); }catch(e){}
}
// --- Minimal Markdown: bold, inline code, line breaks ---
function renderMarkdown(text){
var escaped = text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
return escaped
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
}
// --- Core UI ---
function setConn(state, label){
dot.className = "conn-dot " + state;
connLabel.textContent = label;
}
function setEditable(on){
input.disabled = !on;
sendBtn.disabled = !on;
if(on) input.focus();
}
function pushLog(role, text, ts){
if(!hasHistory){ log.innerHTML = ""; hasHistory = true; }
var el = document.createElement("div");
el.className = "log-item";
var r = document.createElement("div");
r.className = "log-role " + (role === "ai" ? "is-ai" : "is-user");
var timeStr = ts ? new Date(ts).toLocaleString() : new Date().toLocaleString();
r.textContent = (role === "ai" ? "AI" : "YOU") + " · " + timeStr;
var m = document.createElement("div");
m.className = "log-msg";
m.textContent = text;
el.appendChild(r);
el.appendChild(m);
log.insertBefore(el, log.firstChild);
}
function loadHistory(){
fetch(baseUrl + "/api/history")
.then(function(r){ return r.json(); })
.then(function(items){
if(!items || !items.length) return;
items.reverse().forEach(function(e){
pushLog(e.role === "ai" ? "ai" : "user", e.text, e.timestamp);
});
})
.catch(function(){});
}
loadHistory();
function syncMode(enabled){
modeToggle.checked = enabled;
modeText.textContent = enabled ? "ON" : "OFF";
}
function showQuestion(reason){
if(currentPending) return;
currentPending = true;
qText.className = "q-text";
qText.innerHTML = renderMarkdown(reason);
panel.classList.add("live");
stateTag.className = "tag tag-active";
stateTag.textContent = "WAITING";
quickBar.classList.add("show");
setConn("wait", "AI is waiting for your feedback" + (transport === "poll" ? " [HTTP]" : ""));
setEditable(true);
pushLog("ai", reason);
playNotifSound();
sendBrowserNotif(reason);
document.title = "⚡ AI Waiting — Feedback Collector";
}
function clearQuestion(){
currentPending = false;
qText.className = "q-empty";
qText.textContent = "Waiting for AI...";
panel.classList.remove("live");
stateTag.className = "tag tag-idle";
stateTag.textContent = "IDLE";
quickBar.classList.remove("show");
var mode = transport === "ws" ? "" : " [HTTP]";
setConn("ok", "Connected" + mode);
setEditable(false);
input.value = "";
document.title = "Feedback Collector";
}
function sendFeedback(){
var t = input.value.trim();
if(!t) return;
if(transport === "ws" && ws && ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({ type: "feedback", text: t }));
pushLog("user", t);
clearQuestion();
} else {
fetch(baseUrl + "/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: t })
}).then(function(r){ return r.json(); })
.then(function(d){ if(d.ok){ pushLog("user", t); clearQuestion(); } })
.catch(function(){});
}
}
function toggleMode(enabled){
if(transport === "ws" && ws && ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({ type: "toggle", enabled: enabled }));
} else {
fetch(baseUrl + "/api/toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: enabled })
}).catch(function(){});
}
}
// --- Events ---
sendBtn.addEventListener("click", sendFeedback);
input.addEventListener("keydown", function(e){
if(e.key === "Enter" && !e.shiftKey){ e.preventDefault(); sendFeedback(); }
});
input.addEventListener("input", function(){
this.style.height = "auto";
this.style.height = Math.min(this.scrollHeight, 160) + "px";
});
quickBar.addEventListener("click", function(e){
var chip = e.target.closest(".chip");
if(!chip) return;
input.value = chip.dataset.v;
sendFeedback();
});
modeToggle.addEventListener("change", function(){
var enabled = modeToggle.checked;
modeText.textContent = enabled ? "ON" : "OFF";
toggleMode(enabled);
});
exportBtn.addEventListener("click", function(){
window.open(baseUrl + "/api/history/export", "_blank");
});
// --- Task Queue ---
var localQueue = [];
function renderQueue(tasks){
localQueue = tasks || [];
queueCount.textContent = localQueue.length;
if(localQueue.length === 0){
queueList.innerHTML = '<li class="queue-empty">Queue is empty — add tasks for auto-execution</li>';
return;
}
queueList.innerHTML = "";
localQueue.forEach(function(task, i){
var li = document.createElement("li");
li.className = "queue-item";
li.innerHTML = '<span class="q-num">#' + (i+1) + '</span><span class="q-task"></span><button class="q-del" data-idx="' + i + '">×</button>';
li.querySelector(".q-task").textContent = task;
queueList.appendChild(li);
});
}
function sendQueueAction(action, extra){
var msg = Object.assign({ type: "queue", action: action }, extra || {});
if(transport === "ws" && ws && ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify(msg));
} else {
fetch(baseUrl + "/api/queue", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(msg)
}).then(function(r){ return r.json(); })
.then(function(d){ if(d.tasks) renderQueue(d.tasks); })
.catch(function(){});
}
}
queueAddBtn.addEventListener("click", function(){
var t = queueInput.value.trim();
if(!t) return;
sendQueueAction("add", { task: t });
queueInput.value = "";
});
queueInput.addEventListener("keydown", function(e){
if(e.key === "Enter"){
e.preventDefault();
queueAddBtn.click();
}
});
queueClearBtn.addEventListener("click", function(){
sendQueueAction("clear");
});
queueList.addEventListener("click", function(e){
var del = e.target.closest(".q-del");
if(!del) return;
var idx = parseInt(del.dataset.idx, 10);
sendQueueAction("remove", { index: idx });
});
// --- WebSocket transport ---
var wsFails = 0;
function connectWs(){
if(ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
setConn("err", "Connecting (WebSocket)...");
try{ ws = new WebSocket(wsUrl); }catch(e){ fallbackToPoll(); return; }
var openTimer = setTimeout(function(){
if(ws.readyState !== WebSocket.OPEN){ ws.close(); fallbackToPoll(); }
}, 5000);
ws.onopen = function(){
clearTimeout(openTimer);
wsFails = 0;
transport = "ws";
setConn("ok", "Connected");
};
ws.onmessage = function(evt){
try{
var d = JSON.parse(evt.data);
if(d.type === "question" && d.reason) showQuestion(d.reason);
else if(d.type === "resolved") clearQuestion();
else if(d.type === "mode") syncMode(!!d.enabled);
else if(d.type === "queue") renderQueue(d.tasks);
}catch(e){}
};
ws.onclose = function(){
clearTimeout(openTimer);
wsFails++;
if(wsFails >= 3){ fallbackToPoll(); }
else { setConn("err", "Reconnecting..."); setEditable(false); setTimeout(connectWs, 2000); }
};
ws.onerror = function(){ ws.close(); };
}
// --- HTTP Polling fallback ---
function fallbackToPoll(){
transport = "poll";
setConn("ok", "Connected [HTTP]");
if(!pollActive){ pollActive = true; poll(); }
}
function poll(){
if(transport !== "poll"){ pollActive = false; return; }
fetch(baseUrl + "/api/poll?seq=" + pollSeq)
.then(function(r){ return r.json(); })
.then(function(d){
pollSeq = d.seq || pollSeq;
syncMode(!!d.enabled);
if(d.pending && d.reason && !currentPending) showQuestion(d.reason);
else if(!d.pending && currentPending) clearQuestion();
if(!d.pending && !currentPending) setConn("ok", "Connected [HTTP]");
setTimeout(poll, 100);
})
.catch(function(){
setConn("err", "Connection lost — retrying...");
setTimeout(poll, 3000);
});
}
connectWs();
})();
</script>
</body>
</html>