Install
openclaw skills install @joeyiptk/hsil-monitorMonitor Hang Seng Indexes (hsi.com.hk) press releases, index/other notices, insights, and reports; detect newly published items, match them bilingually (EN + 中文) against the user's indexes (default HSI/HSCEI/HSTECH), and produce one concise digest per run. Use when the user wants to watch HSI announcements or run a scheduled Hang Seng Indexes news check. Agent-driven, no Python or compiled code — only curl, jq, and standard shell tools.
openclaw skills install @joeyiptk/hsil-monitorYou (the agent) perform the whole cycle by following this document: fetch the Hang Seng Indexes sources, detect what is new since last run, decide what is relevant, and emit a digest as your final message. You do not send or route the message — OpenClaw delivers your output (the scheduled job's announce routing). This skill contains no channel and no credentials.
Tools used: curl, jq, and coreutils (sort, awk, sed, date, mv).
All reads and writes happen in one writable, per-user home directory, never
in the skill's install folder (that folder may be read-only and its location is
not reliably discoverable from a shell command). This matches the convention
other OpenClaw skills use (~/.config/<skill>/).
Resolve the SAME paths at the very start of every action (setup, run, backfill, doctor) with this exact block — it is a deterministic rule, so the skill always knows where its files are without discovery or a pointer file:
HOME_DIR="${HSIL_MONITOR_HOME:-$HOME/.config/hsil-monitor}"
WORK="$HOME_DIR/work"
mkdir -p "$WORK"
CFG="$HOME_DIR/config.json" # settings
SEEN="$HOME_DIR/seen.txt" # dedup state: one seen item URL per line
LAST="$HOME_DIR/last_run.txt" # ISO-8601 UTC timestamp of last run
UA="Mozilla/5.0 hsil-monitor"
$HSIL_MONITOR_HOME overrides the location; otherwise it defaults to
~/.config/hsil-monitor, writable on any machine and surviving reinstalls.$WORK (not /tmp), so a locked-down /tmp
can't break the skill. Clean $WORK at the start of each run."$CFG.tmp", then mv "$CFG.tmp" "$CFG".$0/dirname to find a path, and do not write inside the
skill folder.config.json (in $HOME_DIR) — settings, created at setup. Schema:
{
"timezone": "Asia/Hong_Kong",
"schedule_time": "08:30",
"languages": ["en", "zh"],
"indexes": ["HSI", "HSCEI", "HSTECH"],
"extra_keywords": [],
"backfill_days": 30,
"deep_read": false,
"sources": [
{"type": "Press release", "page": "https://www.hsi.com.hk/en-hk/media-room/#press-releases", "tab_title": "Press Releases"},
{"type": "Notice", "page": "https://www.hsi.com.hk/en-hk/media-room/#index-other-notices", "tab_title": "Index / Other Notices"},
{"type": "Insight", "page": "https://www.hsi.com.hk/en-hk/insights-and-reports/#insights", "tab_title": "Insights"},
{"type": "Report", "page": "https://www.hsi.com.hk/en-hk/insights-and-reports/#reports", "tab_title": "Reports"}
]
}
config.example.json
in the skill folder is a read-only reference copy of this schema.Delivery is intentionally absent: the skill emits text; OpenClaw routes it.
HOME_DIR="${HSIL_MONITOR_HOME:-$HOME/.config/hsil-monitor}"; WORK="$HOME_DIR/work"
mkdir -p "$WORK"; CFG="$HOME_DIR/config.json"; SEEN="$HOME_DIR/seen.txt"; LAST="$HOME_DIR/last_run.txt"
UA="Mozilla/5.0 hsil-monitor"
[ -f "$CFG" ] || { echo "No config at $CFG — run setup first."; exit 1; }
BACKFILL_DAYS=$(jq -r '.backfill_days // 30' "$CFG")
: > "$WORK/all.tsv"
touch "$SEEN"
FIRST_RUN=0; [ -s "$SEEN" ] || FIRST_RUN=1
Always fetch to a file and validate it is JSON before piping to jq. Some
networks return an HTML bot/geo/WAF challenge or a redirect with HTTP 200;
feeding that to jq causes a jq: parse error. Validate first, skip-with-warning.
Rows are TSV: type <TAB> date <TAB> absolute_url <TAB> title.
jq -c '.sources[]' "$CFG" | while read -r S; do
TYPE=$(jq -r '.type' <<<"$S"); PAGE=$(jq -r '.page' <<<"$S"); TAB=$(jq -r '.tab_title' <<<"$S")
MODEL="$(printf '%s' "$PAGE" | sed 's/#.*$//; s#/\{0,1\}$##').model.json"
curl -sSL --retry 2 --max-time 30 -A "$UA" -H 'Accept: application/json' -o "$WORK/model.json" "$MODEL"
if ! jq -e . "$WORK/model.json" >/dev/null 2>&1; then
echo "WARN: model not JSON for '$TAB' ($MODEL) — block or drift; skipping source" >&2; continue
fi
PATHS=$(jq -r --arg t "$TAB" '.. | objects | select(has("tabs")) | .tabs[]
| select(.tabTitle==$t) | .sourceConfigs[]?.customDataPath' "$WORK/model.json" | sort -u)
[ -z "$PATHS" ] && echo "WARN: no data paths discovered for '$TAB'" >&2
for P in $PATHS; do
U="https://www.hsi.com.hk$P"
code=$(curl -sSL --retry 2 --max-time 30 -A "$UA" -H 'Accept: application/json' -o "$WORK/src.json" -w '%{http_code}' "$U")
if [ "$code" != 200 ] || ! jq -e . "$WORK/src.json" >/dev/null 2>&1; then
echo "WARN: skipped $P (http $code / not JSON) — first bytes: $(head -c 80 "$WORK/src.json" | tr -d '\n')" >&2
continue
fi
jq -r --arg t "$TYPE" '(.contentList // [])[]? | (.resourcesList // [])[]?
| select(.title != null)
| [$t, (.lastUpdate[0:10]), ("https://www.hsi.com.hk" + .url), .title] | @tsv' \
"$WORK/src.json" >> "$WORK/all.tsv"
done
done
sort -u -o "$WORK/all.tsv" "$WORK/all.tsv"
Resilience: a bad/blocked/malformed file is skipped with a WARN (surface these
in the digest's health line); only treat the run as broken if every fetch
failed. The // [] guards mean an unexpected shape yields no rows, not an error.
Read the seen-set in BEGIN via getline so it is correct even when $SEEN is
empty (the NR==FNR trick breaks on an empty first file):
awk -F'\t' -v seenf="$SEEN" 'BEGIN{while((getline l < seenf) > 0) seen[l]=1} !($3 in seen)' \
"$WORK/all.tsv" > "$WORK/new.tsv"
On the first run ($SEEN empty) narrow what you report to the recent window;
you still seed all fetched URLs in step 8, so later runs only show newer items:
if [ "$FIRST_RUN" = 1 ]; then
CUT=$(date -u -d "-${BACKFILL_DAYS} days" +%F 2>/dev/null || date -u -v-"${BACKFILL_DAYS}"d +%F)
awk -F'\t' -v c="$CUT" '$2 >= c' "$WORK/new.tsv" > "$WORK/show.tsv"
else
cp "$WORK/new.tsv" "$WORK/show.tsv"
fi
cat "$WORK/show.tsv" — small, only new items. For each row decide which of the
user's indexes it concerns, per MATCHING RULES below. Record matched index codes
and the language matched. (Optional bilingual recall: also fetch the Chinese data
files — same paths with /data/eng/ → /data/chi/ — and match titles by URL
filename stem. English titles usually already carry the tickers.)
If config.deep_read is true, for up to ~5 matched PDF items you may
curl -sSL "<url>" -o "$WORK/item.pdf" and read it for a one-sentence summary.
Skip silently if you cannot read PDFs. Never deep-read unmatched items.
Group matched items by source type in this order: Press release, Notice,
Insight, Report (all equal weight). Collapse unmatched-new into a count. Use the
DIGEST FORMAT below. If there are no matched items and no warnings, output one
short line (e.g. HSIL monitor: no new matched items today (checked N sources).).
Emit the digest as your final response — do not send it anywhere.
Seed every URL fetched this run so nothing re-alerts:
cut -f3 "$WORK/all.tsv" >> "$SEEN"
sort -u "$SEEN" -o "$SEEN"
date -u +%FT%TZ > "$LAST"
Match each new item's title (and Chinese title / deep-read text if available) against the configured indexes:
| Index | English | 中文 (Traditional + Simplified) |
|---|---|---|
| HSI | HSI, Hang Seng Index | 恒生指數, 恒生指数 |
| HSCEI | HSCEI, Hang Seng China Enterprises Index, China Enterprises Index | 恒生中國企業指數, 恒生中国企业指数, 國企指數, 国企指数 |
| HSTECH | HSTECH, HSTI, Hang Seng TECH Index, Hang Seng TECH | 恒生科技指數, 恒生科技指数, 科技指數 |
Plus any extra_keywords. Guidance:
HSI, HSTECH) as whole tokens, not substrings.deep_read is on, use that; otherwise leave it in the unmatched-new count.HSTECH (en) or 恒生科技指數 (zh).Hang Seng Indexes — Daily Digest
<YYYY-MM-DD HH:MM TZ> # now, in config.timezone
⚠ HEALTH: <only if a source failed / structure drift / all-empty>
New: Press <n> · Notice <n> · Insight <n> · Report <n>
── Press release (<k>) ──
• <title>
<date> · <tag if any> · matched: <labels>
中文: <chinese title if fetched>
"<one-line summary if deep-read>"
<url>
... (Notice, Insight, Report in that order) ...
(+ <m> other new item(s) not matching your indexes)
Health: OK ✅ # or: SEE ALERTS ABOVE ⚠
You are the interactive layer: ask the user, then write config.json.
HOME_DIR="${HSIL_MONITOR_HOME:-$HOME/.config/hsil-monitor}"; mkdir -p "$HOME_DIR/work"
if ! ( : > "$HOME_DIR/.wtest" && rm -f "$HOME_DIR/.wtest" ); then
echo "ERROR: $HOME_DIR is not writable. Set HSIL_MONITOR_HOME to a writable path and re-run setup." ; exit 1
fi
echo "Config + state will be stored in: $HOME_DIR"
$HOME_DIR/config.json from the schema above with their answers, using
jq and an atomic mv (write config.json.tmp, then mv).Run one normal cycle with FIRST_RUN forced to 1 so only the recent
backfill_days window is reported, then all history is seeded into $SEEN.
Emit the windowed digest as your final message.
Create a daily OpenClaw cron job that triggers this skill. Read schedule_time
and timezone from config, convert HH:MM → cron M H * * *, confirm with the
user, then run — embedding the resolved storage path in the message so every
scheduled run uses the exact same location:
HOME_DIR="${HSIL_MONITOR_HOME:-$HOME/.config/hsil-monitor}"
openclaw cron add \
--name hsil-monitor-daily \
--description "Daily Hang Seng Indexes digest" \
--cron "<M> <H> * * *" \
--tz "<timezone>" \
--tools exec \
--announce \
--message "Run the hsil-monitor skill now: set HSIL_MONITOR_HOME=$HOME_DIR, perform one monitoring cycle exactly as its SKILL.md describes, and output the digest as your final message."
Delivery/routing is OpenClaw's responsibility (--announce); this skill names no
channel. Inspect with openclaw cron list; remove with
openclaw cron rm --name hsil-monitor-daily.
Report PASS/WARN/FAIL for:
command -v curl jq — required tools.$HOME_DIR (write a temp file there and delete it).customDataPath discovery returns ≥1 path and the data file parses as JSON.command -v openclaw — needed only for scheduling.$HOME_DIR/config.json exists (else: run setup).$HOME/.config/hsil-monitor, override HSIL_MONITOR_HOME)
holds config, state, and scratch. Nothing is read/written in the skill folder,
and the path is known by rule at every run — no $0, no discovery, no pointer.seen.txt; re-running never re-reports.