
Earn Hunter
- 1.7k installs
- 159 repo stars
- Updated July 22, 2026
- okx/agent-skills
earn-hunter provides documented workflows for Automatically monitors OKX Flash Earn, Fixed Earn and Flexible Earn opportunities, sends push notifications, and guides subscription. 自动监控 OKX 闪赚、定期和活期赚币机会,推送通知
About
The earn-hunter skill automatically monitors OKX Flash Earn Fixed Earn and Flexible Earn opportunities sends push notifications and guides subscription 自动监控 OKX 闪赚 定期和活期赚币机会 推送通知并引导申购 Use when user says 有闪赚通知我 监控赚币 monitor earn notify me about earn 定时检查理财 执行 earn-hunter 扫描 earn-hunter scan 活期年化高了通知我 监控活期 Earn Hunter Automated monitor for OKX Flash Earn Fixed Earn and Flexible Earn Simple Earn opportunities baseDir the directory containing this SKILL md file All relative paths references templates config are resolved from here Verify okx CLI installed which okx If missing install via npm install g okx_ai okx-trade-cli On OpenClaw also verify the in-session cron tool is available in the agent tool list used for scheduling not the openclaw CLI Check optional dependent skills bash okx skill list json Optional skills not required for scanning notifications okx-cex-earn needed for purchase guide subscription execution okx-cex-auth needed for authentication recovery If either is missing attempt to install but do not block if installation fails bash okx skill add okx-cex-earn okx skill add okx-cex-auth Install succeeds continue
- Verify `okx` CLI installed: `which okx`. If missing, install via `npm install -g @okx_ai/okx-trade-cli`.
- Check optional dependent skills:
- `okx-cex-earn` - needed for purchase guide (subscription execution)
- `okx-cex-auth` - needed for authentication recovery
- Install succeeds → continue
Earn Hunter by the numbers
- 1,695 all-time installs (skills.sh)
- +133 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #313 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
earn-hunter capabilities & compatibility
- Capabilities
- verify `okx` cli installed: `which okx`. if miss · check optional dependent skills: · `okx cex earn` needed for purchase guide (subs · `okx cex auth` needed for authentication recov · install succeeds → continue
- Use cases
- documentation
What earn-hunter says it does
# Earn Hunter Automated monitor for OKX Flash Earn, Fixed Earn, and Flexible Earn (Simple Earn) opportunities.
**`{baseDir}`** = the directory containing this SKILL.md file.
npx skills add https://github.com/okx/agent-skills --skill earn-hunterAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.7k |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 22, 2026 |
| Repository | okx/agent-skills ↗ |
How do I use earn-hunter for the task described in its SKILL.md triggers?
Automatically monitors OKX Flash Earn, Fixed Earn and Flexible Earn opportunities, sends push notifications, and guides subscription. 自动监控 OKX 闪赚、定期和活期赚币机会,推送通知并引导申购。Use when user says: 有闪赚通知我, 监控赚币,.
Who is it for?
Teams invoking earn-hunter when the user request matches documented triggers and prerequisites.
Skip if: Skip when cached docs are missing, the request is a negative trigger, or another sibling skill owns the workflow.
When should I use this skill?
Automatically monitors OKX Flash Earn, Fixed Earn and Flexible Earn opportunities, sends push notifications, and guides subscription. 自动监控 OKX 闪赚、定期和活期赚币机会,推送通知并引导申购。Use when user says: 有闪赚通知我, 监控赚币, monitor earn, notify
What you get
Step-by-step guidance grounded in earn-hunter documentation and reference files.
- ranked opportunity reports
- protocol research notes
By the numbers
- 472 installs per skills.sh okx/agent-skills listing
- Rank 32 in the okx/agent-skills skills.sh catalog snapshot
Files
Earn Hunter
Automated monitor for OKX Flash Earn, Fixed Earn, and Flexible Earn (Simple Earn) opportunities.
`{baseDir}` = the directory containing this SKILL.md file. All relative paths (references/, templates/, config/) are resolved from here.
Preflight
1. Verify okx CLI installed: which okx. If missing, install via npm install -g @okx_ai/okx-trade-cli. On OpenClaw, also verify the in-session cron tool is available in the agent tool list (used for scheduling — not the openclaw CLI). 2. Check optional dependent skills:
okx skill list --jsonOptional skills (not required for scanning/notifications):
okx-cex-earn— needed for purchase guide (subscription execution)okx-cex-auth— needed for authentication recovery
If either is missing, attempt to install but do not block if installation fails:
okx skill add okx-cex-earn
okx skill add okx-cex-auth- Install succeeds → continue
- Install fails (network error, marketplace unavailable, etc.) → warn and continue:
"⚠ {skill_name} 安装失败,扫描和通知功能不受影响。申购引导和认证恢复需要该 skill,后续可手动安装。"
- Preflight continues regardless of skill installation result
4. Auth mode detection — run both, first match wins:
okx config show --json→ has non-emptyapi_keyfield → API Key mode. Add--profile liveto all commands.- No API key +
okx auth status --json→"status":"logged_in"→ OAuth mode. No--profileflag needed. - Neither → stop. Load
okx-cex-authskill and follow login steps.
5. Init config and state:
- If
~/.okx/earn-hunter/directory does not exist →mkdir -p ~/.okx/earn-hunter - If
~/.okx/earn-hunter/config.jsondoes not exist → copy{baseDir}/config/default.jsonto it - If
~/.okx/earn-hunter/state.jsondoes not exist → write{"flash":{},"fixed":{},"flexible":{},"consecutive_failures":0,"last_error":""} - If
~/.okx/earn-hunter/platform.jsondoes not exist → run Platform Detection - Always (re)install the scan script:
cp {baseDir}/scripts/scan.sh ~/.okx/earn-hunter/scan.sh && chmod +x ~/.okx/earn-hunter/scan.sh. This is the script cron and interactive scans both call. - Write
~/.okx/earn-hunter/env.snapshotwith resolved tool paths (cron cannot rely on the user's login PATH):
cat > ~/.okx/earn-hunter/env.snapshot << SNAP
# auto-generated by earn-hunter activation — $(date -Iseconds)
OKX_BIN=$(command -v okx)
NODE_BIN=$(command -v node)
JQ_BIN=$(command -v jq)
ACTIVATION_PATH=$PATH
SNAPThe scan script sources this file to resolve tool paths even under cron's minimal PATH=/usr/bin:/bin.
Config/state/platform JSON read-write done by the agent is only for activation/config management. The recurring scan itself is performed entirely by `scripts/scan.sh` (shell + jq) — jq is required for scanning. Verify with which jq; if missing, install (brew install jq / apt-get install jq).
Platform & Channel Detection
Three independent dimensions: platform (where the agent runs), scheduler (what triggers scans), notification channel (where alerts go).
Platform Detection (active probe + user confirmation)
First run (no `platform.json` exists):
1. Probe environment clues:
OPENCLAW_HOMEenv var exists? → hint: OpenClaw- Agent tool list contains
cron/deliverytools? → hint: OpenClaw HERMES_HOMEenv var exists orwhich hermessucceeds? → hint: Hermes Agent- Running inside Claude Code session? → hint: Claude Code
- None of the above matched → hint: Generic
2. Present detection result and ask user to confirm:
- "检测到你正在使用 {detected_platform},是否正确?"
- User confirms → proceed
- User says no → ask: "你使用的是哪个平台?1) OpenClaw 2) Claude Code 3) Hermes Agent 4) 其他"
3. Initialize platform config:
- OpenClaw / Claude Code → copy
{baseDir}/config/<confirmed_platform>.default.jsonto~/.okx/earn-hunter/platform.json - Hermes Agent → copy
{baseDir}/config/claude-code.default.jsonas base, set.platformto"hermes",.scheduler.typeto"cron" - Generic → copy
{baseDir}/config/claude-code.default.jsonas base, set.platformto"generic",.scheduler.typeto"manual"
4. Result written to ~/.okx/earn-hunter/platform.json, subsequent runs skip detection.
Subsequent runs (platform.json exists): Read ~/.okx/earn-hunter/platform.json and extract the .platform field (returns "openclaw", "claude-code", "hermes", or "generic").
No scheduler available on detected platform (only applies to platforms that should have one but don't) → error: "当前客户端不支持定时任务,请升级到最新版本。" Generic platform → no automatic scheduler. Inform: "当前平台不支持自动调度,你可以手动说'执行 earn-hunter 扫描'来触发。"
Configuration Files
| File | Scope | Content |
|---|---|---|
config.json | Shared | Scan scope (flash/fixed/flexible), currencies, APY thresholds, terms, language, verboseLog |
platform.json | Platform-specific | Scheduler type/interval, notification channel, TG/Lark credentials |
state.json | Shared | Dedup state |
Core config (config.json) is identical across platforms. Platform config (platform.json) differs — the scheduler.type field determines how scans are triggered:
OpenClaw (`openclaw.default.json`):
- scheduler.type =
"openclaw-cron"— scheduled via the in-session `cron` agent tool (no OS crontab, no CLI commands). The job runs as an isolated, light-context agent turn and delivers its output back to the conversation channel via cron `announce` delivery. notify.channel defaults to"session"so the scan prints to stdout forannounceto push (avoids double-send).
Claude Code / Hermes / Generic (`claude-code.default.json`):
- scheduler.type =
"cron"— scheduled via OS crontab → `scripts/scan.sh` (zero LLM token cost), notification via TG / Lark curl from the script itself.
Notification Channels (independent of platform)
Detect in priority order (PRD requirement: TG first): 1. Telegram — $TELEGRAM_BOT_TOKEN and $TELEGRAM_CHAT_ID both set → TG ready 2. Lark — platform.notify.lark_webhook non-empty → Lark ready 3. Session — fallback, only works in interactive mode
TG and Lark are standalone push channels — they work regardless of whether the agent client is open. On OS-crontab platforms, scheduled scans send notifications via direct curl. On OpenClaw, the scheduled scan runs in an isolated cron agent turn and delivers via cron `announce` to the conversation channel (channel = "session"); TG/Lark curl is not used unless the user explicitly switches the channel.
---
Skill Routing
| User intent | Route |
|---|---|
| "有闪赚通知我" / "monitor earn" / "帮我监控赚币" / "活期年化高了通知我" | → Activation Flow |
| "改 APY 阈值" / "只看 USDT" / "change config" / "活期加上 BTC" | → Config Management |
| "申购 USDT 定期 7D" / "subscribe" / "我要买" | → Purchase Guide |
| "执行 earn-hunter 扫描" (cron OR interactive) | → Scan Cycle — run scripts/scan.sh and relay its output |
| "停止监控" / "暂停" / "stop" | → Pause/Resume |
| "卸载 earn-hunter" / "uninstall" | → Uninstall |
| "测试 earn-hunter" / "smoke test" / "测试定时任务" | → Test Mode |
---
Activation Flow
First-time setup. Only confirm platform — everything else uses smart defaults.
Step 1 — Platform Detection & Confirmation
See Platform Detection. Probe environment → ask user to confirm → write platform.json.
Step 2 — Detect Notification Channel & Confirm
Must actively check available channels before proceeding. Do NOT silently fall back to session.
On OS-crontab platforms, scheduled notifications go out via direct curl; on OpenClaw they go out via cron announce to the conversation. Detection order (check each, report status for all): 1. Check $TELEGRAM_BOT_TOKEN and $TELEGRAM_CHAT_ID env vars:
- Both set → TG ready
- Token set but chat_id missing → warn: "Telegram 配置不完整(缺少 TELEGRAM_CHAT_ID),跳过 TG" → continue to next channel
- Neither set → TG not available
2. Check platform.notify.lark_webhook or Lark MCP tools:
- Webhook set and valid (starts with
https://and contains/hook/) → Lark ready - Webhook set but format invalid (does not start with
https://or missing/hook/) → warn: "Lark webhook 格式无效,跳过 Lark" → continue to next channel - Not configured and no Lark MCP → Lark not available
Always ask the user to confirm notification channel — never silently default to session. For a monitoring tool, notification is critical; defaulting to session means alerts are lost when the user is not in the conversation.
If one or more external channels detected:
"检测到以下推送渠道可用:
- {list of detected channels, e.g. Telegram / Lark}
你希望通知发到哪里? 1. {detected channel 1} 2. {detected channel 2, if any} 3. 仅在当前会话显示(离线收不到)"
If no external channel detected:
"新机会才能推送到你手上。你希望通知发到哪里? 1. Telegram — 需要提供 Bot Token 和 Chat ID(通过环境变量) 2. Lark/飞书 — 需要提供 Webhook URL 3. 仅在当前会话显示(⚠ 离线收不到通知)
推荐配置 Telegram 或 Lark,这样即使不在对话中也能收到提醒。"
- If user picks Telegram → guide setting
TELEGRAM_BOT_TOKENandTELEGRAM_CHAT_IDenv vars - If user picks Lark → ask for webhook URL, validate format (starts with
https://, contains/hook/), write toplatform.notify.lark_webhook - If user picks session → write
"session"and warn: "⚠ 离线状态下不会收到通知,建议后续配置外部渠道。"
Write confirmed channel to platform.json notify.channel.
Step 3 — Confirm Scan Config (3-step with defaults)
Present default config and ask user to confirm or customize. Each step offers a default — user can press enter to accept.
Step 1/3 — 扫描范围: "扫描范围(可多选): [1] Flash Earn(闪赚) [2] Fixed Earn(定期赚币) [3] Flexible Earn(活期赚币) 默认:全选"
- Default: all three enabled
- If user picks specific items → disable the others
- If user only picks [1] → set
config.fixed.enabled = false,config.flexible.enabled = false; skip Step 2/3 and 3/3 - If user only picks [3] → set
config.flash.enabled = false,config.fixed.enabled = false; go to flexible-specific config (Step 2/3 asks flexible currencies, Step 3/3 asks flexible APY threshold)
Step 2/3 — 监控币种: For Fixed Earn: "定期监控币种:全部(默认,按回车)或输入指定币种(如 USDT, SOL)"
- Default:
"all"(all currencies) - If user specifies → set
config.currenciesto array (e.g.["USDT", "SOL"])
For Flexible Earn: "活期监控币种:USDT, USDC(默认,按回车)或输入指定币种"
- Default:
["USDT", "USDC"] - If user specifies → set
config.flexible.currenciesto array - Note: flexible requires per-currency API calls, so recommend keeping the list small
Step 3/3 — APY 阈值: For Fixed Earn: "定期最低 APY 阈值:不限(默认,按回车)或输入百分比(如 8)"
- Default:
0(no limit) - If user specifies → set
config.fixed.globalMinApytovalue / 100(e.g. 8 →0.08)
For Flexible Earn: "活期最低 APY 阈值:8%(默认,按回车)或输入百分比"
- Default:
0.08(8%) - If user specifies → set
config.flexible.globalMinApytovalue / 100
Auto-detect language from conversation and write to config.json notify.language.
Write config to ~/.okx/earn-hunter/config.json.
Display summary using {baseDir}/templates/activation.md template (in user's language).
Step 4 — Smoke Test & Delivery Confirmation
Smoke test always sends a notification, regardless of `verboseLog` setting.
1. Run one scan cycle immediately with verboseLog forced on so output is always produced, even when there are no opportunities. Temporarily flip verboseLog, run the script, then restore it — and use EH_TEST_NAMESPACE=1 so smoke-test dedup keys go under the test: prefix and don't pollute production state:
jq '.verboseLog=true' ~/.okx/earn-hunter/config.json > ~/.okx/earn-hunter/config.tmp \
&& mv ~/.okx/earn-hunter/config.tmp ~/.okx/earn-hunter/config.json
EH_TEST_NAMESPACE=1 OKX_PROFILE=live ~/.okx/earn-hunter/scan.sh
# then restore verboseLog to the user's original value (e.g. false)2. If new opportunities found → the script sends the normal notification (rendered from templates) 3. If no opportunities found → with verboseLog forced on, the script sends the brief status. Optionally append the activation confirmation message: "Earn Hunter 已激活,当前暂无新机会,将在下一轮自动扫描。" (Use {baseDir}/templates/activation.md as base, append the no-opportunity note) 4. TG or Lark channel → ask: "已向 {channel} 发送测试消息,请确认是否收到?" 5. User confirms → proceed to Step 5 6. Not received → troubleshoot (see notify-channels.md) 7. 5 min no response → ping once 8. Session channel → skip confirmation
Note: The smoke test ignores verboseLog setting — it always produces output to verify the full pipeline works end-to-end.
Step 4b — Cron Environment Smoke Test (OS-crontab platforms only)
Critical: The user's interactive shell has a full PATH, but cron does not. Run a second smoke test simulating cron's minimal environment to verify env.snapshot works:
env -i HOME="$HOME" PATH=/usr/bin:/bin \
EH_TEST_NAMESPACE=1 OKX_PROFILE=live \
bash ~/.okx/earn-hunter/scan.sh- If exit 0 → cron will work. Proceed to Step 5.
- If exit 127 / "FATAL: 'okx' not found" →
env.snapshotis incomplete or missing. Re-run Preflight step to regenerate it. Do NOT proceed to Step 5 — the cron job will fail silently. - Show the user: "已验证 cron 最小环境下可正常执行。如果此步失败,说明 env.snapshot 中的工具路径有误。"
Step 5 — Set Up Scheduler
The scheduling mechanism depends on platform.json .scheduler.type. Branch on the platform.
OpenClaw (scheduler.type = "openclaw-cron")
On OpenClaw, scheduling is done inside the conversation by calling the in-session `cron` agent tool — never an OS command or openclaw cron CLI (the CLI path has permission issues in this context). Encourage the user to set it up right here in the chat: the cron job you create inherits the current session's channel, so its scan output is delivered straight back to this conversation.
Call the cron tool with action: "add" and a job shaped like this (read .scheduler.interval from platform.json for the frequency):
name:"earn-hunter-hourly"schedule:{ "kind": "every", "everyMs": 3600000 }— deriveeveryMsfromscheduler.interval("1h"→ 3600000,"30m"→ 1800000,"2h"→ 7200000)sessionTarget:"isolated"— run in an isolated session, not the main onepayload:{ "kind": "agentTurn", "message": "执行 earn-hunter 扫描", "lightContext": true }lightContext: trueruns the turn with a lightweight bootstrap context (skips workspace bootstrap files) → lower token cost per tick.- Token budget: OpenClaw cron jobs have no per-job tool-whitelist field (the old
--tools exec,read,writeflag no longer exists). The scan stays cheap because it runs theokxCLI throughexecand does not depend on the 160+ okx MCP tools — so as long as the isolated cron agent isn't configured to load the okx MCP server, only the regular tools (exec/read/write) are in play. Which tools load is governed by the agent's config, not by this job. delivery:{ "mode": "announce" }— pushes the turn's output back to the conversation channel that created the job.
When it fires, the isolated agent runs the prompt "执行 earn-hunter 扫描" → Scan Cycle (which runs scripts/scan.sh with channel session/stdout) → relays the result → announce delivers it here. Any new opportunity is therefore sent automatically.
Do NOT emit any shell/openclaw cron CLI command in the conversation — drive scheduling only through the cron tool.
OS-crontab platforms (scheduler.type = "cron" or "launchagent" — Claude Code / Hermes / Generic)
These use OS scheduler → `scripts/scan.sh`. The script does everything (CLI calls, filter, dedup, render, curl notifications) with zero LLM cost.
Install the script — copy the skill's scripts/scan.sh into the state dir so the scheduler has a stable path:
mkdir -p ~/.okx/earn-hunter
cp {baseDir}/scripts/scan.sh ~/.okx/earn-hunter/scan.sh
chmod +x ~/.okx/earn-hunter/scan.shSet up scheduler — try crontab first, fallback to LaunchAgent on macOS if cron daemon is not running:
# Resolve tool directories from the current shell
NODE_DIR=$(dirname "$(command -v node)")
OKX_DIR=$(dirname "$(command -v okx)")
JQ_DIR=$(dirname "$(command -v jq)")
CRON_PATH=$(printf '%s\n' "$NODE_DIR" "$OKX_DIR" "$JQ_DIR" /usr/bin /bin | awk '!seen[$0]++' | paste -sd: -)Step A: Try crontab + verify cron daemon (macOS)
(crontab -l 2>/dev/null; echo "0 * * * * PATH=$CRON_PATH OKX_PROFILE=live ~/.okx/earn-hunter/scan.sh >> ~/.okx/earn-hunter/cron.log 2>&1") | crontab -On macOS (uname -s == Darwin), immediately check if the cron daemon is running:
if [[ "$(uname -s)" == "Darwin" ]] && ! launchctl list com.vix.cron >/dev/null 2>&1; then
# cron daemon not running — fallback to LaunchAgent
fi- If cron daemon is running → done,
scheduler.type = "cron". - If cron daemon is not running → remove the crontab entry and proceed to Step B.
- On Linux → skip the check (cron is always available),
scheduler.type = "cron".
Step B: macOS LaunchAgent fallback (scheduler.type = "launchagent")
Generate ~/Library/LaunchAgents/com.okx.earn-hunter.plist with the resolved paths:
SCAN_SCRIPT="$HOME/.okx/earn-hunter/scan.sh"
LOG_FILE="$HOME/.okx/earn-hunter/cron.log"
INTERVAL=3600 # derive from scheduler.interval: "1h"→3600, "30m"→1800, "10m"→600
cat > ~/Library/LaunchAgents/com.okx.earn-hunter.plist << PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.okx.earn-hunter</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>${SCAN_SCRIPT}</string>
</array>
<key>StartInterval</key>
<integer>${INTERVAL}</integer>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>${CRON_PATH}</string>
<key>OKX_PROFILE</key>
<string>live</string>
<key>HOME</key>
<string>${HOME}</string>
</dict>
<key>StandardOutPath</key>
<string>${LOG_FILE}</string>
<key>StandardErrorPath</key>
<string>${LOG_FILE}</string>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
PLIST
launchctl load ~/Library/LaunchAgents/com.okx.earn-hunter.plistWrite scheduler.type = "launchagent" to platform.json. Inform user:
"macOS cron 服务未运行,已自动切换为 LaunchAgent 调度(无需 sudo,重启自动恢复)。"
Notes:
- OAuth mode → omit
OKX_PROFILEfrom the plistEnvironmentVariables(or set to empty). - LaunchAgent plist paths must be absolute (no
~). The activation flow expands$HOMEat generation time. RunAtLoad: truemeans the first scan runs immediately after loading.- The script reads
config.json/platform.json, writesstate.json/notify.log, and sends notifications via curl to TG Bot API or Lark Webhook itself. No agent involvement needed at tick time. - The script exits 0 and produces no output when there are no new opportunities and
verboseLog=false— this is the intended silent behavior.
IMPORTANT: On OS-scheduler platforms, do NOT use agent-platform `/loop` / Routines (Claude Code /loop, cloud Routines). These spawn LLM sessions per tick and cannot reliably push external notifications. (OpenClaw is the exception above — it uses its in-session cron tool with announce delivery by design.)
---
Scan Cycle
The entire Scan Cycle is implemented by `scripts/scan.sh` (pure shell + jq, zero LLM cost). Whether triggered by OS crontab, by an OpenClaw isolated cron agent turn, or by a user in an interactive session ("执行 earn-hunter 扫描"), the cycle is the same: run the script and relay its output. Do NOT re-implement the scan steps in natural language — the script is the single source of truth.
OpenClaw isolated cron turn: the job's prompt routes here. Run scripts/scan.sh (with platform.json notify.channel = "session", the script prints any notification to stdout); relay that stdout as the turn's response. The cron job's announce delivery then pushes it to the conversation channel. Do not curl TG/Lark from this turn — delivery is handled by announce.
How the agent runs a scan (interactive trigger)
# Profile: pass OKX_PROFILE only in API Key mode (omit for OAuth mode).
OKX_PROFILE=live ~/.okx/earn-hunter/scan.shThen relay the script's stdout verbatim to the user:
- If the script prints a notification (Flash / Fixed / mixed / verbose status), show it.
- If the script prints nothing (silent exit 0, the no-new-opportunity +
verboseLog=falsecase), tell the user "本轮扫描完成,无新机会" — do NOT fabricate a "scan complete" message into a channel; the silence is intentional.
What the script does (see references/scan-logic.md for the spec it implements)
1. Reads ~/.okx/earn-hunter/config.json 2. Runs scan commands (based on flash.enabled / fixed.enabled / flexible.enabled):
- Flash:
okx [--profile live] earn flash-earn projects --status 0,100 --json - Fixed:
okx [--profile live] earn savings fixed-products --json(auto-fallback torate-history+fixedOfferson CLI <1.3.3) - Flexible: for each currency in
flexible.currencies,okx [--profile live] earn savings rate-history --ccy <ccy> --limit 1 --json
3. Filters (two-layer APY threshold, terms filter, currency filter; flexible uses threshold-crossing model) 4. Dedups against ~/.okx/earn-hunter/state.json (state.flash["<id>:<status>"], state.fixed["<ccy>:<term>:<rate>"], state.flexible["<ccy>"]) 5. If new opportunities → renders the matching template (flash / fixed / flexible / mixed) and sends via the detected channel (TG → Lark → session), logging to notify.log 6. Updates state.json (write new keys; flash ID-level diff cleanup; fixed key-level diff cleanup; flexible threshold-crossing diff cleanup; 7-day TTL; failure counter) 7. If no new opportunities: verboseLog=true → brief status; verboseLog=false → silent exit 0, no output, nothing sent 8. Error handling: consecutive-failure counter (alert at 3, then reset); 401/session-expired → credential alert + stop
Channel routing inside the script: detection order TG ($TELEGRAM_BOT_TOKEN+$TELEGRAM_CHAT_ID) → Lark (platform.json .notify.lark_webhook) → session (stdout). A notify.channel of telegram/lark/session in platform.json forces that channel.
Auth / profile: the script never reads or prints credentials. It delegates all auth to the okx CLI (which reads ~/.okx/config.toml). Profile is injected only via the OKX_PROFILE env var.
Flexible Earn dedup model: Unlike Flash/Fixed which dedup by specific opportunity, Flexible uses a threshold-crossing model — key is just <ccy>. Notifies once when APY crosses above threshold; stays silent while it remains above; resets when APY drops below threshold (diff cleanup removes the key). This avoids frequent notifications from rate fluctuations.
---
Purchase Guide
When user wants to subscribe to a Fixed Earn product after receiving a notification.
Read {baseDir}/references/purchase-guide.md for the complete flow.
Summary:
1. Parallel balance check (funding + trading + flexible earn) 2. Compare fixed APR vs flexible lendingRate 3. Calculate recommended amount: min(idle + movable_simple_earn, lendQuota) 4. Present recommendation with comparison hint 5. User confirms → re-check offer availability (soldOut guard) → hand off to okx-cex-earn
Edge cases covered in purchase-guide.md:
- Balance < minLend → show deficit
- Amount > lendQuota → auto-cap with notice
- Redeem succeeded but subscribe failed → warn user, funds are in funding account
- Offer sold out between notification and subscription → inform user
Important: earn-hunter does NOT execute write operations directly. It transfers control to okx-cex-earn.
---
Config Management
Read {baseDir}/references/config-reference.md for field definitions and natural language examples.
When user wants to change settings: 1. Parse intent → map to config field (see config-reference.md for field mapping) 2. Read the target JSON file (config.json or platform.json) → modify the field → write back 3. Read the updated file → confirm the change to user
Exception: TG credentials cannot be changed via natural language. Tell user to set environment variables directly.
---
Pause/Resume
Branch on platform.json .scheduler.type:
OpenClaw (`openclaw-cron`) — manage via the in-session cron tool (no CLI):
- Pause: call
cronwithaction: "update", targeting theearn-hunter-hourlyjob, patch{ "enabled": false }(oraction: "remove"to delete it). - Resume:
action: "update"with{ "enabled": true }(or re-create as in Activation Step 5). - Use
action: "list"to find the job id.
OS-crontab platforms (`cron`):
- Pause:
crontab -l | grep -v 'earn-hunter' | crontab - - Resume: Re-add the crontab entry (same as Activation Step 5).
macOS LaunchAgent (`launchagent`):
- Pause:
launchctl unload ~/Library/LaunchAgents/com.okx.earn-hunter.plist - Resume:
launchctl load ~/Library/LaunchAgents/com.okx.earn-hunter.plist
Config and state are preserved — resuming picks up where it left off.
---
Uninstall
When user says "卸载" / "uninstall": 1. Stop the scheduler (same as Pause). For LaunchAgent, also remove the plist: launchctl unload ~/Library/LaunchAgents/com.okx.earn-hunter.plist && rm -f ~/Library/LaunchAgents/com.okx.earn-hunter.plist 2. Ask: "是否保留配置和历史数据?"
- Yes → only remove scheduler
- No → also remove
~/.okx/earn-hunter/directory
---
Test Mode
Trigger phrases: "测试 earn-hunter" / "earn-hunter smoke test" / "测试定时任务触发"
Behavior — run the script with the Test Mode hooks (no separate logic needed):
# Force verbose so output is always produced; write dedup keys under test: prefix.
EH_TEST_NAMESPACE=1 OKX_PROFILE=live ~/.okx/earn-hunter/scan.sh # with config.verboseLog temporarily set to true1. Execute a full Scan Cycle — the script runs the same scan, but EH_TEST_NAMESPACE=1 isolates state writes 2. Force-send notification — temporarily set config.verboseLog=true so the script always sends output regardless of whether opportunities are found (restore afterwards) 3. Dedup writes to test namespace — EH_TEST_NAMESPACE=1 prefixes dedup keys with test: (e.g. test:flash:12345:100); these keys are immune to diff cleanup (only TTL removes them), so test runs do not pollute production state 4. Output diagnostics after scan completes:
- okx auth status (logged in / expired / not configured)
- Scan command results (flash project count + fixed product count + flexible rate count)
- Post-filter results (how many passed filters per type)
- Notification channel status (which channel is configured, send result)
- Scheduler status (OS-crontab platforms: crontab entry exists? / OpenClaw:
crontoolaction: "list"shows theearn-hunter-hourlyjob?) - Last 5 lines of
~/.okx/earn-hunter/notify.log
5. Completion message: "测试完成。test: 前缀的 state 不影响正式去重,正式扫描不受影响。"
---
Error Handling
Read {baseDir}/templates/error-alert.md for exact alert message templates.
| Error | Action |
|---|---|
okx 401 / "Session expired" | Stop scan. Send alert (凭证失效 template). Load okx-cex-auth if interactive. |
| Network error / timeout | Retry once silently. If still fails, skip this cycle. |
| 3 consecutive scan failures | Send alert (连续失败 template). Counter stored in state.consecutive_failures, reset after alert. |
state.json corrupted | Reset by writing {"flash":{},"fixed":{},"consecutive_failures":0,"last_error":""}. May cause one round of duplicate notifications. |
| Notification send fails | Log to notify.log, continue scan. Dedup key NOT added (next cycle retries). |
config.json missing at runtime | Send alert: "earn-hunter 未配置,请运行首次激活流程。" |
| Dual-client suspected (user mentions both platforms) | Warn: "建议仅在一个客户端运行 earn-hunter,避免重复通知。" |
verboseLog = true + no hits | Send brief status (not silent). |
---
i18n
- All notifications rendered in user's language (detected at activation, stored in
config.notify.language) - Locked terms (never translate): Flash Earn, Fixed Earn, Simple Earn, DCD, APY, APR, OKX, earn-hunter, Telegram, project names, currency codes (USDT, BTC, etc.)
- Fallback: If LLM rendering fails, send Chinese template + append
(translation unavailable, sent in zh-CN)
---
Global Notes
- Security: Never accept credentials in chat. TG token only via env vars. Guide users to
okx config initfor OKX auth. - Output: Use
--jsonfor all okx commands. Render results as markdown tables. - Logging: All notification send results logged to
~/.okx/earn-hunter/notify.log. - Scope: Covers Flash Earn, Simple Earn Fixed, and Simple Earn Flexible (活期). DCD, on-chain, auto-earn are out of scope.
- Mode: Live trading only.
config.simulatedTradingis alwaysfalse.
Defensive Design Principles
These constraints apply to all changes to earn-hunter:
1. cron PATH ≠ user shell PATH. Any cron-triggered script must resolve tool paths independently — never assume cron inherits the user's login PATH. The script uses env.snapshot + resolve_bin fallback. 2. Sanity check first. Before running any CLI command, verify the binary exists. Missing tool → immediate exit with clear error, not silent failure. 3. Never swallow stderr. Use 2>&1 (capture into variable) instead of 2>/dev/null for CLI calls. Otherwise error messages vanish and last_error is empty. 4. Smoke test must simulate production. Activation smoke test runs twice: once in user shell (verify credentials/network), once in minimal env -i PATH=/usr/bin:/bin (verify cron can run). Don't pass Step 5 if cron smoke fails. 5. Alert templates must handle empty fields. If last_error is empty string, append "check ~/.okx/earn-hunter/cron.log; empty error usually means PATH issue" guidance. 6. Tool paths use snapshot + fallback, not global PATH. env.snapshot written at activation with exact paths; resolve_bin tries snapshot → command -v → hardcoded common paths. Survives nvm switch / Homebrew upgrades.
{
"platform": "claude-code",
"scheduler": {
"type": "cron",
"interval": "1h"
},
"notify": {
"channel": "auto",
"fallbackToSession": false,
"telegram": {
"bot_token_env": "TELEGRAM_BOT_TOKEN",
"chat_id_env": "TELEGRAM_CHAT_ID"
},
"lark_webhook": ""
}
}
{
"flash": {
"enabled": true
},
"fixed": {
"enabled": true,
"globalMinApy": 0,
"currencyOverrides": {},
"terms": "all"
},
"flexible": {
"enabled": true,
"globalMinApy": 0.08,
"currencies": ["USDT", "USDC"],
"currencyOverrides": {}
},
"currencies": "all",
"schedule": {
"cron": "0 * * * *",
"interval": "1h"
},
"notify": {
"language": "auto",
"fallbackToSession": false
},
"simulatedTrading": false,
"verboseLog": false
}
{
"platform": "openclaw",
"scheduler": {
"type": "openclaw-cron",
"interval": "1h"
},
"notify": {
"channel": "session"
}
}
Configuration Reference
File Layout
~/.okx/earn-hunter/
├── config.json # Shared core config (scan, thresholds, language)
├── platform.json # Platform-specific (scheduler, notification channel)
└── state.json # Dedup stateconfig.json— initialized by copying{baseDir}/config/default.jsonto~/.okx/earn-hunter/config.jsonplatform.json— initialized by copying{baseDir}/config/<platform>.default.jsonto~/.okx/earn-hunter/platform.jsonduring platform detection
Shared Config (config.json)
Scan Scope
| Field | Type | Default | Description |
|---|---|---|---|
flash.enabled | boolean | true | 是否扫描 Flash Earn(闪赚) |
fixed.enabled | boolean | true | 是否扫描 Fixed Earn(定期赚币) |
fixed.globalMinApy | number | 0 | 全局最低 APY 阈值(小数形式,如 0.05 = 5%)。0 = 不过滤 |
fixed.currencyOverrides | object | {} | 分币种阈值覆盖。格式 {"USDT": {"minApy": 0.08}}(= 8%),优先于 globalMinApy |
fixed.terms | string \ | string[] | "all" |
flexible.enabled | boolean | true | 是否扫描 Flexible Earn(活期赚币) |
flexible.globalMinApy | number | 0.08 | 活期全局最低 APY 阈值(小数形式,0.08 = 8%) |
flexible.currencies | string[] \ | "all" | ["USDT","USDC"] |
flexible.currencyOverrides | object | {} | 活期分币种阈值覆盖。格式 {"USDC": {"minApy": 0.06}}(= 6%),优先于 globalMinApy |
Monitor Scope
| Field | Type | Default | Description |
|---|---|---|---|
currencies | string \ | string[] | [] |
Notification (shared)
| Field | Type | Default | Description |
|---|---|---|---|
notify.language | string | "auto" | 通知语言。首激时自动检测写入,或手动设为 "zh-CN" / "en" |
notify.fallbackToSession | boolean | false | 排障失败降级标记。true 时跳过外部渠道直接用 session |
Schedule
| Field | Type | Default | Description |
|---|---|---|---|
schedule.cron | string | "0 * * * *" | Cron expression for scan frequency. Standard 5-field cron syntax |
schedule.interval | string | "1h" | Human-readable interval (for display). Must stay in sync with schedule.cron |
When user changes frequency (e.g. "把扫描频率改成 30 分钟"): 1. Convert to cron expression: "30 分钟" → "*/30 * * * *" 2. Update both schedule.cron and schedule.interval in config.json 3. Update the actual scheduler (restart cron with new interval)
Common mappings:
- "每小时" / "1h" →
"0 * * * *" - "30 分钟" / "30m" →
"*/30 * * * *" - "2 小时" / "2h" →
"0 */2 * * *" - "15 分钟" / "15m" →
"*/15 * * * *"
Misc
| Field | Type | Default | Description |
|---|---|---|---|
simulatedTrading | boolean | false | 固定实盘,预留字段 |
verboseLog | boolean | false | true 时无命中也推送简讯 |
Platform Config (platform.json)
Initialized from {baseDir}/config/<platform>.default.json during platform detection. scheduler.type is "openclaw-cron" on OpenClaw (in-session cron tool + announce delivery), "cron" on Claude Code / Hermes (OS crontab), "launchagent" on macOS when cron daemon is not running (fallback to LaunchAgent), and "manual" on Generic (no automatic scheduler — user triggers scans by hand).
Common Fields (all platforms)
| Field | Type | Default | Description |
|---|---|---|---|
platform | string | varies | 平台标识("openclaw" / "claude-code" / "hermes" / "generic") |
scheduler.type | string | varies | 调度方式:"openclaw-cron"(OpenClaw 会话内 cron 工具 + announce)/ "cron"(OS crontab)/ "launchagent"(macOS LaunchAgent,cron daemon 不可用时自动 fallback)/ "manual"(Generic) |
scheduler.interval | string | "1h" | 扫描间隔 |
notify.channel | string | varies | 通知渠道:"auto" / "telegram" / "lark" / "session"(OpenClaw 默认 "session",经 announce 投递回会话) |
Claude Code additional fields
| Field | Type | Default | Description |
|---|---|---|---|
notify.fallbackToSession | boolean | false | 排障失败降级标记 |
notify.telegram.bot_token_env | string | "TELEGRAM_BOT_TOKEN" | TG Bot Token 环境变量名 |
notify.telegram.chat_id_env | string | "TELEGRAM_CHAT_ID" | TG Chat ID 环境变量名 |
notify.lark_webhook | string | "" | 飞书 Webhook URL |
Natural Language Config Examples
| User input | Action | Target file |
|---|---|---|
| "只监控 USDT 和 USDC" | Set .currencies to ["USDT","USDC"] | config.json |
| "APY 低于 3% 不通知" | Set .fixed.globalMinApy to 0.03 | config.json |
| "USDT 要 5% 以上才通知" | Set .fixed.currencyOverrides to {"USDT":{"minApy":0.05}} | config.json |
| "只看闪赚" | Set .fixed.enabled and .flexible.enabled to false | config.json |
| "只看活期" | Set .flash.enabled and .fixed.enabled to false | config.json |
| "只看 7 天和 30 天" | Set .fixed.terms to ["7D","30D"] | config.json |
| "活期加上 BTC" | Append "BTC" to .flexible.currencies array | config.json |
| "活期 APY 改成 10%" | Set .flexible.globalMinApy to 0.10 | config.json |
| "USDC 活期 6% 就通知" | Set .flexible.currencyOverrides to {"USDC":{"minApy":0.06}} | config.json |
| "没命中也告诉我" | Set .verboseLog to true | config.json |
| "用飞书通知我" | Set .notify.channel to "lark" + ask webhook URL | platform.json |
| "用 Telegram 通知" | Set .notify.channel to "telegram" | platform.json |
| "把扫描频率改成 30 分钟" | Set .schedule.cron to "*/30 * * * *" and .schedule.interval to "30m" + restart scheduler | config.json |
| "重置配置" | Re-copy default.json → config.json + re-copy platform default → platform.json | both |
Config Operations
All config read/write is performed directly by the AI agent — no external tools (jq, shell scripts) needed.
# Read config
Read ~/.okx/earn-hunter/config.json → parse JSON → extract field
# Modify config
Read → parse → modify field → Write back
# Init config (if file missing)
Copy {baseDir}/config/default.json → ~/.okx/earn-hunter/config.json
# Init platform (if file missing)
Copy {baseDir}/config/claude-code.default.json → ~/.okx/earn-hunter/platform.jsonState File (state.json)
Hierarchical structure with separate namespaces for Flash, Fixed, and Flexible:
{
"flash": {
"<id>:<status>": { "notifiedAt": "<ISO 8601 timestamp>" }
},
"fixed": {
"<ccy>:<term>:<rate>": { "notifiedAt": "<ISO 8601 timestamp>" }
},
"flexible": {
"<ccy>": { "notifiedAt": "<ISO 8601 timestamp>", "rate": "<lendingRate>" }
},
"consecutive_failures": 0,
"last_error": ""
}| Field | Type | Description |
|---|---|---|
flash | object | Flash Earn dedup entries. Key format: <project_id>:<status> |
fixed | object | Fixed Earn dedup entries. Key format: <ccy>:<term>:<rate> |
flexible | object | Flexible Earn dedup entries. Key format: <ccy>. Threshold-crossing model: key removed when rate drops below threshold |
consecutive_failures | number | Consecutive scan failure count. Reset to 0 on success. Alert at 3 |
last_error | string | Last error message (truncated to 200 chars) |
Each dedup entry contains notifiedAt with ISO 8601 timestamp (e.g. "2026-05-25T15:00:00+08:00"). Flexible entries additionally store the rate at notification time (informational, not used for dedup).
Notification Channels
Delivery Models
There are three distinct delivery models. Understanding which applies avoids confusion.
1. Interactive Session (user is in a live conversation)
The agent outputs markdown directly in the conversation. Works on all platforms (OpenClaw, Claude Code, Hermes, Generic). Full interactivity — user can reply to subscribe immediately.
2. OS Crontab (scheduled scan, no LLM session) — Claude Code / Hermes / Generic
Scheduled scans run via OS crontab (no LLM session). Always use direct curl to TG Bot API or Lark Webhook for notifications. scripts/scan.sh does the curl itself.
3. OpenClaw In-Session Cron (announce delivery)
On OpenClaw the scheduled scan runs as an isolated cron agent turn created via the in-session cron tool. Delivery is via the cron job's `announce` mode, which pushes the turn's output back to the conversation channel that created the job. platform.json notify.channel is "session" so scripts/scan.sh prints the notification to stdout for the turn to relay — do not curl TG/Lark from this turn (that would double-send). The user can still switch to a TG/Lark channel explicitly if they prefer curl push.
4. Direct Webhook (standalone push, no agent session)
External system calls TG Bot API or Lark Webhook directly via curl. Does not depend on any agent platform's delivery mechanism. Useful for custom integrations.
Channel Summary
| Delivery Model | Platform | Method | Interactivity |
|---|---|---|---|
| Interactive session | Any | Markdown in conversation | Full (can reply) |
| OS crontab (scheduled) | Claude Code / Hermes / Generic | curl TG/Lark | Push only |
| In-session cron (scheduled) | OpenClaw | cron announce → conversation channel | Push (into chat) |
| Direct webhook | N/A | curl TG Bot API / Lark Webhook | Push only |
Channel Detection (auto mode)
All Platforms
渠道探测优先级(TG > Lark > Session),含半配置处理:
1. Telegram
$TELEGRAM_BOT_TOKENand$TELEGRAM_CHAT_IDboth set → TG ready$TELEGRAM_BOT_TOKENset but$TELEGRAM_CHAT_IDmissing → incomplete: warn "Telegram 配置不完整(缺少 TELEGRAM_CHAT_ID)",skip TG, continue- Neither set → not available
2. Lark webhook
platform.json.notify.lark_webhookis non-empty, starts withhttps://, and contains/hook/→ Lark ready- Value present but format invalid → incomplete: warn "Lark webhook 格式无效(需以 https:// 开头且包含 /hook/)",skip Lark, continue
- Not configured → not available
3. Session — fallback, only works when Claude Code is open interactively
Sending Methods
Telegram Bot
# Pseudocode: read env var names from platform.json → notify.telegram.bot_token_env / chat_id_env
# Then use printenv to get actual token values
TOKEN=$(printenv "${config.telegram.bot_token_env}")
CHAT_ID=$(printenv "${config.telegram.chat_id_env}")
curl -s "https://api.telegram.org/bot${TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
-d "text=${message}" \
-d "parse_mode=HTML"TG message formatting: use plain text + emoji. Avoid MarkdownV2 (escape hell).
TG 消息拆分
TG 单条消息上限 4096 字符。超长时拆分发送:
# 拆分规则
1. 按段落边界拆分,尽量不截断单个 offer
2. 首条消息带完整标题头
3. 后续条带 "(续 N/M)" 前缀
4. 每条不超过 4000 字符(留 96 字符 buffer)TG 安全约束
TG 凭证(bot token、chat_id)不通过自然语言修改,必须直接改环境变量。
原因:避免凭证出现在对话日志 / LLM context 中。
引导话术:
"TG 凭证需要直接修改环境变量,不能通过对话修改。请执行:export TELEGRAM_BOT_TOKEN=xxx和export TELEGRAM_CHAT_ID=xxx,并写入 shell profile 持久化。"
Lark Webhook
curl -s -X POST "<webhook_url>" \
-H "Content-Type: application/json" \
-d '{
"msg_type": "interactive",
"card": {
"schema": "2.0",
"header": {
"title": {"content": "<title>", "tag": "plain_text"},
"template": "blue"
},
"body": {
"elements": [
{"tag": "markdown", "content": "<body>"}
]
}
}
}'If Lark MCP (OKEngine-LARK-MCP) is available and has messaging tools (e.g. lark_send_message), prefer using them over raw curl. Do NOT use lark_drive_import_document — it creates cloud documents, not instant messages.
Session (fallback)
Output the notification directly as markdown in the conversation. Only works during interactive sessions — not available when triggered by cron/launchd.
First-Time Channel Setup
When notify_channel is "auto" and no channel is configured, guide the user through setup.
Telegram Setup
1. "Create a Telegram bot via @BotFather — send /newbot and follow the prompts" 2. "Copy the bot token and set it as an environment variable:"
export TELEGRAM_BOT_TOKEN="your-bot-token"3. "Send any message to your bot in Telegram, then get your chat ID:"
curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates" | jq '.result[0].message.chat.id'4. "Set the chat ID:"
export TELEGRAM_CHAT_ID="your-chat-id"5. Add both exports to your shell profile for persistence.
Lark Webhook Setup
1. "Create a custom bot in a Lark group → get the webhook URL" 2. "Save it to config:" Read ~/.okx/earn-hunter/platform.json → set .notify.lark_webhook to the webhook URL → write back.
Smoke Test & Delivery Confirmation (PRD 3.2)
After setup, send a test message to confirm delivery:
📡 Earn Hunter — Notification Test
This is a test message from earn-hunter.
If you see this, notifications are working!
Timestamp: <current_time>送达确认流程
发送测试消息后,必须主动追问:
1. 发送测试消息 2. 提示用户:"已向 {channel} 发送测试消息,请确认是否收到?" 3. 用户确认收到 → 通过,完成 setup 4. 用户说没收到 → 进入排障流程(见下方) 5. 5 分钟无回复 → 主动 ping 一次:"还没收到测试消息吗?需要帮你排查一下?"
排障流程 (PRD 3.2.1) — TG 渠道
当用户反馈未收到 TG 测试消息时,按以下步骤排查:
# Step 1: 验证 bot token 有效性
curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"
# 期望返回 ok: true + bot info
# Step 2: 验证 chat_id 有效性
curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getChat?chat_id=${TELEGRAM_CHAT_ID}"
# 期望返回 ok: true + chat info
# Step 3: 重发测试消息
curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
-d "text=🔧 Earn Hunter 排障测试消息" \
-d "parse_mode=HTML"
# Step 4: 检查 bot 是否被用户 block(从 sendMessage 返回判断)
# error_code 403 + "bot was blocked by the user" → 用户需要先 unblock bot排障失败(所有步骤均未解决):
- 写入
config.notify.fallbackToSession = true - 降级到 session 模式
- 提示用户:"TG 通知暂不可用,已降级到会话模式。后续排查好 TG 后可重新启用。"
排障流程 — Lark 渠道
1. 检查 webhook URL 格式(必须以 https:// 开头,包含 /hook/) 2. 重发测试消息,检查返回 {"StatusCode":0} 3. 确认 bot 已被添加到目标 Lark 群组 4. 失败 → 同样降级到 session 模式
notify.log
所有渠道发送结果写入 ~/.okx/earn-hunter/notify.log,格式:
[ISO_TIMESTAMP] [CHANNEL] [OK|FAIL] [detail]示例:
[2026-05-20T10:30:00+08:00] [TG] [OK] flash:12345:100 sent
[2026-05-20T10:30:01+08:00] [TG] [OK] fixed:USDT:7D:0.035 sent
[2026-05-20T10:30:02+08:00] [LARK] [FAIL] webhook returned StatusCode 9499
[2026-05-20T11:00:00+08:00] [SESSION] [OK] verbose log — no new opportunities日志用途:
- 排查通知未送达问题
- 审计历史通知记录
- 监控渠道可用性
Purchase Guide
Trigger
User receives a Fixed Earn notification and returns to the agent session saying any of:
- "申购" / "subscribe" / "我要买" / "buy it"
- "申购 USDT 定期 7天" (with specific parameters)
Flow
Step 1 — Gather Balance (parallel)
Run all three in parallel:
okx [--profile live] account asset-balance <ccy> # funding account
okx [--profile live] account balance <ccy> # trading account
okx [--profile live] earn savings balance <ccy> --json # Simple Earn flexible positionAlso fetch current flexible rate for comparison:
okx [--profile live] earn savings rate-history --ccy <ccy> --limit 1 --jsonStep 2 — Compare APR
Extract:
fixed_ratefrom the notification orearn savings fixed-productsflexible_rate=lendingRatefrom rate-history
Step 3 — Recommend Amount
Build a recommendation table:
| Source | Available | Current Yield | Recommendation |
|---|---|---|---|
| Funding account | X <ccy> | 0% (idle) | Full amount → Fixed Earn |
| Trading account | Y <ccy> | 0% (idle) | Full amount → Fixed Earn ⚠️ |
| Simple Earn Flexible | Z <ccy> | A.AA% | Move to Fixed if fixed_rate > flexible_rate |
⚠️ Note for trading account: transferring requires Withdraw permission on the API key. If the user's key lacks this permission, note that they need to transfer manually in the OKX app.
Step 4 — Present and Confirm
Show the recommendation to the user. Let them choose:
- Which sources to use
- How much from each source
- Which term to subscribe
Operation Sequence (CRITICAL)
The following operations MUST execute in this exact order:
1. soldOut recheck — re-fetch earn savings fixed-products to verify the target product is still available 2. User final confirmation — show final summary and get explicit "确认" from user 3. Redeem (if needed) — redeem from Simple Earn flexible to funding account 4. Purchase — execute fixed-purchase via okx-cex-earn
Never execute redeem before soldOut recheck. If soldOut recheck fails (product no longer available), abort immediately — do not proceed to redeem or purchase.
Step 5 — Hand Off to okx-cex-earn
earn-hunter does NOT directly execute write operations. Instead:
1. Announce: "Handing off to okx-cex-earn for the subscription..." 2. Load the okx-cex-earn skill and follow its Fixed Earn subscribe flow:
- Preview:
okx earn savings fixed-purchase --ccy <ccy> --amt <amt> --term <term> --json - Show confirmation summary (with lock warning)
- Execute:
okx earn savings fixed-purchase --ccy <ccy> --amt <amt> --term <term> --confirm --json - Verify:
okx earn savings fixed-orders --ccy <ccy> --state pending --json
Fallback: okx-cex-earn Skill Not Available
If okx-cex-earn skill is not installed or cannot be loaded (marketplace unavailable, install failed, etc.), provide a manual fallback:
Option A — Direct CLI commands (copy-paste):
# Preview (dry run)
okx earn savings fixed-purchase --ccy <ccy> --amt <amt> --term <term> --json
# Confirm and execute
okx earn savings fixed-purchase --ccy <ccy> --amt <amt> --term <term> --confirm --json
# Verify order was placed
okx earn savings fixed-orders --ccy <ccy> --state pending --jsonPresent the pre-filled commands with the user's confirmed parameters so they can copy-paste directly.
Option B — OKX App: "你也可以在 OKX App 中操作:金融 → 赚币 → Simple Earn → 定期 → 选择 {ccy} {term} → 输入金额 → 确认申购。"
Fund Routing (if needed)
If funds are in trading account:
okx [--profile live] account transfer --ccy <ccy> --amt <amt> --from 18 --to 6(18=trading, 6=funding)
If funds are in Simple Earn flexible:
okx [--profile live] earn savings redeem --ccy <ccy> --amt <amt>(Funds return to funding account)
Both operations require user confirmation before executing.
Recommended Amount Formula
Calculation
idle_amt = funding_balance + trading_balance
movable_simple_earn = (simple_earn_balance if lendingRate < fixed_rate else 0)
recommended_amt = idle_amt + movable_simple_earn
maxBuyableAmt = min(recommended_amt, lendQuota)Display Template
推荐买入金额:{maxBuyableAmt} = 闲置 {idle_amt} + 低利活期 {movable_simple_earn_amt}- If
movable_simple_earnis 0, simplify to:推荐买入金额:{maxBuyableAmt}(全部来自闲置资金)
Comparison Hint
| Condition | Hint |
|---|---|
lendingRate < fixed_rate | "活期收益低于定期目标,建议挪过来" |
lendingRate >= fixed_rate | "活期收益不低于定期目标,建议保留" |
Edge Cases & Error Handling
Balance Insufficient
When idle_amt + movable_simple_earn < minLend:
❌ 余额不足,还差 {diff} {ccy}
当前可用:{available} {ccy}(闲置 {idle_amt} + 可挪活期 {movable_simple_earn})
最低申购:{minLend} {ccy}Amount Exceeds Quota
When user-requested amount or recommended_amt > lendQuota:
- Auto-narrow to
lendQuota - Notify user:
⚠ 产品剩余额度 {lendQuota} {ccy},已自动调整为最大可购金额Pre-Purchase Recheck (Sold Out)
After user confirms the amount, before executing purchase:
1. Re-fetch earn savings fixed-products --ccy <ccy> --json 2. If the target product's lendQuota is 0 or product is no longer listed:
❌ 该产品已售罄,申购未执行Redeem Succeeded but Purchase Failed
If Simple Earn flexible redeem succeeds but subsequent Fixed Earn purchase fails:
⚠ 已赎回 {amt} {ccy},但申购失败
资金当前在资金账户中,未丢失
错误原因:{error_message}
你可以稍后重试申购,或手动在 OKX App 操作Preview Completed but User Did Not Confirm (after redeem)
If user triggered a preview flow that redeemed Simple Earn funds but then did not confirm the Fixed Earn purchase:
⚠ 已赎回 {amt} {ccy} 到资金账户,但未完成定期申购
资金安全在资金账户中
如需继续申购,回复"申购 {ccy} 定期 {term}"Scan Logic
⚙️ This logic is implemented by `scripts/scan.sh` (pure shell + jq). The agent does not execute these steps manually — it runsscripts/scan.sh(installed to~/.okx/earn-hunter/scan.sh) and relays the output. This document is the specification the script implements, kept for reference and review. Do not hand-execute the steps below; if behavior needs to change, change the script and update this spec together.
>
Key script behaviors that satisfy the two original bugs:
- No new opportunities + `verboseLog=false` → the script exits 0 silently with zero output and sends nothing. (Fixes the "sends 'Scan complete' instead of staying silent" bug.)
- Triggered by OS crontab, not an LLM session (Claude Code / Hermes / Generic). (Fixes the "Claude Code /loop expires" bug.)>
OpenClaw exception: the scan is triggered by an isolated, light-context cron agent turn (via the in-sessioncrontool). That turn runs this same script withnotify.channel = "session"(output to stdout) and lets cronannouncedeliver the result to the conversation — it does not curl TG/Lark. Everything else (filter, dedup, state, silent-exit) is identical.
>
Test hooks (env vars, used only for verification, inert in production):EH_FLASH_FIXTURE,EH_FIXED_FIXTURE,EH_FLEXIBLE_FIXTURE,EH_DRY_RUN,EH_STATE_DIR,EH_FORCE_FAIL,EH_NOW_ISO,EH_TEST_NAMESPACE. Profile is injected viaOKX_PROFILE(empty → no--profileflag).
CLI Version Compatibility
| Feature | v1.3.2 | v1.3.3+ |
|---|---|---|
earn flash-earn projects | ✅ | ✅ |
earn savings rate-history (includes fixedOffers) | ✅ | ✅ |
earn savings fixed-products (dedicated command) | ❌ | ✅ |
For maximum compatibility, default to rate-history + fixedOffers extraction. Use fixed-products when available for cleaner output.
Auth Mode Detection
Before scanning, determine auth mode (done once during Preflight, remembered for session):
okx config show --jsonhasapi_key→ API Key mode → all commands use--profile live- No API key +
okx auth status --jsonreturnslogged_in→ OAuth mode → no--profileflag
Below, [--profile live] means: add the flag only in API Key mode.
Scan Commands
Flash Earn
okx [--profile live] earn flash-earn projects --status 0,100 --jsonOutput: array of projects with fields:
id— project ID (part of dedupe key)status—0=upcoming,100=in-progresscanPurchase— boolean, whether user can subscribe nowbeginTime/endTime— formatted timestampsrewards— array of{amt, ccy}
Fixed Earn (定期) + Flexible Rate
CLI v1.3.3+:
okx [--profile live] earn savings fixed-products [--ccy <ccy>] --jsonCLI v1.3.2 (fallback): fixed-products command does not exist. Use rate-history which returns both flexible rate and fixed offers in one call:
okx [--profile live] earn savings rate-history --ccy <ccy> --limit 1 --jsonOutput structure (v1.3.2):
{
"data": [{"ccy": "USDT", "lendingRate": "0.0138", "ts": "..."}],
"fixedOffers": [{"ccy": "USDT", "term": "90D", "rate": "0.035", "lendQuota": "0", "minLend": "", "soldOut": true}]
}data[].lendingRate— current flexible APY (for comparison)fixedOffers[]— available fixed-term products withccy,term,rate,lendQuota,soldOut
Detection logic: Try earn savings fixed-products --json first. If command not found, fall back to earn savings rate-history --json and extract fixedOffers.
Flexible Earn (活期)
For each currency in config.flexible.currencies (default ["USDT", "USDC"]):
okx [--profile live] earn savings rate-history --ccy <ccy> --limit 1 --jsonOutput structure:
{
"data": [{"ccy": "USDC", "lendingRate": "0.0841", "ts": "1717020000000"}],
"fixedOffers": [...]
}data[0].lendingRate— current flexible APY (decimal, e.g."0.0841"= 8.41%)- The script iterates through configured currencies and collects
{ccy, lendingRate}pairs into a JSON array
If flexible.currencies is "all" or empty, defaults to ["USDT", "USDC"] (since the API requires a ccy parameter per call).
Filter Rules
Flash Earn
1. status=100 (in-progress) AND canPurchase=true → notify with "🟢 Available now" 2. status=0 (upcoming) → notify with "⏳ Upcoming" + countdown to beginTime. Do NOT mark as "available" 3. status=100 AND canPurchase=false → skip (sold out)
Sorting: upcoming (status=0) projects sort before in-progress (status=100) projects in the notification list.
Fixed Earn
1. soldOut=true → skip 2. lendQuota converts to zero or empty → skip. API returns string values; explicitly convert: Number(lendQuota) === 0 OR lendQuota === "" OR lendQuota is null/undefined → skip 3. If config.currencies is non-empty array or "all" string → "all" and [] both mean all currencies; non-empty array → only include matching ccy 4. APY 两层阈值过滤(all values in decimal form, e.g. 0.08 = 8%):
- 先查
config.currencyOverrides[ccy].minApy,有值则用它作为该币种阈值 - 没有 override → 使用
config.globalMinApy作为兜底阈值 - Compare
Number(rate)directly with threshold (both in decimal). IfNumber(rate) < threshold→ skip - Example: rate="0.092" (9.2%), minApy=0.08 (8%) → 0.092 >= 0.08 → pass
5. 期限筛选:
- 读取
config.terms(默认值"all") - 如果
terms是"all"→ 不过滤期限 - 如果
terms是数组(如["7D", "30D", "90D"])→ 只保留term在列表中的 offer
Flexible Earn
1. APY 两层阈值过滤(same pattern as Fixed, all values in decimal form):
- 先查
config.flexible.currencyOverrides[ccy].minApy,有值则用它作为该币种阈值 - 没有 override → 使用
config.flexible.globalMinApy作为兜底阈值(default0.08= 8%) - Compare
Number(lendingRate)directly with threshold (both in decimal). IfNumber(lendingRate) < threshold→ skip - Example: lendingRate="0.0841" (8.41%), minApy=0.08 (8%) → 0.0841 >= 0.08 → pass
Dedup Rules
State uses a hierarchical structure with separate flash, fixed, and flexible namespaces:
{
"flash": {
"<id>:<status>": { "notifiedAt": "<ISO 8601>" }
},
"fixed": {
"<ccy>:<term>:<rate>": { "notifiedAt": "<ISO 8601>" }
},
"flexible": {
"<ccy>": { "notifiedAt": "<ISO 8601>", "rate": "<lendingRate>" }
},
"consecutive_failures": 0,
"last_error": ""
}Before notifying, read ~/.okx/earn-hunter/state.json and check if key exists in the corresponding namespace. If exists → skip.
Key format:
- Flash:
<id>:<status>instate.flash(e.g.state.flash["12345:0"],state.flash["12345:100"]) - 状态从
0变为100时会生成新 key("12345:100"),触发第二次通知 - 效果:upcoming 通知一次,变为 in-progress 后再通知一次
- Fixed:
<ccy>:<term>:<rate>instate.fixed(e.g.state.fixed["USDT:7D:0.035"]) rate使用 API 返回的全精度字符串(不做四舍五入)- APY 变化视为新 offer,生成新 key,触发新通知
- Flexible:
<ccy>instate.flexible(e.g.state.flexible["USDC"]) - 阈值穿越模式:key 只用币种名,当 rate >= threshold 时通知一次
- rate 下降到 threshold 以下时,diff cleanup 移除 key
- 下次 rate 重新超过 threshold 时,生成新通知
- 效果:每个"高收益期"只通知一次,避免活期利率波动导致频繁通知
rate字段存储通知时的利率(仅供参考,不参与去重判断)
TTL (safety net — diff cleanup is the primary removal mechanism):
- Flash keys expire after 7 days
- Fixed keys expire after 7 days
- Flexible keys expire after 7 days
After notifying: add key to the corresponding namespace with ISO 8601 timestamp → state.flash["<id>:<status>"] = {"notifiedAt": "<ISO 8601>"} or state.fixed["<ccy>:<term>:<rate>"] = {"notifiedAt": "<ISO 8601>"} or state.flexible["<ccy>"] = {"notifiedAt": "<ISO 8601>", "rate": "<lendingRate>"}.
State Cleanup
每次扫描结束后执行三步清理(AI 直接读写 state.json):
Step 1: Diff 清理(移除已下架 offer)
Flash: 基于 project ID 维度清理(不是 key 精确匹配): 1. 收集本轮 flash_results 中所有 project id 2. 遍历 state.flash 中所有 key,提取 key 中的 id 部分(<id>:<status> 中的 <id>) 3. 如果 id 不在本轮 flash_results 中 → 删除该 key
效果:项目完全下架才清 state。状态从 0 变为 100, canPurchase=false(直接售罄)时,只要项目还在 API 返回中,state 中的 "<id>:0" key 不会被误删。
Fixed: 基于 key 精确匹配清理: 1. 收集本轮 fixed_results 中所有非 soldOut 产品的 key <ccy>:<term>:<rate> 2. 遍历 state.fixed 中所有 key 3. 如果 key 不在本轮 current_fixed_keys 中 → 删除
Flexible: 基于阈值穿越清理: 1. 收集本轮 flex_filtered(已过 APY 阈值过滤)中所有币种 key <ccy> 2. 遍历 state.flexible 中所有 key 3. 如果 key(去掉 test: 前缀后)不在本轮 current_flex_keys 中 → 删除(说明 rate 已低于阈值) 4. 效果:rate 低于阈值时移除 state,下次 rate 回升时可重新触发通知 5. 注意:flexible 的 `test:` key 也参与 diff 清理(不同于 flash/fixed 的豁免),因为 flexible 的清理语义是"利率是否仍在阈值以上",测试 fixture 的利率数据同样应参与此判断
Test namespace immunity (flash/fixed only): flash 和 fixed 的 test: 前缀 key 不参与 diff 清理(Test Mode 写入的 key 只受 TTL 清理影响),因为测试 fixture 的 ID/offer 不在真实 API 返回中,会被误删。Flexible 不适用此规则。
Step 2: TTL 清理(过期兜底)
遍历 state.flash、state.fixed 和 state.flexible 中所有 key,解析 notifiedAt 时间戳,删除超过 TTL 的条目:
- Flash keys: TTL = 7 天
- Fixed keys: TTL = 7 天
- Flexible keys: TTL = 7 天
Step 3: 失败计数更新
- 扫描成功完成(无论是否有新机会)→
state.consecutive_failures = 0,state.last_error = "" - 扫描失败(任何错误)→
state.consecutive_failures += 1,state.last_error = "<error message truncated to 200 chars>" consecutive_failures >= 3→ 发送"连续失败"告警(见templates/error-alert.md),然后重置为 0
Complete Scan Sequence
1. Read config (AI reads ~/.okx/earn-hunter/config.json directly)
flash_enabled = config.flash.enabled
fixed_enabled = config.fixed.enabled
flex_enabled = config.flexible.enabled
currencies = config.currencies
globalMinApy = config.fixed.globalMinApy
currencyOverrides = config.fixed.currencyOverrides
terms = config.fixed.terms
flex_min_apy = config.flexible.globalMinApy # default 0.08 (8%)
flex_currencies = config.flexible.currencies # default ["USDT","USDC"]
flex_ccy_overrides = config.flexible.currencyOverrides
verboseLog = config.verboseLog
2. Run scan commands (parallel where possible)
If flash_enabled is true:
flash_results = okx earn flash-earn projects --status 0,100 --json
If fixed_enabled is true:
fixed_results = okx earn savings fixed-products --json
If command not found (CLI <1.3.3), fallback:
fixed_results = okx earn savings rate-history --ccy <ccy> --limit 1 --json → extract fixedOffers
For APR comparison: fetch flexible rate for relevant currencies
If flex_enabled is true:
flex_results = []
For each ccy in flex_currencies:
out = okx earn savings rate-history --ccy <ccy> --limit 1 --json
Extract data[0].lendingRate → append {ccy, lendingRate} to flex_results
NOTE: all numeric fields from API are strings. Always convert before comparison:
Number(lendQuota), Number(rate), Number(lendingRate), etc.
3. Apply filters
flash_filtered = apply Flash Earn filter rules
fixed_filtered = apply Fixed Earn filter rules (两层 APY 阈值 + terms 筛选)
flex_filtered = apply Flexible Earn filter rules (两层 APY 阈值: override > globalMinApy)
4. Apply dedup (read state.json hierarchical structure)
flash_new = [p for p in flash_filtered if "<p.id>:<p.status>" not in state.flash]
fixed_new = [p for p in fixed_filtered if "<p.ccy>:<p.term>:<p.rate>" not in state.fixed]
flex_new = [p for p in flex_filtered if "<p.ccy>" not in state.flexible]
5. Notify (if any new opportunities)
Count sections with new opportunities. If >= 2, use mixed template.
If flash_new is non-empty:
Render notification using templates/flash-earn.md
Send via configured channel (see notify-channels.md)
For each notified project:
state.flash["<id>:<status>"] = {"notifiedAt": "<ISO 8601 now>"}
If fixed_new is non-empty:
Optionally fetch flexible rates for APR comparison
Render notification using templates/fixed-earn.md
Send via configured channel
For each notified product:
state.fixed["<ccy>:<term>:<rate>"] = {"notifiedAt": "<ISO 8601 now>"}
If flex_new is non-empty:
Render notification using templates/flexible-earn.md
Send via configured channel
For each notified currency:
state.flexible["<ccy>"] = {"notifiedAt": "<ISO 8601 now>", "rate": "<lendingRate>"}
6. Update state.json (AI reads, modifies, writes back)
# 6a. Flash diff cleanup: ID-level (not key-level)
# Collect all project IDs from raw flash_results
current_flash_ids = [p.id for each in flash_results]
For each key in state.flash:
Extract id from key (split by ":" → first element)
If id not in current_flash_ids → delete state.flash[key]
Skip any key starting with "test:" (Test Mode immunity)
# 6b. Fixed diff cleanup: key-level
# Use RAW scan results (before APY/currency/terms filter), only exclude soldOut
current_fixed_keys = ["<ccy>:<term>:<rate>" for each in fixed_results where NOT soldOut AND Number(lendQuota) > 0]
For each key in state.fixed:
If key not in current_fixed_keys → delete state.fixed[key]
Skip any key starting with "test:" (Test Mode immunity)
# 6c. Flexible diff cleanup: threshold-crossing (key = ccy)
# Use FILTERED results (after APY threshold filter) — only currencies still above threshold
current_flex_keys = [p.ccy for each in flex_filtered]
For each key in state.flexible:
Strip "test:" prefix if present, then check against current_flex_keys
If stripped key not in current_flex_keys → delete state.flexible[key]
# NOTE: test: keys are NOT immune here (unlike flash/fixed) — see State Cleanup section
# 6d. TTL cleanup: remove entries older than 7 days
For each key in state.flash:
Parse notifiedAt → if (now - notifiedAt) > 7 days → delete
For each key in state.fixed:
Parse notifiedAt → if (now - notifiedAt) > 7 days → delete
For each key in state.flexible:
Parse notifiedAt → if (now - notifiedAt) > 7 days → delete
# 6e. Failure counter update
state.consecutive_failures = 0
state.last_error = ""
7. No new opportunities → verboseLog 控制行为
If flash_new, fixed_new and flex_new are all empty:
If verboseLog is true:
Send brief status: "✅ Earn Hunter 扫描完成,暂无新机会。Flash: X 个活跃, Fixed: Y 个可申购, Flexible: Z 个达标。"
(X = len(flash_filtered), Y = len(fixed_filtered), Z = len(flex_filtered))
If verboseLog is false:
Silent exit (no output)
8. On scan error (any step 2-6 throws):
state.consecutive_failures += 1
state.last_error = error_message[:200]
If state.consecutive_failures >= 3:
Send alert using templates/error-alert.md "连续失败" template
state.consecutive_failures = 0 (reset after alerting)
If error is 401 / "Session expired":
Send alert using templates/error-alert.md "凭证失效" template
Do NOT continue scanning — stop until re-authenticated
Else (network/timeout):
Retry once. If still fails, skip this cycle.Scheduler Setup
Two scheduling models, selected by platform.json .scheduler.type:
- `openclaw-cron` (OpenClaw) — scheduled via the in-session `cron` agent tool, isolated + light-context, delivered back to the conversation via
announce. See OpenClaw. - `cron` (Claude Code / Hermes / Generic) — scheduled via OS crontab + `okx` CLI + curl notifications. No LLM sessions spawned — zero token cost. See OS Crontab.
For OS-crontab platforms, agent-platform /loop and cloud Routines are not recommended: each tick spawns an LLM session and isolated sessions cannot reliably push TG/Lark notifications. (OpenClaw is the deliberate exception — its in-session cron + announce delivery is the supported path.)
OpenClaw (in-session cron tool)
OpenClaw does not use OS crontab or the openclaw CLI (the CLI cron path has permission issues here). Scheduling is created inside the conversation by calling the in-session `cron` agent tool, so the job inherits the current session's channel and can deliver scan results straight back to the chat.
Encourage the user to set it up directly in the conversation, then call the tool — never print a shell or `openclaw cron` command.
Create the job
Call the cron tool, action: "add", with a job:
| Field | Value | Why |
|---|---|---|
name | "earn-hunter-hourly" | stable identifier for list/update/remove |
schedule | { "kind": "every", "everyMs": <ms> } | from platform.json .scheduler.interval (1h→3600000, 30m→1800000, 2h→7200000) |
sessionTarget | "isolated" | run isolated, don't disturb the main session |
payload | { "kind": "agentTurn", "message": "执行 earn-hunter 扫描", "lightContext": true } | the prompt routes to the Scan Cycle; lightContext keeps tokens low |
delivery | { "mode": "announce" } | push the turn's output to the conversation channel |
Tool budget (the token guardrail): OpenClaw cron jobs have no per-job tool-whitelist field — the old --tools exec,read,write flag no longer exists, and lightContext only trims bootstrap workspace files (it does not change which tools load). The scan stays cheap because it invokes the okx CLI via exec and does not depend on the 160+ okx MCP tools; provided the isolated cron agent isn't configured to attach the okx MCP server, only the regular tools (exec/read/write) are present. Tool loading is governed by the agent config, not the cron job.
Verification
1. cron tool action: "list" → confirm the earn-hunter-hourly job exists with the expected next-run time 2. After the first trigger, confirm the scan result was announced into the conversation 3. Check ~/.okx/earn-hunter/notify.log for a corresponding log entry
Management
- List:
cronaction: "list" - Pause:
cronaction: "update", patch{ "enabled": false }(oraction: "remove") - Resume:
cronaction: "update", patch{ "enabled": true } - Change frequency:
cronaction: "update", patchschedule.everyMs(and updateplatform.json.scheduler.interval)
OS Crontab Configuration
scan.sh is shipped with the skill at {baseDir}/scripts/scan.sh. During activation it is copied (not generated) to ~/.okx/earn-hunter/scan.sh:
mkdir -p ~/.okx/earn-hunter
cp {baseDir}/scripts/scan.sh ~/.okx/earn-hunter/scan.sh
chmod +x ~/.okx/earn-hunter/scan.shThe script (pure shell + jq) calls the okx CLI directly, filters/dedups, and sends notifications via curl to TG/Lark — with zero LLM cost. It exits silently (no output, nothing sent) when there are no new opportunities and verboseLog=false.
# Add to crontab (every hour). Set OKX_PROFILE only in API Key mode; omit for OAuth.
(crontab -l 2>/dev/null; echo "0 * * * * OKX_PROFILE=live ~/.okx/earn-hunter/scan.sh >> ~/.okx/earn-hunter/cron.log 2>&1") | crontab -To change frequency: crontab -e → modify the cron expression (e.g., */30 * * * * for every 30 minutes).
Prerequisites
jqmust be installed (the script uses it for all JSON processing):which jq→ if missing,brew install jq(macOS) orapt-get install jq(Linux).okxCLI installed and authenticated (~/.okx/config.toml). The script never reads or prints credentials; auth is fully delegated to the CLI.
Verification
1. crontab -l → confirm earn-hunter entry exists 2. After the first trigger, check ~/.okx/earn-hunter/cron.log for scan output 3. Check ~/.okx/earn-hunter/notify.log for a corresponding notification log entry
Management
crontab -l # list all cron jobs
crontab -l | grep -v 'earn-hunter' | crontab - # pause (remove entry)
# Re-add to resume (same command as initial setup)Frequency Configuration
The scheduler.interval field in platform.json records the user's preferred frequency. When setting up the scheduler:
- Read
platform.json→.scheduler.intervalto determine the interval - Default
"1h"= every hour - User can change via natural language: "把扫描频率改成 30 分钟" → update
platform.json.scheduler.intervalto"30m"+ update crontab expression
Common mappings:
- "每小时" / "1h" →
0 * * * * - "30 分钟" / "30m" →
*/30 * * * * - "2 小时" / "2h" →
0 */2 * * * - "15 分钟" / "15m" →
*/15 * * * *
Testing Tips
- Use a short interval for initial testing (e.g.
5m). Once you confirm the scheduler triggers correctly, change back to your preferred interval (default1h). - Temporarily enable `verboseLog` during testing (
config.json→"verboseLog": true). This ensures a notification is sent even when there are no new opportunities, making it easy to confirm the full pipeline works. Turn it off after testing.
#!/usr/bin/env bash
#
# earn-hunter scan.sh
#
# Pure shell + jq implementation of the earn-hunter Scan Cycle.
# Designed to run from OS crontab with ZERO LLM cost. Also invoked by the
# earn-hunter skill agent (which simply runs this script and relays its output).
#
# Implements references/scan-logic.md (8-step sequence) verbatim:
# 1. read config 2. call CLI (flash + fixed, with rate-history fallback)
# 3. filter 4. dedup (read state)
# 5. render + send 6. state cleanup (diff / TTL / failure counter)
# 7. verboseLog gate 8. error handling (consecutive failures, 401)
#
# SECURITY:
# - NEVER hardcodes API key/secret/passphrase. Auth is fully delegated to the
# okx CLI, which reads ~/.okx/config.toml itself.
# - NEVER runs any command that prints credentials (no `okx config show`, no dump).
# - Profile is injected via env var OKX_PROFILE (optional). When set the script
# adds `--profile "$OKX_PROFILE"`; when empty it passes no profile flag.
#
# TEST HOOKS (do not affect production paths when unset):
# EH_FLASH_FIXTURE path to a JSON file used INSTEAD of the live flash CLI call
# EH_FIXED_FIXTURE path to a JSON file used INSTEAD of the live fixed CLI call
# EH_FLEXIBLE_FIXTURE path to a JSON file used INSTEAD of the live flexible CLI calls
# EH_DRY_RUN=1 send functions echo the payload instead of curl-ing it
# EH_STATE_DIR override state dir (default ~/.okx/earn-hunter), for tests
# EH_FORCE_FAIL=1 simulate a scan failure (for failure-counter tests)
# EH_NOW_ISO override "now" ISO timestamp (for deterministic tests)
# EH_TEST_NAMESPACE=1 prefix dedup keys with "test:" (Test Mode)
#
set -uo pipefail
# ---------------------------------------------------------------------------
# 0a. Resolve tool paths (cron-safe — macOS cron PATH=/usr/bin:/bin only)
# ---------------------------------------------------------------------------
# Source env.snapshot if present (written during activation with known paths).
_EH_SNAPSHOT="${EH_STATE_DIR:-$HOME/.okx/earn-hunter}/env.snapshot"
# shellcheck disable=SC1090
[[ -f "$_EH_SNAPSHOT" ]] && source "$_EH_SNAPSHOT"
_resolve_bin() {
local name="$1" snap_var="$2"
local snap="${!snap_var:-}"
[[ -x "$snap" ]] && { printf '%s' "$snap"; return; }
local p
for p in \
"$(command -v "$name" 2>/dev/null || true)" \
/opt/homebrew/bin/"$name" \
/usr/local/bin/"$name" \
"$HOME/.npm-global/bin/$name" \
"${NVM_DIR:-$HOME/.nvm}/current/bin/$name" \
/usr/bin/"$name"; do
[[ -n "$p" && -x "$p" ]] && { printf '%s' "$p"; return; }
done
return 1
}
_OKX_BIN=$(_resolve_bin okx OKX_BIN) || { echo "[earn-hunter] FATAL: 'okx' not found (PATH=$PATH)" >&2; exit 127; }
_JQ_BIN=$(_resolve_bin jq JQ_BIN) || { echo "[earn-hunter] FATAL: 'jq' not found (PATH=$PATH)" >&2; exit 127; }
_NODE_BIN=$(_resolve_bin node NODE_BIN) || { echo "[earn-hunter] FATAL: 'node' not found (PATH=$PATH)" >&2; exit 127; }
# Inject resolved dirs into PATH so okx's #!/usr/bin/env node shebang works.
export PATH="$(dirname "$_NODE_BIN"):$(dirname "$_OKX_BIN"):$(dirname "$_JQ_BIN"):${PATH:-/usr/bin:/bin}"
# ---------------------------------------------------------------------------
# 0. Paths & globals
# ---------------------------------------------------------------------------
STATE_DIR="${EH_STATE_DIR:-$HOME/.okx/earn-hunter}"
CONFIG_FILE="$STATE_DIR/config.json"
PLATFORM_FILE="$STATE_DIR/platform.json"
STATE_FILE="$STATE_DIR/state.json"
NOTIFY_LOG="$STATE_DIR/notify.log"
STATE_INIT='{"flash":{},"fixed":{},"flexible":{},"consecutive_failures":0,"last_error":""}'
TTL_DAYS=7
# Profile flag injection — empty OKX_PROFILE => no flag.
PROFILE_ARGS=()
if [[ -n "${OKX_PROFILE:-}" ]]; then
PROFILE_ARGS=(--profile "$OKX_PROFILE")
fi
# Test namespace prefix for dedup keys (Test Mode immunity).
KEY_PREFIX=""
if [[ "${EH_TEST_NAMESPACE:-0}" == "1" ]]; then
KEY_PREFIX="test:"
fi
now_iso() {
if [[ -n "${EH_NOW_ISO:-}" ]]; then
printf '%s' "$EH_NOW_ISO"
else
date +%Y-%m-%dT%H:%M:%S%z 2>/dev/null
fi
}
now_hhmm() {
date +%H:%M 2>/dev/null
}
log_notify() {
# $1 channel $2 OK|FAIL $3 detail
printf '[%s] [%s] [%s] %s\n' "$(now_iso)" "$1" "$2" "$3" >> "$NOTIFY_LOG" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# 1. Init state dir / files
# ---------------------------------------------------------------------------
init_storage() {
mkdir -p "$STATE_DIR" 2>/dev/null || true
if [[ ! -f "$STATE_FILE" ]]; then
printf '%s\n' "$STATE_INIT" > "$STATE_FILE"
fi
# Validate / repair corrupted state.
if ! jq -e . "$STATE_FILE" >/dev/null 2>&1; then
printf '%s\n' "$STATE_INIT" > "$STATE_FILE"
fi
# Ensure required keys exist.
local fixed
fixed=$(jq -c '
{
flash: (.flash // {}),
fixed: (.fixed // {}),
flexible: (.flexible // {}),
consecutive_failures: (.consecutive_failures // 0),
last_error: (.last_error // "")
}' "$STATE_FILE" 2>/dev/null) || fixed="$STATE_INIT"
printf '%s\n' "$fixed" > "$STATE_FILE"
}
# ---------------------------------------------------------------------------
# Config accessors (with safe defaults if config missing)
# ---------------------------------------------------------------------------
cfg() {
# $1 jq filter, $2 default
local val
if [[ -f "$CONFIG_FILE" ]]; then
val=$(jq -r "$1 // empty" "$CONFIG_FILE" 2>/dev/null)
fi
if [[ -z "${val:-}" ]]; then printf '%s' "$2"; else printf '%s' "$val"; fi
}
cfg_json() {
# $1 jq filter, $2 default(json)
local val
if [[ -f "$CONFIG_FILE" ]]; then
val=$(jq -c "$1" "$CONFIG_FILE" 2>/dev/null)
fi
if [[ -z "${val:-}" || "$val" == "null" ]]; then printf '%s' "$2"; else printf '%s' "$val"; fi
}
# ---------------------------------------------------------------------------
# i18n
# ---------------------------------------------------------------------------
LANG_SEL="zh-CN"
resolve_lang() {
local l
l=$(cfg '.notify.language' 'auto')
if [[ "$l" == "auto" || -z "$l" ]]; then LANG_SEL="zh-CN"; else LANG_SEL="$l"; fi
}
t() {
# $1 = key. Returns localized string.
case "$1" in
flash_title) [[ "$LANG_SEL" == en ]] && echo "Flash Earn" || echo "Flash Earn" ;;
badge_inprogress) [[ "$LANG_SEL" == en ]] && echo "in-progress" || echo "进行中" ;;
badge_upcoming) [[ "$LANG_SEL" == en ]] && echo "upcoming" || echo "预告" ;;
flash_cta) [[ "$LANG_SEL" == en ]] && echo "→ Subscribe now ( https://okx.com/ul/rhNe3q )" || echo "→ 立即申购( https://okx.com/ul/rhNe3q )" ;;
fixed_filter) [[ "$LANG_SEL" == en ]] && echo "Filter" || echo "筛选条件" ;;
fixed_cta_session) [[ "$LANG_SEL" == en ]] && echo "→ Reply with amount to subscribe now" || echo "→ 回复申购金额,立即帮你申购" ;;
fixed_cta_push) [[ "$LANG_SEL" == en ]] && echo "→ Say \"subscribe %s fixed %s\" in your agent chat" || echo "→ 在对话中说\"申购 %s 定期 %s\"" ;;
flex_cta_session) [[ "$LANG_SEL" == en ]] && echo "→ Reply \"subscribe %s flexible earn\" to subscribe" || echo "→ 回复\"申购 %s 活期\"立即申购" ;;
flex_cta_push) [[ "$LANG_SEL" == en ]] && echo "→ Say \"subscribe %s flexible earn\" in your agent chat" || echo "→ 在对话中说\"申购 %s 活期\"" ;;
new_opps) [[ "$LANG_SEL" == en ]] && echo "new" || echo "个新机会" ;;
verbose_status) [[ "$LANG_SEL" == en ]] && echo "✅ Earn Hunter scan complete, no new opportunities. Flash: %s active, Fixed: %s subscribable, Flexible: %s above threshold." || echo "✅ Earn Hunter 扫描完成,暂无新机会。Flash: %s 个活跃, Fixed: %s 个可申购, Flexible: %s 个达标。" ;;
*) echo "" ;;
esac
}
# ---------------------------------------------------------------------------
# 2. CLI calls (fixture-aware, with retry for transient server errors)
# ---------------------------------------------------------------------------
# Retry wrapper: run a command up to 3 times with 3s delay on transient errors.
# Usage: retry_cmd okx ... --json
# Returns the output of the last attempt.
retry_cmd() {
local attempt out _ef _stderr
_ef=$(mktemp "${TMPDIR:-/tmp}/eh-stderr.XXXXXX")
for attempt in 1 2 3; do
out=$("$@" 2>"$_ef")
# stdout is clean (no node warnings); check if valid JSON.
if echo "$out" | jq -e 'type=="array" or .data' >/dev/null 2>&1; then
rm -f "$_ef"; printf '%s' "$out"; return 0
fi
# Auth errors are not transient — bail immediately.
_stderr=$(cat "$_ef" 2>/dev/null)
if is_auth_error "$out $_stderr"; then rm -f "$_ef"; printf '%s' "$out $_stderr"; return 1; fi
[[ "$attempt" -lt 3 ]] && sleep 3
done
_stderr=$(cat "$_ef" 2>/dev/null)
rm -f "$_ef"
printf '%s' "${out:+$out }$_stderr"; return 1
}
fetch_flash() {
if [[ -n "${EH_FLASH_FIXTURE:-}" ]]; then
cat "$EH_FLASH_FIXTURE" 2>/dev/null
return $?
fi
retry_cmd okx "${PROFILE_ARGS[@]+"${PROFILE_ARGS[@]}"}" earn flash-earn projects --status 0,100 --json
}
fetch_fixed() {
if [[ -n "${EH_FIXED_FIXTURE:-}" ]]; then
cat "$EH_FIXED_FIXTURE" 2>/dev/null
return $?
fi
local out
out=$(retry_cmd okx "${PROFILE_ARGS[@]+"${PROFILE_ARGS[@]}"}" earn savings fixed-products --json)
local rc=$?
# Fallback: fixed-products unavailable (CLI <1.3.3) → rate-history.fixedOffers
if [[ $rc -ne 0 || -z "$out" ]] || ! echo "$out" | jq -e 'type=="array"' >/dev/null 2>&1; then
local rh
rh=$(retry_cmd okx "${PROFILE_ARGS[@]+"${PROFILE_ARGS[@]}"}" earn savings rate-history --limit 1 --json)
out=$(echo "$rh" | jq -c '.fixedOffers // []' 2>/dev/null)
[[ -z "$out" ]] && out="[]"
fi
printf '%s' "$out"
}
fetch_flexible() {
if [[ -n "${EH_FLEXIBLE_FIXTURE:-}" ]]; then
cat "$EH_FLEXIBLE_FIXTURE" 2>/dev/null
return $?
fi
local ccys result="[]"
ccys=$(cfg_json '.flexible.currencies' '["USDT","USDC"]')
if [[ "$ccys" == '"all"' || "$ccys" == '[]' ]]; then
ccys='["USDT","USDC"]'
fi
local ccy_list
ccy_list=$(echo "$ccys" | jq -r '.[]' 2>/dev/null)
while IFS= read -r ccy; do
[[ -z "$ccy" ]] && continue
local out
out=$(retry_cmd okx "${PROFILE_ARGS[@]+"${PROFILE_ARGS[@]}"}" earn savings rate-history --ccy "$ccy" --limit 1 --json)
if [[ $? -eq 0 ]] && echo "$out" | jq -e '.data[0]' >/dev/null 2>&1; then
local rate
rate=$(echo "$out" | jq -r '.data[0].lendingRate // ""' 2>/dev/null)
if [[ -n "$rate" && "$rate" != "null" ]]; then
result=$(echo "$result" | jq -c --arg c "$ccy" --arg r "$rate" '. + [{ccy: $c, lendingRate: $r}]' 2>/dev/null)
fi
fi
done <<< "$ccy_list"
printf '%s' "$result"
}
# ---------------------------------------------------------------------------
# Auth-error detection (401 / session expired) — scan stdout/stderr text
# ---------------------------------------------------------------------------
is_auth_error() {
# $1 = combined output text
echo "$1" | grep -qiE '401|unauthorized|session expired|not authenticated|token expired|requires_auth' && return 0
return 1
}
# ---------------------------------------------------------------------------
# Notification senders (channel routing). DRY_RUN => echo only.
# ---------------------------------------------------------------------------
detect_channel() {
# Priority: explicit notify.channel override else auto (TG > Lark > session)
local ch
ch=$(jq -r '.notify.channel // "auto"' "$PLATFORM_FILE" 2>/dev/null)
[[ -z "$ch" || "$ch" == "null" ]] && ch="auto"
local tg_token_env tg_chat_env lark
tg_token_env=$(jq -r '.notify.telegram.bot_token_env // "TELEGRAM_BOT_TOKEN"' "$PLATFORM_FILE" 2>/dev/null)
tg_chat_env=$(jq -r '.notify.telegram.chat_id_env // "TELEGRAM_CHAT_ID"' "$PLATFORM_FILE" 2>/dev/null)
lark=$(jq -r '.notify.lark_webhook // ""' "$PLATFORM_FILE" 2>/dev/null)
local tg_token tg_chat
tg_token=$(printenv "$tg_token_env" 2>/dev/null || true)
tg_chat=$(printenv "$tg_chat_env" 2>/dev/null || true)
local tg_ready=0 lark_ready=0
[[ -n "$tg_token" && -n "$tg_chat" ]] && tg_ready=1
if [[ "$lark" == https://* && "$lark" == *"/hook/"* ]]; then lark_ready=1; fi
case "$ch" in
telegram) [[ $tg_ready -eq 1 ]] && { echo telegram; return; } ;;
lark) [[ $lark_ready -eq 1 ]] && { echo lark; return; } ;;
session) echo session; return ;;
esac
# auto / fell through
if [[ $tg_ready -eq 1 ]]; then echo telegram; return; fi
if [[ $lark_ready -eq 1 ]]; then echo lark; return; fi
echo session
}
send_telegram() {
# $1 = plain text message, $2 = dedup detail for log
local msg="$1" detail="$2"
local tg_token_env tg_chat_env
tg_token_env=$(jq -r '.notify.telegram.bot_token_env // "TELEGRAM_BOT_TOKEN"' "$PLATFORM_FILE" 2>/dev/null)
tg_chat_env=$(jq -r '.notify.telegram.chat_id_env // "TELEGRAM_CHAT_ID"' "$PLATFORM_FILE" 2>/dev/null)
local TOKEN CHAT_ID
TOKEN=$(printenv "$tg_token_env" 2>/dev/null)
CHAT_ID=$(printenv "$tg_chat_env" 2>/dev/null)
if [[ "${EH_DRY_RUN:-0}" == "1" ]]; then
echo "=== [DRY-RUN TG] chat=$CHAT_ID ==="
printf '%s\n' "$msg"
log_notify "TG" "OK" "$detail (dry-run)"
return 0
fi
local resp
resp=$(curl -s "https://api.telegram.org/bot${TOKEN}/sendMessage" \
--data-urlencode "chat_id=${CHAT_ID}" \
--data-urlencode "text=${msg}" \
-d "parse_mode=HTML" 2>/dev/null)
if echo "$resp" | jq -e '.ok == true' >/dev/null 2>&1; then
log_notify "TG" "OK" "$detail sent"
return 0
else
log_notify "TG" "FAIL" "$detail $(echo "$resp" | head -c 150)"
return 1
fi
}
send_lark() {
# $1 = title, $2 = markdown body, $3 = template color, $4 = detail
local title="$1" body="$2" color="$3" detail="$4"
local webhook
webhook=$(jq -r '.notify.lark_webhook // ""' "$PLATFORM_FILE" 2>/dev/null)
local payload
payload=$(jq -n --arg t "$title" --arg b "$body" --arg c "$color" '{
msg_type: "interactive",
card: {
schema: "2.0",
header: { title: { content: $t, tag: "plain_text" }, template: $c },
body: { elements: [ { tag: "markdown", content: $b } ] }
}
}')
if [[ "${EH_DRY_RUN:-0}" == "1" ]]; then
echo "=== [DRY-RUN LARK] $title ==="
printf '%s\n' "$body"
log_notify "LARK" "OK" "$detail (dry-run)"
return 0
fi
local resp
resp=$(curl -s -X POST "$webhook" -H "Content-Type: application/json" -d "$payload" 2>/dev/null)
if echo "$resp" | jq -e '.StatusCode == 0 or .code == 0' >/dev/null 2>&1; then
log_notify "LARK" "OK" "$detail sent"
return 0
else
log_notify "LARK" "FAIL" "$detail $(echo "$resp" | head -c 150)"
return 1
fi
}
send_session() {
# $1 = message, $2 = detail. Session = stdout (interactive relay).
local msg="$1" detail="$2"
printf '%s\n' "$msg"
log_notify "SESSION" "OK" "$detail"
return 0
}
# Unified dispatch. $1 title $2 plain-body $3 lark-color $4 detail
# Returns 0 if delivered (so caller may commit dedup keys), non-zero on failure.
dispatch() {
local title="$1" body="$2" color="$3" detail="$4"
local channel
channel=$(detect_channel)
local full
full="$(printf '%s\n\n%s' "$title" "$body")"
case "$channel" in
telegram) send_telegram "$full" "$detail" ;;
lark) send_lark "$title" "$body" "$color" "$detail" ;;
session) send_session "$full" "$detail" ;;
*) log_notify "NONE" "FAIL" "$detail no channel available"; return 1 ;;
esac
}
# ---------------------------------------------------------------------------
# Failure / auth / success state helpers (defined before MAIN uses them)
# ---------------------------------------------------------------------------
record_failure() {
# $1 = error message. Increments counter, alerts at >=3.
local emsg
emsg=$(printf '%s' "$1" | head -c 200)
local cur
cur=$(jq -r '.consecutive_failures // 0' "$STATE_FILE" 2>/dev/null)
[[ -z "$cur" ]] && cur=0
cur=$((cur + 1))
local tmp
tmp=$(jq --argjson c "$cur" --arg e "$emsg" '.consecutive_failures=$c | .last_error=$e' "$STATE_FILE" 2>/dev/null)
[[ -n "$tmp" ]] && printf '%s\n' "$tmp" > "$STATE_FILE"
if [[ "$cur" -ge 3 ]]; then
local title body err_display
if [[ -z "$emsg" ]]; then
if [[ "$LANG_SEL" == en ]]; then
err_display="(no error captured — likely cron PATH issue, check ~/.okx/earn-hunter/cron.log)"
else
err_display="(未捕获到错误信息 — 通常是 cron 的 PATH 找不到 okx/node,请查看 ~/.okx/earn-hunter/cron.log)"
fi
else
err_display="$emsg"
fi
if [[ "$LANG_SEL" == en ]]; then
title="🚨 Earn Hunter · 3 consecutive scan failures"
body=$(printf 'The last 3 scans all failed.\n\n🔍 Last error:\n %s\n\n🛠 Try:\n 1. Check network\n 2. Run `okx auth login`\n 3. Run `okx earn flash-earn projects --json` manually\n 4. Check cron.log: cat ~/.okx/earn-hunter/cron.log' "$err_display")
else
title="🚨 Earn Hunter · 连续 3 轮扫描失败"
body=$(printf '最近 3 次扫描均未成功完成。\n\n🔍 最后一次错误:\n %s\n\n🛠 排查建议:\n 1. 检查网络连接\n 2. 运行 `okx auth login` 确认凭证有效\n 3. 运行 `okx earn flash-earn projects --json` 手动测试 API\n 4. 检查日志: cat ~/.okx/earn-hunter/cron.log' "$err_display")
fi
dispatch "$title" "$body" "red" "error:consecutive_failures"
# Reset after alerting.
tmp=$(jq '.consecutive_failures=0' "$STATE_FILE" 2>/dev/null)
[[ -n "$tmp" ]] && printf '%s\n' "$tmp" > "$STATE_FILE"
fi
}
alert_auth() {
local title body
if [[ "$LANG_SEL" == en ]]; then
title="⚠ Earn Hunter · Credential expired"
body=$(printf 'OKX API credentials expired or invalid; scanning paused.\n\n🔑 Re-login:\n Run `okx-cex-auth login` to re-authenticate.\n Earn Hunter resumes on the next scan after auth.')
else
title="⚠ Earn Hunter · 凭证失效"
body=$(printf 'OKX API 凭证已过期或失效,扫描已暂停。\n\n🔑 重新登录:\n 运行 `okx-cex-auth login` 重新认证\n 认证完成后,Earn Hunter 将在下一轮自动恢复扫描')
fi
dispatch "$title" "$body" "orange" "error:auth_expired"
}
mark_success() {
local tmp
tmp=$(jq '.consecutive_failures=0 | .last_error=""' "$STATE_FILE" 2>/dev/null)
[[ -n "$tmp" ]] && printf '%s\n' "$tmp" > "$STATE_FILE"
}
# ---------------------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------------------
init_storage
resolve_lang
# Forced-failure test hook
if [[ "${EH_FORCE_FAIL:-0}" == "1" ]]; then
record_failure "forced failure (EH_FORCE_FAIL)"
exit 0
fi
FLASH_ENABLED=$(cfg '.flash.enabled' 'true')
FIXED_ENABLED=$(cfg '.fixed.enabled' 'true')
FLEX_ENABLED=$(cfg '.flexible.enabled' 'true')
VERBOSE=$(cfg '.verboseLog' 'false')
GLOBAL_MIN_APY=$(cfg '.fixed.globalMinApy' '0')
CCY_OVERRIDES=$(cfg_json '.fixed.currencyOverrides' '{}')
TERMS=$(cfg_json '.fixed.terms' '"all"')
CURRENCIES=$(cfg_json '.currencies' '"all"')
FLEX_MIN_APY=$(cfg '.flexible.globalMinApy' '0.08')
FLEX_CCY_OVERRIDES=$(cfg_json '.flexible.currencyOverrides' '{}')
# ---- Step 2: fetch raw data ----
FLASH_RAW="[]"
FIXED_RAW="[]"
FLEX_RAW="[]"
SCAN_ERR=""
FEEDS_ENABLED=0
FEEDS_FAILED=0
if [[ "$FLASH_ENABLED" == "true" ]]; then
FEEDS_ENABLED=$((FEEDS_ENABLED+1))
FLASH_RAW=$(fetch_flash)
if ! echo "$FLASH_RAW" | jq -e 'type=="array"' >/dev/null 2>&1; then
if is_auth_error "$FLASH_RAW"; then
alert_auth; exit 0
fi
SCAN_ERR="flash fetch failed: $(printf '%s' "$FLASH_RAW" | head -c 120)"
FLASH_RAW="[]"
FEEDS_FAILED=$((FEEDS_FAILED+1))
fi
fi
if [[ "$FIXED_ENABLED" == "true" ]]; then
FEEDS_ENABLED=$((FEEDS_ENABLED+1))
FIXED_RAW=$(fetch_fixed)
if ! echo "$FIXED_RAW" | jq -e 'type=="array"' >/dev/null 2>&1; then
if is_auth_error "$FIXED_RAW"; then
alert_auth; exit 0
fi
SCAN_ERR="${SCAN_ERR:+$SCAN_ERR; }fixed fetch failed: $(printf '%s' "$FIXED_RAW" | head -c 120)"
FIXED_RAW="[]"
FEEDS_FAILED=$((FEEDS_FAILED+1))
fi
fi
if [[ "$FLEX_ENABLED" == "true" ]]; then
FEEDS_ENABLED=$((FEEDS_ENABLED+1))
FLEX_RAW=$(fetch_flexible)
if ! echo "$FLEX_RAW" | jq -e 'type=="array"' >/dev/null 2>&1; then
if is_auth_error "$FLEX_RAW"; then
alert_auth; exit 0
fi
SCAN_ERR="${SCAN_ERR:+$SCAN_ERR; }flexible fetch failed: $(printf '%s' "$FLEX_RAW" | head -c 120)"
FLEX_RAW="[]"
FEEDS_FAILED=$((FEEDS_FAILED+1))
fi
fi
# Only count as scan failure if ALL enabled feeds failed.
# Partial failure → continue with the feeds that succeeded.
if [[ "$FEEDS_FAILED" -gt 0 && "$FEEDS_FAILED" -ge "$FEEDS_ENABLED" ]]; then
record_failure "$SCAN_ERR"
exit 0
fi
# ---- Step 3: filters (jq) ----
# Flash filter: status=100 & canPurchase=true (in-progress), OR status=0 (upcoming).
# Sort upcoming (0) before in-progress (100).
FLASH_FILTERED=$(echo "$FLASH_RAW" | jq -c '
[ .[]
| select(
((.status|tostring)=="0")
or (((.status|tostring)=="100") and (.canPurchase==true))
)
]
| sort_by((.status|tostring)=="100")
' 2>/dev/null)
[[ -z "$FLASH_FILTERED" ]] && FLASH_FILTERED="[]"
# Fixed filter: drop soldOut, drop lendQuota<=0/empty, currency filter,
# two-layer APY threshold (override > global), terms filter.
FIXED_FILTERED=$(echo "$FIXED_RAW" | jq -c \
--argjson gmin "$( [[ "$GLOBAL_MIN_APY" =~ ^-?[0-9.]+$ ]] && echo "$GLOBAL_MIN_APY" || echo 0 )" \
--argjson overrides "$CCY_OVERRIDES" \
--argjson terms "$TERMS" \
--argjson currencies "$CURRENCIES" '
def is_all($v): ($v=="all") or ($v|type=="array" and length==0);
[ .[]
| select(.soldOut != true)
| select((.lendQuota // "") as $lq | ($lq|tostring) != "" and ($lq|tonumber? // 0) > 0)
| select( is_all($currencies) or (($currencies|type=="array") and (.ccy as $c | $currencies | index($c))) )
| . as $o
| ( ($overrides[$o.ccy].minApy) // $gmin ) as $thr
| select( ((.rate // "0")|tonumber? // 0) >= $thr )
| select( is_all($terms) or (($terms|type=="array") and (.term as $tm | $terms | index($tm))) )
]
' 2>/dev/null)
[[ -z "$FIXED_FILTERED" ]] && FIXED_FILTERED="[]"
# Flexible filter: rate >= threshold (two-layer: override > global).
FLEX_FILTERED=$(echo "$FLEX_RAW" | jq -c \
--argjson gmin "$( [[ "$FLEX_MIN_APY" =~ ^-?[0-9.]+$ ]] && echo "$FLEX_MIN_APY" || echo 0.08 )" \
--argjson overrides "$FLEX_CCY_OVERRIDES" '
[ .[]
| ( ($overrides[.ccy].minApy) // $gmin ) as $thr
| select( ((.lendingRate // "0")|tonumber? // 0) >= $thr )
]
' 2>/dev/null)
[[ -z "$FLEX_FILTERED" ]] && FLEX_FILTERED="[]"
# ---- Step 4: dedup against state ----
FLASH_NEW=$(echo "$FLASH_FILTERED" | jq -c --slurpfile st "$STATE_FILE" --arg p "$KEY_PREFIX" '
($st[0].flash // {}) as $seen
| [ .[] | . + {_key: ($p + ((.id|tostring)) + ":" + (.status|tostring))}
| select(($seen[._key]) == null) ]
' 2>/dev/null)
[[ -z "$FLASH_NEW" ]] && FLASH_NEW="[]"
FIXED_NEW=$(echo "$FIXED_FILTERED" | jq -c --slurpfile st "$STATE_FILE" --arg p "$KEY_PREFIX" '
($st[0].fixed // {}) as $seen
| [ .[] | . + {_key: ($p + .ccy + ":" + .term + ":" + (.rate|tostring))}
| select(($seen[._key]) == null) ]
' 2>/dev/null)
[[ -z "$FIXED_NEW" ]] && FIXED_NEW="[]"
# Flexible dedup: key = <ccy> (threshold-crossing model).
FLEX_NEW=$(echo "$FLEX_FILTERED" | jq -c --slurpfile st "$STATE_FILE" --arg p "$KEY_PREFIX" '
($st[0].flexible // {}) as $seen
| [ .[] | . + {_key: ($p + .ccy)}
| select(($seen[._key]) == null) ]
' 2>/dev/null)
[[ -z "$FLEX_NEW" ]] && FLEX_NEW="[]"
N_FLASH_NEW=$(echo "$FLASH_NEW" | jq 'length' 2>/dev/null); [[ -z "$N_FLASH_NEW" ]] && N_FLASH_NEW=0
N_FIXED_NEW=$(echo "$FIXED_NEW" | jq 'length' 2>/dev/null); [[ -z "$N_FIXED_NEW" ]] && N_FIXED_NEW=0
N_FLEX_NEW=$(echo "$FLEX_NEW" | jq 'length' 2>/dev/null); [[ -z "$N_FLEX_NEW" ]] && N_FLEX_NEW=0
N_FLASH_FILT=$(echo "$FLASH_FILTERED" | jq 'length' 2>/dev/null); [[ -z "$N_FLASH_FILT" ]] && N_FLASH_FILT=0
N_FIXED_FILT=$(echo "$FIXED_FILTERED" | jq 'length' 2>/dev/null); [[ -z "$N_FIXED_FILT" ]] && N_FIXED_FILT=0
N_FLEX_FILT=$(echo "$FLEX_FILTERED" | jq 'length' 2>/dev/null); [[ -z "$N_FLEX_FILT" ]] && N_FLEX_FILT=0
# Pre-detect channel so CTA text can adapt (session=interactive, TG/Lark=push).
NOTIFY_CHANNEL=$(detect_channel)
# ---- Rendering ----
render_flash_lines() {
echo "$1" | jq -r --arg ip "$(t badge_inprogress)" --arg up "$(t badge_upcoming)" '
.[] |
( (.name // .projectName // "") ) as $nm |
( if $nm=="" or $nm==null then "(unnamed)" else $nm end ) as $name |
( (.ccy // (.rewards[0].ccy) // "") ) as $c |
( if $c=="" or $c==null then "-" else $c end ) as $ccy |
( (.apy // .rate // "") ) as $a |
( if $a=="" or $a==null then "-" else ((((($a|tonumber?) // 0)*10000)|round)/100 | tostring) end ) as $apy |
( if (.status|tostring)=="100" then "🟢 " + $ip else "⏳ " + $up end ) as $badge |
"• " + $name + " · " + $ccy + " · " + $apy + "% APY [" + $badge + "]"
' 2>/dev/null
}
render_fixed_table() {
# markdown table rows
echo "$1" | jq -r '
.[] |
( if (.rate==null or .rate=="") then "-" else (((((.rate|tonumber?)//0)*10000)|round)/100 | tostring) + "%" end ) as $rate |
( if (.minLend==null or .minLend=="") then "-" else (.minLend|tostring) end ) as $min |
( if (.lendQuota==null or .lendQuota=="") then "-" else (.lendQuota|tostring) end ) as $rem |
"| " + (.ccy // "-") + " | " + (.term // "-") + " | " + $rate + " | " + $min + " | " + $rem + " |"
' 2>/dev/null
}
build_flash_body() {
local lines; lines=$(render_flash_lines "$FLASH_NEW")
printf '%s\n\n%s' "$lines" "$(t flash_cta)"
}
build_fixed_body() {
local rows; rows=$(render_fixed_table "$FIXED_NEW")
local hdr="| Currency | Term | APR | Min | Remaining |
|----------|------|-----|-----|-----------|"
local ccy term cta
ccy=$(echo "$FIXED_NEW" | jq -r '.[0].ccy // ""' 2>/dev/null)
term=$(echo "$FIXED_NEW" | jq -r '.[0].term // ""' 2>/dev/null)
if [[ "$NOTIFY_CHANNEL" == "session" ]]; then
cta=$(t fixed_cta_session)
else
# shellcheck disable=SC2059
cta=$(printf "$(t fixed_cta_push)" "$ccy" "$term")
fi
printf '%s\n%s\n\n%s' "$hdr" "$rows" "$cta"
}
render_flexible_table() {
echo "$1" | jq -r '
.[] |
( if (.lendingRate==null or .lendingRate=="") then "-"
else (((((.lendingRate|tonumber?)//0)*10000)|round)/100 | tostring) + "%" end ) as $apy |
"| " + (.ccy // "-") + " | " + $apy + " |"
' 2>/dev/null
}
build_flexible_body() {
local rows; rows=$(render_flexible_table "$FLEX_NEW")
local hdr="| Currency | APY |
|----------|-----|"
local ccy cta
ccy=$(echo "$FLEX_NEW" | jq -r '.[0].ccy // ""' 2>/dev/null)
if [[ "$NOTIFY_CHANNEL" == "session" ]]; then
# shellcheck disable=SC2059
cta=$(printf "$(t flex_cta_session)" "$ccy")
else
# shellcheck disable=SC2059
cta=$(printf "$(t flex_cta_push)" "$ccy")
fi
printf '%s\n%s\n\n%s' "$hdr" "$rows" "$cta"
}
DELIVERED=0
# Count how many types have new opportunities.
SECTION_COUNT=0
[[ "$N_FLASH_NEW" -gt 0 ]] && SECTION_COUNT=$((SECTION_COUNT+1))
[[ "$N_FIXED_NEW" -gt 0 ]] && SECTION_COUNT=$((SECTION_COUNT+1))
[[ "$N_FLEX_NEW" -gt 0 ]] && SECTION_COUNT=$((SECTION_COUNT+1))
if [[ "$SECTION_COUNT" -ge 2 ]]; then
# Mixed: two or more types.
title="🎯 Earn Hunter · $(now_hhmm)"
body=""
detail_parts=""
if [[ "$N_FLASH_NEW" -gt 0 ]]; then
body=$(printf '⚡ Flash Earn · %s %s\n\n%s' "$N_FLASH_NEW" "$(t new_opps)" "$(build_flash_body)")
detail_parts="flash:${N_FLASH_NEW}"
fi
if [[ "$N_FIXED_NEW" -gt 0 ]]; then
[[ -n "$body" ]] && body=$(printf '%s\n\n---\n\n' "$body")
body=$(printf '%s🏦 Fixed Earn · %s %s\n\n%s' "$body" "$N_FIXED_NEW" "$(t new_opps)" "$(build_fixed_body)")
detail_parts="${detail_parts:+$detail_parts+}fixed:${N_FIXED_NEW}"
fi
if [[ "$N_FLEX_NEW" -gt 0 ]]; then
[[ -n "$body" ]] && body=$(printf '%s\n\n---\n\n' "$body")
body=$(printf '%s💰 Simple Earn · %s %s\n\n%s' "$body" "$N_FLEX_NEW" "$(t new_opps)" "$(build_flexible_body)")
detail_parts="${detail_parts:+$detail_parts+}flex:${N_FLEX_NEW}"
fi
if dispatch "$title" "$body" "green" "mixed:${detail_parts}"; then DELIVERED=1; fi
elif [[ "$N_FLASH_NEW" -gt 0 ]]; then
title="⚡ Flash Earn · ${N_FLASH_NEW} $(t new_opps) · $(now_hhmm)"
body=$(build_flash_body)
if dispatch "$title" "$body" "purple" "flash:${N_FLASH_NEW}"; then DELIVERED=1; fi
elif [[ "$N_FIXED_NEW" -gt 0 ]]; then
title="🏦 Fixed Earn · ${N_FIXED_NEW} $(t new_opps) · $(now_hhmm)"
body=$(build_fixed_body)
if dispatch "$title" "$body" "blue" "fixed:${N_FIXED_NEW}"; then DELIVERED=1; fi
elif [[ "$N_FLEX_NEW" -gt 0 ]]; then
title="💰 Simple Earn · ${N_FLEX_NEW} $(t new_opps) · $(now_hhmm)"
body=$(build_flexible_body)
if dispatch "$title" "$body" "orange" "flex:${N_FLEX_NEW}"; then DELIVERED=1; fi
fi
# ---- Step 6: commit dedup keys (only for delivered notifications) ----
NOW="$(now_iso)"
if [[ "$DELIVERED" == "1" ]]; then
if [[ "$N_FLASH_NEW" -gt 0 ]]; then
tmp=$(jq --slurpfile new <(echo "$FLASH_NEW") --arg now "$NOW" '
reduce $new[0][] as $o (.; .flash[$o._key] = {notifiedAt: $now})
' "$STATE_FILE" 2>/dev/null)
[[ -n "$tmp" ]] && printf '%s\n' "$tmp" > "$STATE_FILE"
fi
if [[ "$N_FIXED_NEW" -gt 0 ]]; then
tmp=$(jq --slurpfile new <(echo "$FIXED_NEW") --arg now "$NOW" '
reduce $new[0][] as $o (.; .fixed[$o._key] = {notifiedAt: $now})
' "$STATE_FILE" 2>/dev/null)
[[ -n "$tmp" ]] && printf '%s\n' "$tmp" > "$STATE_FILE"
fi
if [[ "$N_FLEX_NEW" -gt 0 ]]; then
tmp=$(jq --slurpfile new <(echo "$FLEX_NEW") --arg now "$NOW" '
reduce $new[0][] as $o (.; .flexible[$o._key] = {notifiedAt: $now, rate: $o.lendingRate})
' "$STATE_FILE" 2>/dev/null)
[[ -n "$tmp" ]] && printf '%s\n' "$tmp" > "$STATE_FILE"
fi
fi
# ---- Step 6a/6b: diff cleanup (skip test: keys) ----
# Flash: ID-level. Keep keys whose id is still present in raw flash_results.
CURRENT_FLASH_IDS=$(echo "$FLASH_RAW" | jq -c '[ .[] | (.id|tostring) ]' 2>/dev/null); [[ -z "$CURRENT_FLASH_IDS" ]] && CURRENT_FLASH_IDS="[]"
tmp=$(jq --argjson ids "$CURRENT_FLASH_IDS" '
.flash = ( .flash | with_entries(
select(
(.key|startswith("test:"))
or ( ( .key | sub("^test:";"") | split(":")[0] ) as $id | ($ids | index($id)) )
)
) )
' "$STATE_FILE" 2>/dev/null)
[[ -n "$tmp" ]] && printf '%s\n' "$tmp" > "$STATE_FILE"
# Fixed: key-level. current_fixed_keys = raw offers not soldOut and lendQuota>0.
CURRENT_FIXED_KEYS=$(echo "$FIXED_RAW" | jq -c '
[ .[] | select(.soldOut != true) | select(((.lendQuota // "")|tostring) != "" and ((.lendQuota|tonumber?)//0) > 0)
| (.ccy + ":" + .term + ":" + (.rate|tostring)) ]
' 2>/dev/null); [[ -z "$CURRENT_FIXED_KEYS" ]] && CURRENT_FIXED_KEYS="[]"
tmp=$(jq --argjson keys "$CURRENT_FIXED_KEYS" '
.fixed = ( .fixed | with_entries(
select(
(.key|startswith("test:"))
or ( ( .key | sub("^test:";"") ) as $k | ($keys | index($k)) )
)
) )
' "$STATE_FILE" 2>/dev/null)
[[ -n "$tmp" ]] && printf '%s\n' "$tmp" > "$STATE_FILE"
# Flexible: key-level. Keep keys whose ccy is still above threshold (in FLEX_FILTERED).
CURRENT_FLEX_KEYS=$(echo "$FLEX_FILTERED" | jq -c '[ .[] | .ccy ]' 2>/dev/null); [[ -z "$CURRENT_FLEX_KEYS" ]] && CURRENT_FLEX_KEYS="[]"
tmp=$(jq --argjson keys "$CURRENT_FLEX_KEYS" '
.flexible = ( .flexible | with_entries(
select(
( .key | sub("^test:";"") ) as $k | ($keys | index($k))
)
) )
' "$STATE_FILE" 2>/dev/null)
[[ -n "$tmp" ]] && printf '%s\n' "$tmp" > "$STATE_FILE"
# ---- Step 6c: TTL cleanup (7 days) ----
# Compute cutoff epoch. notifiedAt parsed via jq fromdateiso8601 best-effort.
NOW_EPOCH=$(date +%s 2>/dev/null)
CUTOFF=$(( NOW_EPOCH - TTL_DAYS * 86400 ))
tmp=$(jq --argjson cutoff "$CUTOFF" '
def keep(e):
( e.notifiedAt // "" ) as $ts
| if $ts=="" then true
else ( ($ts | sub("\\+.*$";"Z") | sub("Z$";"Z") | try (fromdateiso8601) catch null) ) as $ep
| if $ep==null then true else ($ep >= $cutoff) end
end;
.flash = (.flash | with_entries(select(keep(.value))))
| .fixed = (.fixed | with_entries(select(keep(.value))))
| .flexible = (.flexible | with_entries(select(keep(.value))))
' "$STATE_FILE" 2>/dev/null)
[[ -n "$tmp" ]] && printf '%s\n' "$tmp" > "$STATE_FILE"
# ---- Step 6d: success ----
mark_success
# ---- Step 7: verboseLog gate when no new opportunities ----
if [[ "$N_FLASH_NEW" -eq 0 && "$N_FIXED_NEW" -eq 0 && "$N_FLEX_NEW" -eq 0 ]]; then
if [[ "$VERBOSE" == "true" ]]; then
# shellcheck disable=SC2059
msg=$(printf "$(t verbose_status)" "$N_FLASH_FILT" "$N_FIXED_FILT" "$N_FLEX_FILT")
dispatch "Earn Hunter" "$msg" "grey" "verbose:no_new"
fi
# else: SILENT. No output, exit 0.
exit 0
fi
exit 0
Activation Confirmation Template
Displayed after user activates Earn Hunter successfully. Summarizes the configuration and first scan result.
Render in user's language. Brand/token names are never translated.
Structure
🎯 Earn Hunter 已激活
📋 监控配置
扫描时间:{scan_time}
扫描品种:{scan_scope}
定期 — 币种:{currencies},阈值:{min_apy_display},期限:{terms}
活期 — 币种:{flex_currencies},阈值:{flex_min_apy_display}
模式:实盘
🔍 首次扫描结果
{first_scan_result}
⏰ 扫描频次
每小时 1 次(默认)
如需修改,对我说"把扫描频率改成 30 分钟",或编辑 platform.json 的 scheduler.interval 字段
📢 通知渠道
{channel_display}
{channel_hint}Data Fields
Configuration
{scan_time}— activation timestamp, ISO 8601 format (e.g.2026-05-20T14:30:00+08:00){scan_scope}— combination of enabled types- all three → "Flash Earn + Fixed Earn + Flexible Earn"
- only flash → "仅 Flash Earn"
- only fixed → "仅 Fixed Earn"
- only flexible → "仅 Flexible Earn"
- any other combination → list enabled types with " + " separator
{currencies}— comma-separated list of monitored currencies for Fixed Earn (e.g.USDT, USDC, BTC){flex_currencies}— comma-separated list of monitored currencies for Flexible Earn (e.g.USDT, USDC){min_apy_display}— Fixed Earn: whenglobalMinApy = 0, display "不限" (zh) or "No limit" (en); otherwise format asAPR ≥ X.XX%{flex_min_apy_display}— Flexible Earn: format asAPY ≥ X.XX%(default 8.00%){terms}— term filter display (e.g.7D, 14D, 30Dor全部期限)
First Scan Result
- If opportunities found:
发现 {X} 个机会(详见下方通知) - If no opportunities:
暂无新机会,将在下一轮扫描时检查
Notification Channel
{channel_display}— active channel name (e.g.Lark 推送,Telegram,当前会话){channel_hint}— guidance when channel is "session" (current conversation):
💡 当前通知仅在会话内显示。如需离线推送,配置 Lark 或 Telegram 渠道:
编辑 platform.json 中的 notify.channel 字段When channel is Lark or Telegram, {channel_hint} is empty.
Locked Terms (do not translate)
Earn Hunter, Flash Earn, Fixed Earn, Simple Earn, APR, APY, OKX — brand/financial terms stay as-is. Currency symbols stay as-is.
Example (zh-CN)
🎯 Earn Hunter 已激活
📋 监控配置
扫描时间:2026-05-20T14:30:00+08:00
扫描品种:Flash Earn + Fixed Earn + Flexible Earn
定期 — 币种:USDT, USDC,阈值:APR ≥ 3.00%,期限:7D, 14D, 30D
活期 — 币种:USDT, USDC,阈值:APY ≥ 8.00%
模式:实盘
🔍 首次扫描结果
发现 3 个机会(详见下方通知)
⏰ 扫描频次
每小时 1 次(默认)
如需修改,对我说"把扫描频率改成 30 分钟",或编辑 platform.json 的 scheduler.interval 字段
📢 通知渠道
当前会话
💡 当前通知仅在会话内显示。如需离线推送,配置 Lark 或 Telegram 渠道:
编辑 platform.json 中的 notify.channel 字段Error Alert Template
Displayed when Earn Hunter encounters operational errors that require user attention.
Render in user's language. Brand/token names are never translated.
Scenario 1: Credential Expired
Trigger: OKX API returns authentication error (401, token expired, session invalid).
⚠ Earn Hunter · 凭证失效
OKX API 凭证已过期或失效,扫描已暂停。
🔑 重新登录:
运行 `okx-cex-auth login` 重新认证
或在 Claude Code 中说"登录 OKX"加载 okx-cex-auth skill
认证完成后,Earn Hunter 将在下一轮自动恢复扫描
📋 影响:
暂停期间的机会将不会被通知Scenario 2: Consecutive Scan Failures (3 rounds)
Trigger: scan has failed for 3 consecutive rounds (any error type: network, API, parse error, etc.).
🚨 Earn Hunter · 连续 3 轮扫描失败
最近 3 次扫描均未成功完成。
🔍 最后一次错误:
{error_message}
🛠 排查建议:
1. 检查网络连接
2. 运行 `okx auth login` 确认凭证有效
3. 运行 `okx earn flash-earn projects --json` 手动测试 API
4. 如持续失败,检查 OKX 服务状态
📋 影响:
扫描仍在重试中,恢复后将自动继续推送Data Fields
{error_message}— last error message from the failed scan (truncated to 200 chars if needed)
Empty Error Fallback
If {error_message} is an empty string, replace the error line with:
🔍 最后一次错误:
(未捕获到错误信息)
💡 这通常是 cron 的 PATH 找不到 okx/node/jq 导致的。
请检查 ~/.okx/earn-hunter/cron.log 获取详细信息。
临时修复:在 crontab 行首添加 PATH=... 指向 node/okx/jq 所在目录。en:
🔍 Last error:
(no error message captured)
💡 This usually means cron's PATH cannot find okx/node/jq.
Check ~/.okx/earn-hunter/cron.log for details.
Quick fix: prepend PATH=... to the crontab entry pointing to node/okx/jq directories.Locked Terms (do not translate)
Earn Hunter, OKX — brand terms stay as-is. CLI commands stay as-is (e.g. okx auth login).
Lark Card Format
- Credential expired: use
template: "orange"for header - Consecutive failures: use
template: "red"for header
Fixed Earn Notification Template
Render the notification in the user's language (detected from conversation context or client locale). The structure and data fields below are language-neutral — translate all labels and copy naturally.
Title Format
🏦 Fixed Earn · {n} 个新机会 · {hh:mm}{n}— number of new products in this batch{hh:mm}— scan completion time (user's local timezone)
Effective Threshold Display
Show the active filter thresholds that produced these results:
{effective_threshold_display}Example: 筛选条件:APR ≥ 3.00%,币种 USDT/USDC
Product Table
| Currency | Term | APR | Min | Remaining |
|---|---|---|---|---|
{ccy} | {term} | {rate}% | {minLend} | {lendQuota} |
Data Fields
{ccy}— currency (keep as-is){term}— lock period (e.g.7D,30D){rate}— annualized APR, display as percentage with 2 decimals{minLend}— minimum subscription amount{lendQuota}— remaining subscribable amount
Empty Field Handling
All numeric/amount/rate fields must be checked before rendering. If a field value is null, "" (empty string), or undefined, display - instead:
{rate}is empty → render as-(not0%or blank){minLend}is empty → render as-{lendQuota}is empty → render as-{lendingRate}(in APR comparison) is empty → skip the comparison section entirely
Always check before formatting: e.g. do NOT attempt to calculate uplift if either rate or lendingRate is empty.
APR Comparison (optional)
Only show when rate > lendingRate. Display:
📊 收益对比:
活期 APY {lendingRate}% → 定期 APR {rate}%(+{uplift}%,锁 {term}){lendingRate}— current flexible APY for same currency{uplift}=rate - lendingRate, display with 2 decimals
Call to Action
Interactive Channel (session)
→ 回复申购金额,立即帮你申购User can reply with amount directly; agent proceeds to purchase-guide flow.
Non-interactive Channel (TG / Lark push)
→ 打开 Claude Code 说"申购 {ccy} 定期 {term}"Adapt command language to user's locale:
- zh:
"申购 {ccy} 定期 {term}" - en:
"subscribe {ccy} fixed {term}"
Locked Terms (do not translate)
Fixed Earn, Simple Earn, APR, APY, OKX — brand/financial terms stay as-is. Currency symbols (USDT, BTC, ETH, USDC) stay as-is.
Lark Card Format
Use template: "blue" for header. Body: table element for products + markdown elements for comparison and CTA.
Example (zh-CN)
🏦 Fixed Earn · 3 个新机会 · 14:30
筛选条件:APR ≥ 3.00%,币种 USDT/USDC
| Currency | Term | APR | Min | Remaining |
|----------|------|-------|------|-----------|
| USDT | 7D | 4.50% | 100 | 50,000 |
| USDT | 30D | 5.20% | 100 | 30,000 |
| USDC | 14D | 3.80% | 50 | 80,000 |
📊 收益对比:
活期 APY 2.10% → 定期 APR 4.50%(+2.40%,锁 7D)
→ 回复申购金额,立即帮你申购Flash Earn Notification Template
Render the notification in the user's language (detected from conversation context or client locale). The structure and data fields below are language-neutral — translate all labels and copy naturally.
Title Format
⚡ Flash Earn · {n} 个新机会 · {hh:mm}{n}— number of new projects in this batch{hh:mm}— scan completion time (user's local timezone)
Body Format
Each project occupies one line:
• {project_name} · {ccy} · {apy}% APY [{status_badge}]Status Badge Logic
| status | canPurchase | Badge (render in user's language) |
|---|---|---|
| 100 | true | 🟢 进行中 / in-progress |
| 0 | - | ⏳ 预告 / upcoming |
Note: status=100 + canPurchase=false (sold out) is already filtered out in scan-logic.md and will never reach this template.
Data Fields
{project_name}— project display name{ccy}— reward currency (keep as-is, e.g. OKB, USDT){apy}— annualized yield, display as percentage with 2 decimals{status_badge}— "预告" for upcoming (status=0), "进行中" for in-progress (status=100 + canPurchase=true)
Empty Field Handling
All numeric/rate fields must be checked before rendering. If a field value is null, "" (empty string), or undefined, display - instead:
{apy}is empty → render as-(not0%or blank){project_name}is empty → render as(unnamed){ccy}is empty → render as-
Always check before formatting: e.g. do NOT attempt to format null as a percentage.
Call to Action
→ 立即申购( https://okx.com/ul/rhNe3q )Link opens OKX App directly to Flash Earn. Emphasize first-come-first-served urgency.
Rendering Rules
- All copy rendered in user's language
- Brand names and token names are never translated (see Locked Terms)
- Upcoming projects show countdown to
beginTimeif available
Locked Terms (do not translate)
Flash Earn, OKX, OKB, USDT, USDC, BTC, ETH — brand/token names stay as-is.
Lark Card Format
Use template: "purple" for header. Body: 2-3 markdown elements (badge + details + CTA).
Example (zh-CN)
⚡ Flash Earn · 2 个新机会 · 14:30
• OKB Staking Boost · OKB · 12.50% APY [🟢 进行中]
• USDT Launch Pool · USDT · 8.20% APY [⏳ 预告]
→ 立即参与Flexible Earn (Simple Earn) Notification Template
Render the notification in the user's language (detected from conversation context or client locale). The structure and data fields below are language-neutral — translate all labels and copy naturally.
Title Format
💰 Simple Earn · {n} 个新机会 · {hh:mm}{n}— number of currencies newly above threshold in this batch{hh:mm}— scan completion time (user's local timezone)
Body Format
Rate Table
| Currency | APY |
|---|---|
{ccy} | {lendingRate}% |
Data Fields
{ccy}— currency (keep as-is, e.g. USDC, USDT){lendingRate}— current flexible lending APY, display as percentage with 2 decimals
Empty Field Handling
{lendingRate}is empty → render as-(not0%or blank){ccy}is empty → render as-
Threshold Display (optional)
Show the active APY threshold:
筛选条件:APY ≥ {threshold}%{threshold}— the effective APY threshold in percentage form (e.g.8.00)- Only show when
globalMinApy > 0
Call to Action
Interactive Channel (session)
→ 回复"申购 {ccy} 活期"立即申购Non-interactive Channel (TG / Lark push)
→ 通过 OKX App 申购,或说"申购 {ccy} 活期"Adapt command language to user's locale:
- zh:
"申购 {ccy} 活期" - en:
"subscribe {ccy} flexible earn"
Dedup Behavior
Flexible earn uses a threshold-crossing dedup model:
- Notifies once when a currency's APY crosses above the threshold
- Stays silent while the APY remains above the threshold
- When APY drops below the threshold, the dedup key is cleaned up
- If APY rises above the threshold again, a new notification is sent
This means each currency generates at most one notification per "above-threshold" period.
Locked Terms (do not translate)
Simple Earn, APY, OKX — brand/financial terms stay as-is. Currency symbols (USDT, USDC, BTC, ETH) stay as-is.
Lark Card Format
Use template: "orange" for header. Body: table element for rates + markdown for threshold and CTA.
Example (zh-CN)
💰 Simple Earn · 2 个新机会 · 14:30
| Currency | APY |
|----------|-------|
| USDC | 8.41% |
| USDT | 8.12% |
筛选条件:APY ≥ 8.00%
→ 通过 OKX App 申购,或说"申购 USDC 活期"Mixed Notification Template
When two or more opportunity types (Flash Earn, Fixed Earn, Flexible Earn) are found in the same scan round, merge them into a single message instead of sending separate notifications.
Render the notification in the user's language. Brand/token names are never translated.
Title Format
🎯 Earn Hunter · {hh:mm}{hh:mm}— scan completion time (user's local timezone)
Body Structure
Sections are ordered: Flash Earn → Fixed Earn → Flexible Earn, separated by dividers. Only include sections that have new opportunities.
⚡ Flash Earn · {n_flash} 个新机会
• {project_name} · {ccy} · {apy}% APY [{status_badge}]
• ...
→ 立即参与
---
🏦 Fixed Earn · {n_fixed} 个新机会
{effective_threshold_display}
| Currency | Term | APR | Min | Remaining |
|----------|------|-------|------|-----------|
| ... | ... | ... | ... | ... |
📊 收益对比:
活期 APY {lendingRate}% → 定期 APR {rate}%(+{uplift}%,锁 {term})
→ 回复申购金额,立即帮你申购
---
💰 Simple Earn · {n_flex} 个新机会
| Currency | APY |
|----------|-------|
| ... | ... |
→ 通过 OKX App 申购,或说"申购 {ccy} 活期"APR Comparison Note
When multiple Fixed Earn products are listed, show the APR comparison for the highest APR product only. Format:
📊 收益对比:
活期 APY {lendingRate}% → 定期 APR {best_rate}%(+{uplift}%,锁 {best_term})If only one product exists, show its comparison directly. The comparison section is optional — only show when best_rate > lendingRate.
Section Rules
- Flash section: follow
flash-earn.mdtemplate format (project lines + status badges + CTA) - Fixed section: follow
fixed-earn.mdtemplate format (threshold display + product table + APR comparison + CTA) - Flexible section: follow
flexible-earn.mdtemplate format (rate table + CTA) - Divider: use
---(horizontal rule) between sections - Only include sections that have new opportunities (e.g. Flash + Flexible without Fixed)
When to Use
Use this template when two or more opportunity types produce new results in the same scan round. If only one type has results, use the corresponding single template (flash-earn.md, fixed-earn.md, or flexible-earn.md).
Locked Terms (do not translate)
Flash Earn, Fixed Earn, Earn Hunter, Simple Earn, APR, APY, OKX, OKB, USDT, USDC, BTC, ETH — brand/token/financial terms stay as-is.
Lark Card Format
Use template: "green" for header (distinct from purple/blue used by single-type notifications). Body: combine both sections with markdown divider.
Example (zh-CN)
🎯 Earn Hunter · 14:30
⚡ Flash Earn · 1 个新机会
• OKB Staking Boost · OKB · 12.50% APY [🟢 进行中]
→ 立即参与
---
🏦 Fixed Earn · 2 个新机会
筛选条件:APR ≥ 3.00%,币种 USDT
| Currency | Term | APR | Min | Remaining |
|----------|------|-------|------|-----------|
| USDT | 7D | 4.50% | 100 | 50,000 |
| USDT | 30D | 5.20% | 100 | 30,000 |
📊 收益对比:
活期 APY 2.10% → 定期 APR 4.50%(+2.40%,锁 7D)
→ 回复申购金额,立即帮你申购
---
💰 Simple Earn · 1 个新机会
| Currency | APY |
|----------|-------|
| USDC | 8.41% |
→ 通过 OKX App 申购,或说"申购 USDC 活期"Related skills
How it compares
Use earn-hunter for opportunity discovery across DeFi protocols; use dedicated smart-contract audit skills before deploying capital to unfamiliar contracts.
FAQ
What does earn-hunter do?
Automatically monitors OKX Flash Earn, Fixed Earn and Flexible Earn opportunities, sends push notifications, and guides subscription. 自动监控 OKX 闪赚、定期和活期赚币机会,推送通知并引导申购。Use when user says: 有闪赚通知我, 监控赚币, monitor earn, notify
When should I use earn-hunter?
Automatically monitors OKX Flash Earn, Fixed Earn and Flexible Earn opportunities, sends push notifications, and guides subscription. 自动监控 OKX 闪赚、定期和活期赚币机会,推送通知并引导申购。Use when user says: 有闪赚通知我, 监控赚币, monitor earn, notify
What are common prerequisites?
--- name: earn-hunter description: "Automatically monitors OKX Flash Earn, Fixed Earn and Flexible Earn opportunities, sends push notifications, and guides subscription.