@clawhub-yinquan251-f60351e74d
WeatherPanel Note AI PC for Shanghai weather. This skill fetches current weather from Open-Meteo, summarizes the overall conditions with a local LLM through...
---
name: weatherpanel-note-aipc
description: >
WeatherPanel Note AI PC for Shanghai weather. This skill fetches current weather
from Open-Meteo, summarizes the overall conditions with a local LLM through the
summarize CLI, updates a local Canvas dashboard, and safely appends the summary
to a Markdown note inside a configured Obsidian vault.
user-invocable: true
metadata:
openclaw:
emoji: "🌤️"
homepage: https://open-meteo.com/en/docs
---
# WeatherPanel Note AI PC
## What this skill does
- Fetches current and hourly Shanghai weather from Open-Meteo.
- Generates a local summary with the installed `summarize` CLI.
- Updates the Canvas dashboard data for `weatherpanel-note-aipc`.
- Appends summary records to a Markdown note inside the configured Obsidian vault.
## Safety and ClawHub alignment
- Do **not** modify `HEARTBEAT.md`.
- Do **not** change global OpenClaw config.
- Do **not** create or run `.bat`, `.cmd`, or `.ps1` files.
- Do **not** use Windows Task Scheduler, startup folders, registry persistence, or shell profile persistence.
- Do **not** read generic secret-bearing files such as `env.bat`.
- Only run the Python scripts bundled with this skill.
- The weather source is fixed to Shanghai coordinates in bundled code.
- The summary step uses a shell-free subprocess call to the fixed command name `summarize` found on PATH.
- The Obsidian step does **not** invoke `obsidian-cli`; it writes only to a validated `.md` path under a configured vault directory inside the user's home directory.
## Default action
For a normal invocation, run the bundled pipeline:
```
python run_weatherpanel.py --mode all
```
Then tell the user the dashboard is available at the local Canvas path for this skill:
```
/__openclaw__/canvas/weatherpanel-note-aipc/dashboard.html
```
## Other requests
- Refresh weather now:
```
python run_weatherpanel.py --mode all
```
- Fetch only:
```
python run_weatherpanel.py --mode fetch
```
- Summarize only:
```
python run_weatherpanel.py --mode summarize
```
- Flush queued summaries to the Obsidian-compatible Markdown note only:
```
python run_weatherpanel.py --mode flush
```
- Prepare or refresh just the dashboard asset:
```
python run_weatherpanel.py --mode prepare-dashboard
```
- Check token cost:
read the file `token_cost.json` from the Canvas directory for `weatherpanel-note-aipc`.
## Optional configuration
This skill does not require secrets. If needed, it may read a dedicated allowlisted JSON config file at:
```
~/.openclaw/state/weatherpanel_note_aipc/config.json
```
Supported keys are limited to:
- `CANVAS_ROOT`
- `OBSIDIAN_VAULT`
- `OBSIDIAN_NOTE_PATH`
- `OPENCLAW_BASE_URL`
FILE:scripts/dashboard.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WeatherPanel Note AI PC</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap');
:root {
--bg: #0c0e14;
--surface: #151822;
--surface2: #1c2030;
--border: #2a2f42;
--text: #e4e8f1;
--text2: #8891a8;
--accent: #6ee7b7;
--accent2: #38bdf8;
--warn: #fbbf24;
--red: #f87171;
--purple: #c084fc;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'DM Sans', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(26,31,48,0.4) 0%, var(--bg) 100%);
position: sticky;
top: 0;
z-index: 10;
backdrop-filter: blur(8px);
}
.header h1 {
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--accent);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(110,231,183,0.4); }
50% { opacity: 0.6; box-shadow: 0 0 0 6px rgba(110,231,183,0); }
}
.badges {
display: flex;
gap: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text2);
}
.badge {
background: var(--surface2);
border: 1px solid var(--border);
padding: 3px 8px;
border-radius: 5px;
}
.debug-bar {
padding: 4px 24px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--accent);
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px 24px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
}
.card:hover { border-color: #3a4060; }
.card h2 {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 500;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 10px;
}
.card-full { grid-column: 1 / -1; }
.current-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.metric {
background: var(--surface2);
border-radius: 6px;
padding: 10px;
text-align: center;
}
.metric .val {
font-family: 'JetBrains Mono', monospace;
font-size: 22px;
font-weight: 700;
line-height: 1;
margin-bottom: 3px;
}
.metric .lbl {
font-size: 10px;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.c-temp { color: var(--accent); }
.c-humid { color: var(--accent2); }
.c-wind { color: var(--warn); }
.c-precip { color: var(--purple); }
.c-press { color: var(--red); }
.c-cloud { color: #94a3b8; }
.chart-wrap { position: relative; height: 190px; }
.bottom-split {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1fr 300px;
gap: 12px;
}
.summary-text {
font-size: 13px;
line-height: 1.65;
white-space: pre-wrap;
max-height: 260px;
overflow-y: auto;
padding-right: 6px;
}
.summary-text::-webkit-scrollbar { width: 3px; }
.summary-text::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.cost-panel {
background: var(--surface2);
border-radius: 8px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.cost-panel h3 {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.cost-big {
font-family: 'JetBrains Mono', monospace;
font-size: 32px;
font-weight: 700;
color: var(--accent);
letter-spacing: -1px;
}
.cost-row {
display: flex;
justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.cost-row .k { color: var(--text2); }
.cost-row .v { color: var(--text); }
.no-data {
text-align: center;
padding: 30px;
color: var(--text2);
font-style: italic;
font-size: 13px;
}
@media (max-width: 800px) {
.grid { grid-template-columns: 1fr; }
.bottom-split { grid-template-columns: 1fr; }
.current-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<div class="header">
<h1><span class="dot"></span> WeatherPanel Note AI PC</h1>
<div class="badges">
<span class="badge" id="lastUpdate">--</span>
<span class="badge" id="dataPoints">0 pts</span>
<span class="badge" id="refreshCD">30s</span>
</div>
</div>
<div class="debug-bar" id="debugBar">Loading...</div>
<div class="grid">
<div class="card card-full">
<h2>Current Conditions</h2>
<div class="current-grid">
<div class="metric"><div class="val c-temp" id="mTemp">--</div><div class="lbl">Temperature</div></div>
<div class="metric"><div class="val c-temp" id="mFeels">--</div><div class="lbl">Feels Like</div></div>
<div class="metric"><div class="val c-humid" id="mHumid">--</div><div class="lbl">Humidity</div></div>
<div class="metric"><div class="val c-wind" id="mWind">--</div><div class="lbl">Wind Speed</div></div>
<div class="metric"><div class="val c-wind" id="mGusts">--</div><div class="lbl">Gusts</div></div>
<div class="metric"><div class="val c-precip" id="mPrecip">--</div><div class="lbl">Precipitation</div></div>
<div class="metric"><div class="val c-press" id="mPress">--</div><div class="lbl">Pressure</div></div>
<div class="metric"><div class="val c-cloud" id="mCloud">--</div><div class="lbl">Cloud Cover</div></div>
</div>
</div>
<div class="card"><h2>Temperature (C)</h2><div class="chart-wrap"><canvas id="chTemp"></canvas></div></div>
<div class="card"><h2>Humidity (%)</h2><div class="chart-wrap"><canvas id="chHumid"></canvas></div></div>
<div class="card"><h2>Wind Speed (km/h)</h2><div class="chart-wrap"><canvas id="chWind"></canvas></div></div>
<div class="card"><h2>Precipitation (mm)</h2><div class="chart-wrap"><canvas id="chPrecip"></canvas></div></div>
<div class="bottom-split">
<div class="card">
<h2>Latest Summary</h2>
<div class="summary-text" id="summaryText">
<div class="no-data">Waiting for first summary...</div>
</div>
</div>
<div class="card" style="padding:0">
<div class="cost-panel" style="height:100%">
<h3>Accumulated Token Cost</h3>
<div class="cost-big" id="totalCost">$0.000000</div>
<div class="cost-row"><span class="k">Total calls</span><span class="v" id="cCalls">0</span></div>
<div class="cost-row"><span class="k">Input tokens</span><span class="v" id="cIn">0</span></div>
<div class="cost-row"><span class="k">Output tokens</span><span class="v" id="cOut">0</span></div>
<div class="cost-row"><span class="k">Avg cost/call</span><span class="v" id="cAvg">$0.000000</span></div>
<h3 style="margin-top:4px">Cost Over Time</h3>
<div style="height:80px"><canvas id="chCost"></canvas></div>
</div>
</div>
</div>
</div>
<script>
const REFRESH_MS = 30000;
let countdown = 30;
let refreshCount = 0;
Chart.defaults.color = '#8891a8';
Chart.defaults.borderColor = '#2a2f42';
Chart.defaults.font.family = "'JetBrains Mono', monospace";
Chart.defaults.font.size = 10;
function mkOpts(yLbl, clr) {
return {
responsive: true, maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 8, maxRotation: 0 } },
y: { grid: { color: '#1c2030' }, title: { display: true, text: yLbl, color: '#8891a8' } }
},
elements: {
point: { radius: 2, hoverRadius: 5, backgroundColor: clr },
line: { borderWidth: 2, borderColor: clr, backgroundColor: clr + '18', fill: true, tension: 0.3 }
}
};
}
// Store chart instances in an object for easy destroy/recreate
let charts = {};
function createChart(id, yLbl, clr) {
const canvas = document.getElementById(id);
if (charts[id]) {
charts[id].destroy();
}
charts[id] = new Chart(canvas, {
type: 'line',
data: { labels: [], datasets: [{ data: [], borderColor: clr, backgroundColor: clr + '18', fill: true, tension: 0.3, pointRadius: 2, borderWidth: 2 }] },
options: mkOpts(yLbl, clr)
});
return charts[id];
}
// Create cost chart
let costChart = null;
function createCostChart() {
const canvas = document.getElementById('chCost');
if (costChart) costChart.destroy();
costChart = new Chart(canvas, {
type: 'line',
data: { labels: [], datasets: [{ data: [], borderColor: '#6ee7b7', backgroundColor: '#6ee7b718', fill: true, tension: 0.3, pointRadius: 1, borderWidth: 2 }] },
options: {
responsive: true, maintainAspectRatio: false, animation: false,
plugins: { legend: { display: false } },
scales: { x: { display: false }, y: { grid: { color: '#1c2030' }, ticks: { callback: v => '$' + v.toFixed(4) } } }
}
});
return costChart;
}
// Use fetch timestamp (changes every 5 min) for labels, not Open-Meteo local_time (changes every 15 min)
function fmtTime(ts) {
if (!ts) return '?';
try {
const d = new Date(ts);
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return h + ':' + m;
} catch(e) {
return '?';
}
}
function fmtNow() {
const d = new Date();
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
}
async function getJSON(file) {
try {
const r = await fetch(file + '?_=' + Date.now(), { cache: 'no-store' });
if (!r.ok) { console.error(file, 'status', r.status); return null; }
return await r.json();
} catch(e) { console.error(file, e); return null; }
}
async function refresh() {
refreshCount++;
const debugParts = ['Refresh #' + refreshCount + ' at ' + fmtNow()];
try {
// Timeseries
const ts = await getJSON('timeseries.json');
if (ts && ts.length) {
const L = ts[ts.length - 1];
document.getElementById('mTemp').textContent = (L.temperature_2m != null ? L.temperature_2m : '--') + 'C';
document.getElementById('mFeels').textContent = (L.apparent_temperature != null ? L.apparent_temperature : '--') + 'C';
document.getElementById('mHumid').textContent = (L.relative_humidity_2m != null ? L.relative_humidity_2m : '--') + '%';
document.getElementById('mWind').textContent = (L.wind_speed_10m != null ? L.wind_speed_10m : '--') + ' km/h';
document.getElementById('mGusts').textContent = (L.wind_gusts_10m != null ? L.wind_gusts_10m : '--') + ' km/h';
document.getElementById('mPrecip').textContent = (L.precipitation != null ? L.precipitation : '--') + ' mm';
document.getElementById('mPress').textContent = (L.surface_pressure != null ? L.surface_pressure : '--') + ' hPa';
document.getElementById('mCloud').textContent = (L.cloud_cover != null ? L.cloud_cover : '--') + '%';
document.getElementById('lastUpdate').textContent = 'Last: ' + fmtTime(L.timestamp);
document.getElementById('dataPoints').textContent = ts.length + ' pts';
// Use timestamp (Python fetch time, changes every 5 min) for chart labels
const recent = ts.slice(-288);
const labels = recent.map(p => fmtTime(p.timestamp));
const tempData = recent.map(p => p.temperature_2m);
const humidData = recent.map(p => p.relative_humidity_2m);
const windData = recent.map(p => p.wind_speed_10m);
const precipData = recent.map(p => p.precipitation);
// Destroy and recreate charts every refresh to guarantee visual update
const chT = createChart('chTemp', 'C', '#6ee7b7');
chT.data.labels = labels;
chT.data.datasets[0].data = tempData;
chT.update();
const chH = createChart('chHumid', '%', '#38bdf8');
chH.data.labels = labels;
chH.data.datasets[0].data = humidData;
chH.update();
const chW = createChart('chWind', 'km/h', '#fbbf24');
chW.data.labels = labels;
chW.data.datasets[0].data = windData;
chW.update();
const chP = createChart('chPrecip', 'mm', '#c084fc');
chP.data.labels = labels;
chP.data.datasets[0].data = precipData;
chP.update();
debugParts.push(ts.length + ' pts');
debugParts.push('last: ' + fmtTime(L.timestamp));
debugParts.push('temp: ' + L.temperature_2m);
} else {
debugParts.push('timeseries: empty or null');
}
// Summaries
const sums = await getJSON('summaries.json');
if (sums && sums.length) {
document.getElementById('summaryText').textContent = sums[sums.length - 1].summary || 'No summary.';
debugParts.push(sums.length + ' summaries');
}
// Token Cost
const cost = await getJSON('token_cost.json');
if (cost) {
document.getElementById('totalCost').textContent = '$' + (cost.total_cost_usd || 0).toFixed(6);
document.getElementById('cCalls').textContent = cost.calls || 0;
document.getElementById('cIn').textContent = (cost.total_input_tokens || 0).toLocaleString();
document.getElementById('cOut').textContent = (cost.total_output_tokens || 0).toLocaleString();
const avg = cost.calls > 0 ? cost.total_cost_usd / cost.calls : 0;
document.getElementById('cAvg').textContent = '$' + avg.toFixed(6);
if (cost.history && cost.history.length) {
let cum = 0;
const cl = [], cd = [];
for (const h of cost.history.slice(-50)) {
cum += h.cost_usd || 0;
cl.push(fmtTime(h.timestamp));
cd.push(cum);
}
const chC = createCostChart();
chC.data.labels = cl;
chC.data.datasets[0].data = cd;
chC.update();
}
}
} catch(e) {
debugParts.push('ERROR: ' + e.message);
console.error('refresh error', e);
}
document.getElementById('debugBar').textContent = debugParts.join(' | ');
countdown = 30;
}
setInterval(() => {
countdown = Math.max(0, countdown - 1);
document.getElementById('refreshCD').textContent = countdown + 's';
}, 1000);
refresh();
setInterval(refresh, REFRESH_MS);
</script>
</body>
</html>
FILE:scripts/env_loader.py
"""
env_loader.py - Shared configuration helper for weatherpanel-note-aipc.
This helper intentionally does NOT parse generic user shell files such as env.bat.
It only exposes a stable state directory and optionally loads a dedicated JSON
config file with a small allowlist of non-secret keys.
Supported optional config file locations:
- ~/.openclaw/state/weatherpanel_note_aipc/config.json
- ~/.openclaw/state/weather_monitor/config.json (legacy fallback)
Environment variables always take precedence over config.json.
"""
import json
import os
STATE_SLUG = "weatherpanel_note_aipc"
LEGACY_STATE_SLUG = "weather_monitor"
_HOME = os.environ.get("USERPROFILE") or os.environ.get("HOME") or os.path.expanduser("~")
if not _HOME or _HOME in {"~", "/root"}:
_HOME = os.path.expanduser("~")
LEGACY_STATE_DIR = os.path.join(_HOME, ".openclaw", "state", LEGACY_STATE_SLUG)
STATE_DIR = os.path.join(_HOME, ".openclaw", "state", STATE_SLUG)
if not os.path.exists(STATE_DIR) and os.path.exists(LEGACY_STATE_DIR):
STATE_DIR = LEGACY_STATE_DIR
CANVAS_ROOT_DEFAULT = os.path.join(_HOME, "clawd", "canvas")
_ALLOWED_CONFIG_KEYS = {
"CANVAS_ROOT",
"WEATHER_LAT",
"WEATHER_LON",
"WEATHER_TZ",
"WEATHER_UNITS",
"SUMMARIZE_BIN",
"OBSIDIAN_BIN",
"OBSIDIAN_VAULT",
"OBSIDIAN_NOTE_PATH",
"OPENCLAW_BASE_URL",
}
def _load_config_json():
candidates = [
os.path.join(os.path.join(_HOME, ".openclaw", "state", STATE_SLUG), "config.json"),
os.path.join(os.path.join(_HOME, ".openclaw", "state", LEGACY_STATE_SLUG), "config.json"),
]
config_path = next((p for p in candidates if os.path.exists(p)), None)
if not config_path:
return
try:
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return
if not isinstance(data, dict):
return
for key, value in data.items():
if key not in _ALLOWED_CONFIG_KEYS:
continue
if key in os.environ:
continue
if value is None:
continue
os.environ[key] = str(value)
_load_config_json()
FILE:scripts/fetch_weather.py
#!/usr/bin/env python3
"""
fetch_weather.py - Fetch current weather from Open-Meteo and append to time-series.
Writes timeseries.json to the Canvas directory for live dashboard rendering.
Exits 0 on success.
"""
import json
import os
import sys
from datetime import datetime, timezone
from urllib.request import urlopen, Request, ProxyHandler, build_opener
from urllib.error import URLError
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import env_loader
STATE_DIR = env_loader.STATE_DIR
CANVAS_ROOT = os.environ.get("CANVAS_ROOT", env_loader.CANVAS_ROOT_DEFAULT)
CANVAS_DIR = os.path.join(CANVAS_ROOT, "weatherpanel-note-aipc")
TIMESERIES_FILE = os.path.join(CANVAS_DIR, "timeseries.json")
LATEST_FILE = os.path.join(STATE_DIR, "latest_fetch.json")
LAST_FETCH_FILE = os.path.join(STATE_DIR, "last_fetch.txt")
MAX_POINTS = 576 # 48 hours at 5-min intervals
LAT = os.environ.get("WEATHER_LAT", "31.2304")
LON = os.environ.get("WEATHER_LON", "121.4737")
TZ = os.environ.get("WEATHER_TZ", "Asia/Shanghai")
UNITS = os.environ.get("WEATHER_UNITS", "metric")
def build_url():
temp_unit = "fahrenheit" if UNITS == "imperial" else "celsius"
wind_unit = "mph" if UNITS == "imperial" else "kmh"
precip_unit = "inch" if UNITS == "imperial" else "mm"
base = "https://api.open-meteo.com/v1/forecast"
params = (
f"latitude={LAT}&longitude={LON}"
f"&timezone={TZ}"
f"¤t=temperature_2m,relative_humidity_2m,apparent_temperature,"
f"precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m,"
f"surface_pressure"
f"&hourly=temperature_2m,relative_humidity_2m,apparent_temperature,"
f"precipitation_probability,precipitation,wind_speed_10m,cloud_cover,weather_code"
f"&forecast_hours=24"
f"&temperature_unit={temp_unit}"
f"&wind_speed_unit={wind_unit}"
f"&precipitation_unit={precip_unit}"
)
return f"{base}?{params}"
def fetch_weather():
url = build_url()
req = Request(url, headers={"User-Agent": "OpenClaw-WeatherPanelNoteAIPC/1.0"})
proxies = {}
http_proxy = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy")
https_proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")
if http_proxy:
proxies["http"] = http_proxy
if https_proxy:
proxies["https"] = https_proxy
if proxies:
print(f"[fetch] Using proxy: {proxies}")
opener = build_opener(ProxyHandler(proxies))
else:
opener = build_opener(ProxyHandler())
try:
with opener.open(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except URLError as e:
print(f"[fetch] ERROR: {e}", file=sys.stderr)
if "10060" in str(e) or "timed out" in str(e).lower():
print("[fetch] Timeout. Set HTTP_PROXY/HTTPS_PROXY in the process environment if behind a proxy.", file=sys.stderr)
sys.exit(1)
def extract_current(data):
c = data.get("current", {})
return {
"timestamp": datetime.now(timezone.utc).isoformat(),
"local_time": c.get("time", ""),
"temperature_2m": c.get("temperature_2m"),
"relative_humidity_2m": c.get("relative_humidity_2m"),
"apparent_temperature": c.get("apparent_temperature"),
"precipitation": c.get("precipitation"),
"weather_code": c.get("weather_code"),
"cloud_cover": c.get("cloud_cover"),
"wind_speed_10m": c.get("wind_speed_10m"),
"wind_direction_10m": c.get("wind_direction_10m"),
"wind_gusts_10m": c.get("wind_gusts_10m"),
"surface_pressure": c.get("surface_pressure"),
}
def load_timeseries():
if os.path.exists(TIMESERIES_FILE):
for enc in ["utf-8", "gbk"]:
try:
with open(TIMESERIES_FILE, "r", encoding=enc) as f:
data = json.load(f)
if enc != "utf-8":
with open(TIMESERIES_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return data
except (json.JSONDecodeError, UnicodeDecodeError, IOError):
continue
return []
return []
def save_timeseries(ts):
if len(ts) > MAX_POINTS:
ts = ts[-MAX_POINTS:]
with open(TIMESERIES_FILE, "w", encoding="utf-8") as f:
json.dump(ts, f, indent=2, ensure_ascii=False)
def main():
os.makedirs(STATE_DIR, exist_ok=True)
os.makedirs(CANVAS_DIR, exist_ok=True)
print(f"[fetch] Fetching weather for ({LAT}, {LON})...")
raw = fetch_weather()
with open(LATEST_FILE, "w", encoding="utf-8") as f:
json.dump(raw, f, indent=2, ensure_ascii=False)
current = extract_current(raw)
hourly = raw.get("hourly", {})
current["hourly_forecast"] = {
"time": hourly.get("time", [])[:24],
"temperature_2m": hourly.get("temperature_2m", [])[:24],
"precipitation_probability": hourly.get("precipitation_probability", [])[:24],
"precipitation": hourly.get("precipitation", [])[:24],
}
ts = load_timeseries()
ts.append(current)
save_timeseries(ts)
now_str = datetime.now(timezone.utc).isoformat()
with open(LAST_FETCH_FILE, "w", encoding="utf-8") as f:
f.write(now_str)
print(f"[fetch] OK at {now_str} "
f"(temp={current['temperature_2m']}C, humidity={current['relative_humidity_2m']}%)")
sys.exit(0)
if __name__ == "__main__":
main()
FILE:scripts/flush_to_obsidian.py
#!/usr/bin/env python3
"""
flush_to_obsidian.py - Write queued weather summaries to Obsidian.
Uses Yakitrak obsidian-cli: create "note" --vault "V" --content "..." --append
"""
import json
import os
import sys
import subprocess
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import env_loader
STATE_DIR = env_loader.STATE_DIR
QUEUE_FILE = os.path.join(STATE_DIR, "summary_queue.jsonl")
DONE_FILE = os.path.join(STATE_DIR, "summary_queue.done.jsonl")
WRITTEN_IDS_FILE = os.path.join(STATE_DIR, "written_ids.json")
LAST_FLUSH_FILE = os.path.join(STATE_DIR, "last_flush.txt")
OBSIDIAN_BIN = os.environ.get("OBSIDIAN_BIN", "obsidian-cli")
OBSIDIAN_VAULT = os.environ.get("OBSIDIAN_VAULT", "")
NOTE_PATH = os.environ.get("OBSIDIAN_NOTE_PATH", "Inbox/WeatherPanel Note AI PC.md")
def load_written_ids():
if os.path.exists(WRITTEN_IDS_FILE):
for enc in ["utf-8", "gbk"]:
try:
with open(WRITTEN_IDS_FILE, "r", encoding=enc) as f:
return set(json.load(f))
except (json.JSONDecodeError, UnicodeDecodeError, IOError):
continue
return set()
def save_written_ids(ids):
with open(WRITTEN_IDS_FILE, "w", encoding="utf-8") as f:
json.dump(sorted(ids), f)
def read_queue():
records = []
if os.path.exists(QUEUE_FILE):
with open(QUEUE_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
records.append(json.loads(line))
except json.JSONDecodeError:
continue
return records
def write_to_obsidian(content, timestamp):
"""Yakitrak: obsidian-cli create "path" --vault "V" --content "..." --append"""
formatted = (
f"\n---\n"
f"## WeatherPanel Note AI PC Summary - {timestamp[:19].replace('T', ' ')} UTC\n\n"
f"{content}\n"
f"<!-- weatherpanel-note-aipc-{timestamp} -->\n"
)
cmd = [OBSIDIAN_BIN, "create", NOTE_PATH]
if OBSIDIAN_VAULT:
cmd.extend(["--vault", OBSIDIAN_VAULT])
cmd.extend(["--content", formatted, "--append"])
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=30, encoding="utf-8",
)
if result.returncode != 0:
print(f"[flush] Obsidian error: {result.stderr}", file=sys.stderr)
return False
return True
except subprocess.TimeoutExpired:
print("[flush] Obsidian timeout", file=sys.stderr)
return False
except FileNotFoundError:
print(f"[flush] '{OBSIDIAN_BIN}' not found", file=sys.stderr)
return False
def main():
os.makedirs(STATE_DIR, exist_ok=True)
print(f"[flush] BIN={OBSIDIAN_BIN} VAULT={OBSIDIAN_VAULT or '(empty)'} NOTE={NOTE_PATH}")
records = read_queue()
if not records:
print("[flush] No pending records.")
sys.exit(0)
written_ids = load_written_ids()
pending = [r for r in records if r.get("id") not in written_ids]
if not pending:
print("[flush] All records already written.")
sys.exit(0)
print(f"[flush] {len(pending)} pending record(s)...")
success_count = 0
for rec in pending:
rid = rec["id"]
if write_to_obsidian(rec.get("content", ""), rec.get("timestamp", "")):
written_ids.add(rid)
success_count += 1
print(f"[flush] Written: {rid}")
with open(DONE_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
else:
print(f"[flush] FAILED: {rid}", file=sys.stderr)
save_written_ids(written_ids)
remaining = [r for r in records if r.get("id") not in written_ids]
with open(QUEUE_FILE, "w", encoding="utf-8") as f:
for r in remaining:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
now = datetime.now(timezone.utc).isoformat()
with open(LAST_FLUSH_FILE, "w", encoding="utf-8") as f:
f.write(now)
print(f"[flush] Done. {success_count}/{len(pending)} written.")
if __name__ == "__main__":
main()
FILE:scripts/run_weatherpanel.py
#!/usr/bin/env python3
"""
run_weatherpanel.py - Safe local runner for weatherpanel-note-aipc.
This replaces the old .bat wrappers and keeps execution inside the bundled
Python scripts. It does not modify global heartbeat settings, OS startup,
Task Scheduler, or shell profile files.
"""
import argparse
import os
import shutil
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import env_loader
SKILL_ID = "weatherpanel-note-aipc"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
CANVAS_ROOT = os.environ.get("CANVAS_ROOT", env_loader.CANVAS_ROOT_DEFAULT)
CANVAS_DIR = os.path.join(CANVAS_ROOT, SKILL_ID)
DASHBOARD_SRC = os.path.join(SCRIPT_DIR, "dashboard.html")
DASHBOARD_DST = os.path.join(CANVAS_DIR, "dashboard.html")
BASE_URL = os.environ.get("OPENCLAW_BASE_URL", "http://localhost:18789")
def prepare_dashboard() -> None:
os.makedirs(CANVAS_DIR, exist_ok=True)
shutil.copy2(DASHBOARD_SRC, DASHBOARD_DST)
print(f"[runner] Dashboard prepared: {DASHBOARD_DST}")
print(f"[runner] Suggested canvas URL: {BASE_URL}/__openclaw__/canvas/{SKILL_ID}/dashboard.html")
def run_step(script_name: str) -> int:
script_path = os.path.join(SCRIPT_DIR, script_name)
cmd = [sys.executable, script_path]
print(f"[runner] Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=SCRIPT_DIR)
print(f"[runner] Exit code for {script_name}: {result.returncode}")
return result.returncode
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--mode",
default="all",
choices=["all", "prepare-dashboard", "fetch", "summarize", "flush"],
help="Which portion of the workflow to run.",
)
args = parser.parse_args()
if args.mode == "prepare-dashboard":
prepare_dashboard()
return 0
if args.mode == "fetch":
prepare_dashboard()
return run_step("fetch_weather.py")
if args.mode == "summarize":
return run_step("summarize_weather.py")
if args.mode == "flush":
return run_step("flush_to_obsidian.py")
# full pipeline
prepare_dashboard()
results = {
"fetch": run_step("fetch_weather.py"),
"summarize": run_step("summarize_weather.py"),
"flush": run_step("flush_to_obsidian.py"),
}
failures = {k: v for k, v in results.items() if v != 0}
if not failures:
print("[runner] All steps completed successfully.")
return 0
print(f"[runner] Partial failures: {failures}")
# Keep the workflow user-friendly: fetch might fail due to network,
# summarize/flush might fail due to missing local tools. The dashboard
# and prior cached data can still be useful, so return 0 only when at
# least one step succeeded.
if any(v == 0 for v in results.values()):
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/summarize_weather.py
#!/usr/bin/env python3
"""
summarize_weather.py - Summarize Shanghai weather via summarize CLI with a local LLM for WeatherPanel Note AI PC.
Passes Open-Meteo URL to summarize. Config in ~/.summarize/config.json.
Writes summaries.json and token_cost.json to Canvas dir.
"""
import json
import os
import sys
import subprocess
import hashlib
import re
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import env_loader
STATE_DIR = env_loader.STATE_DIR
CANVAS_ROOT = os.environ.get("CANVAS_ROOT", env_loader.CANVAS_ROOT_DEFAULT)
CANVAS_DIR = os.path.join(CANVAS_ROOT, "weatherpanel-note-aipc")
TIMESERIES_FILE = os.path.join(CANVAS_DIR, "timeseries.json")
SUMMARIES_FILE = os.path.join(CANVAS_DIR, "summaries.json")
TOKEN_COST_FILE = os.path.join(CANVAS_DIR, "token_cost.json")
QUEUE_FILE = os.path.join(STATE_DIR, "summary_queue.jsonl")
LAST_SUMMARY_FILE = os.path.join(STATE_DIR, "last_summary.txt")
SUMMARIZE_BIN = os.environ.get("SUMMARIZE_BIN", "summarize")
MAX_SUMMARIES = 144
LAT = os.environ.get("WEATHER_LAT", "31.2304")
LON = os.environ.get("WEATHER_LON", "121.4737")
TZ = os.environ.get("WEATHER_TZ", "Asia/Shanghai")
UNITS = os.environ.get("WEATHER_UNITS", "metric")
COST_PER_INPUT_TOKEN = 0.0
COST_PER_OUTPUT_TOKEN = 0.0
def load_json(path, default=None):
if default is None:
default = []
if os.path.exists(path):
for enc in ["utf-8", "gbk"]:
try:
with open(path, "r", encoding=enc) as f:
data = json.load(f)
if enc != "utf-8":
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return data
except (json.JSONDecodeError, UnicodeDecodeError, IOError):
continue
return default
return default
def save_json(path, data):
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def load_token_cost():
return load_json(TOKEN_COST_FILE, {
"total_input_tokens": 0,
"total_output_tokens": 0,
"total_cost_usd": 0.0,
"calls": 0,
"history": []
})
def build_open_meteo_url():
temp_unit = "fahrenheit" if UNITS == "imperial" else "celsius"
wind_unit = "mph" if UNITS == "imperial" else "kmh"
precip_unit = "inch" if UNITS == "imperial" else "mm"
base = "https://api.open-meteo.com/v1/forecast"
params = (
f"latitude={LAT}&longitude={LON}"
f"&timezone={TZ}"
f"¤t=temperature_2m,relative_humidity_2m,apparent_temperature,"
f"precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,"
f"wind_gusts_10m,surface_pressure"
f"&hourly=temperature_2m,relative_humidity_2m,apparent_temperature,"
f"precipitation_probability,precipitation,wind_speed_10m,cloud_cover,weather_code"
f"&forecast_hours=24"
f"&temperature_unit={temp_unit}"
f"&wind_speed_unit={wind_unit}"
f"&precipitation_unit={precip_unit}"
)
return f"{base}?{params}"
def run_summarize(url):
"""Run summarize CLI. Uses shell=True to protect & in URL from cmd.exe."""
cmd_str = f'"{SUMMARIZE_BIN}" "{url}"'
print(f"[summarize] URL: {url}")
try:
result = subprocess.run(
cmd_str,
capture_output=True,
text=True,
timeout=180,
encoding="utf-8",
shell=True,
)
stdout = result.stdout.strip()
stderr = result.stderr or ""
if result.returncode != 0:
print(f"[summarize] CLI error rc={result.returncode}:", file=sys.stderr)
print(f"[summarize] stderr: {stderr}", file=sys.stderr)
return None, 0, 0
if not stdout:
print("[summarize] CLI returned empty.", file=sys.stderr)
if stderr:
print(f"[summarize] stderr: {stderr}", file=sys.stderr)
return None, 0, 0
input_tokens = 0
output_tokens = 0
tok_match = re.search(r"(\d+)\s*input.*?(\d+)\s*output", stderr, re.IGNORECASE)
if tok_match:
input_tokens = int(tok_match.group(1))
output_tokens = int(tok_match.group(2))
else:
tok_total = re.search(r"(\d+)\s*tokens?", stderr, re.IGNORECASE)
if tok_total:
total = int(tok_total.group(1))
input_tokens = int(total * 0.7)
output_tokens = total - input_tokens
else:
input_tokens = 500
output_tokens = len(stdout) // 4
return stdout, input_tokens, output_tokens
except subprocess.TimeoutExpired:
print("[summarize] Timeout after 180s.", file=sys.stderr)
return None, 0, 0
except FileNotFoundError:
print(f"[summarize] '{SUMMARIZE_BIN}' not found.", file=sys.stderr)
return None, 0, 0
def enqueue_for_obsidian(summary_text, timestamp):
record_id = hashlib.sha256(
f"{timestamp}:{summary_text[:100]}".encode()
).hexdigest()[:16]
record = {"id": record_id, "timestamp": timestamp, "content": summary_text}
with open(QUEUE_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
return record_id
def main():
os.makedirs(STATE_DIR, exist_ok=True)
os.makedirs(CANVAS_DIR, exist_ok=True)
ts = load_json(TIMESERIES_FILE, [])
if not ts:
print("[summarize] No timeseries data. Run fetch_weather.py first.", file=sys.stderr)
sys.exit(0)
url = build_open_meteo_url()
print(f"[summarize] Summarizing for ({LAT}, {LON}) via local LLM...")
summary, in_tok, out_tok = run_summarize(url)
if not summary:
print("[summarize] No summary produced.")
sys.exit(1)
now = datetime.now(timezone.utc).isoformat()
cost = load_token_cost()
call_cost = (in_tok * COST_PER_INPUT_TOKEN) + (out_tok * COST_PER_OUTPUT_TOKEN)
cost["total_input_tokens"] += in_tok
cost["total_output_tokens"] += out_tok
cost["total_cost_usd"] += call_cost
cost["calls"] += 1
cost["history"].append({
"timestamp": now, "input_tokens": in_tok,
"output_tokens": out_tok, "cost_usd": round(call_cost, 6),
})
if len(cost["history"]) > 1000:
cost["history"] = cost["history"][-1000:]
cost["total_cost_usd"] = round(cost["total_cost_usd"], 6)
save_json(TOKEN_COST_FILE, cost)
summaries = load_json(SUMMARIES_FILE, [])
summaries.append({
"timestamp": now, "summary": summary,
"input_tokens": in_tok, "output_tokens": out_tok,
"cost_usd": round(call_cost, 6),
})
if len(summaries) > MAX_SUMMARIES:
summaries = summaries[-MAX_SUMMARIES:]
save_json(SUMMARIES_FILE, summaries)
rid = enqueue_for_obsidian(summary, now)
with open(LAST_SUMMARY_FILE, "w", encoding="utf-8") as f:
f.write(now)
print(f"[summarize] Done. Tokens: {in_tok} in / {out_tok} out")
print(f"[summarize] Cumulative: {cost['total_input_tokens']} in / "
f"{cost['total_output_tokens']} out over {cost['calls']} calls")
print(f"[summarize] Queued for Obsidian: {rid}")
if __name__ == "__main__":
main()
Fetch Weibo realtime hot search rankings, summarize trending topics with a local model, and append the hot list plus digest to an Obsidian note. Supports one...
---
name: weibo-trendnote-aipc
description: >
Fetch Weibo realtime hot search rankings, summarize trending topics with a local model,
and append the hot list plus digest to an Obsidian note.
Supports one-time runs and optional recurring cron setup on Windows.
user-invocable: true
metadata: {"openclaw":{"os":["win32"],"requires":{"anyBins":["python","py"],"bins":["openclaw"]},"homepage":"https://docs.openclaw.ai/tools/skills"}}
---
# Weibo TrendNote AI PC
Use this skill to fetch current Weibo hot trends, summarize them locally, and append the result to Obsidian.
## What it does
- Fetches the current Weibo hot search list.
- Summarizes the latest hot topics with a local summarize CLI / local model flow.
- Appends the hot list and summary to an Obsidian note.
- Can optionally register recurring cron jobs, but only when the user explicitly asks for scheduled automation.
## Safety and side effects
- This skill writes local state files under `C:\Users\Intel\.openclaw\state\weibo_hot`.
- This skill appends content to an Obsidian note.
- Optional cron setup creates persistent scheduled jobs in OpenClaw.
- Do not install recurring cron jobs unless the user explicitly asks for ongoing automation.
## Recommended commands
### One-time run
Use this for a normal fetch → summarize → write pass:
exec: python "{baseDir}/skill_runner.py" once
To force a full run even when the hot list has not changed:
exec: python "{baseDir}/skill_runner.py" once --force
### Optional recurring automation
Only use this after the user explicitly asks to enable scheduled runs:
exec: python "{baseDir}/skill_runner.py" install-crons
## Notes
- This skill is Windows-only.
- The runner loads optional values from `C:\Users\Intel\.openclaw\state\weibo_hot\env.ps1` when present.
- If `env.ps1` is absent, the bundled scripts fall back to their built-in defaults.
FILE:scripts/fetch_weibo_hot.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
fetch_weibo_hot.py - Fetch Weibo realtime hot searches, normalize, dedup.
Based on the weibo skill (weibo.com/ajax/side/hotSearch desktop AJAX API).
Windows-native: no Unix dependencies, safe file IO for NTFS.
Exit codes:
0 - Success, hotlist unchanged (dedup).
42 - Success, hotlist CHANGED (caller should trigger Skill #2).
1 - Error (network, parse, IO).
"""
import argparse
import hashlib
import json
import os
import sys
import io
import time
from datetime import datetime, timezone, timedelta
from pathlib import Path
# Force UTF-8 output (fixes garbled Chinese in NSSM/PowerShell)
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
try:
import requests
except ImportError:
print('[error] requests library not installed. Run: pip install requests',
file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
# HARDCODED: Path.home() under NSSM resolves to SYSTEM profile, not Intel.
STATE_DIR = r'C:\Users\Intel\.openclaw\state\weibo_hot'
HOT_JSON = os.path.join(STATE_DIR, 'hot.json')
HOT_SHA = os.path.join(STATE_DIR, 'hot.sha256')
LAST_FETCH = os.path.join(STATE_DIR, 'last_fetch.txt')
DEBUG_RESP = os.path.join(STATE_DIR, 'debug_response.json')
API_URL = 'https://weibo.com/ajax/side/hotSearch'
DEFAULT_UA = (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/120.0.0.0 Safari/537.36'
)
USER_AGENT = os.environ.get('WEIBO_UA', DEFAULT_UA)
MAX_RETRIES = 3
RETRY_CODES = {429, 500, 502, 503, 504}
BACKOFF_BASE = 2 # seconds
DEFAULT_LIMIT = 50
CST = timezone(timedelta(hours=8))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def ensure_state_dir():
os.makedirs(STATE_DIR, exist_ok=True)
def fetch_raw():
"""Fetch the Weibo AJAX API with retries and exponential backoff.
Mirrors the error handling from the proven weibo skill (weibo_hot.py).
"""
headers = {
'User-Agent': USER_AGENT,
'Referer': 'https://weibo.com/',
}
last_err = None
for attempt in range(MAX_RETRIES):
try:
response = requests.get(API_URL, headers=headers, timeout=10)
response.raise_for_status()
data = response.json()
if 'data' in data and 'realtime' in data['data']:
return data
else:
# API returned valid JSON but unexpected structure
# Dump for debugging, then retry
ensure_state_dir()
with open(DEBUG_RESP, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
last_err = ValueError('data.realtime missing in response')
print(
f'[retry] Unexpected API structure, saved to debug_response.json '
f'(attempt {attempt+1}/{MAX_RETRIES})',
file=sys.stderr,
)
wait = BACKOFF_BASE ** (attempt + 1)
time.sleep(wait)
continue
except requests.exceptions.HTTPError as e:
last_err = e
code = e.response.status_code if e.response else 0
if code in RETRY_CODES:
wait = BACKOFF_BASE ** (attempt + 1)
print(
f'[retry] HTTP {code}, waiting {wait}s '
f'(attempt {attempt+1}/{MAX_RETRIES})',
file=sys.stderr,
)
time.sleep(wait)
continue
raise
except requests.RequestException as e:
last_err = e
wait = BACKOFF_BASE ** (attempt + 1)
print(
f'[retry] Network error: {e}, waiting {wait}s '
f'(attempt {attempt+1}/{MAX_RETRIES})',
file=sys.stderr,
)
time.sleep(wait)
except json.JSONDecodeError as e:
# API returned empty body or HTML — dump raw text for debugging
last_err = e
ensure_state_dir()
try:
raw_text = response.text[:2000] if response else '(no response)'
with open(DEBUG_RESP, 'w', encoding='utf-8') as f:
f.write(raw_text)
except Exception:
pass
wait = BACKOFF_BASE ** (attempt + 1)
print(
f'[retry] JSON decode error: {e}, waiting {wait}s '
f'(attempt {attempt+1}/{MAX_RETRIES})',
file=sys.stderr,
)
time.sleep(wait)
raise RuntimeError(f'Failed after {MAX_RETRIES} retries: {last_err}')
def parse_items(data, limit=DEFAULT_LIMIT):
"""Extract hot search items from API response.
API response shape (already validated by fetch_raw):
{ "data": { "realtime": [ { "word": ..., "num": ..., "category": ... }, ... ] } }
"""
realtime = data['data']['realtime'][:limit]
items = []
seen_titles = set()
for idx, item in enumerate(realtime):
word = (item.get('word') or '').strip()
if not word or word in seen_titles:
continue
seen_titles.add(word)
num = item.get('num', 0)
category = (item.get('category') or '').strip()
# Build a search URL from the word
search_url = f'https://s.weibo.com/weibo?q=%23{requests.utils.quote(word)}%23'
items.append({
'rank': idx + 1,
'title': word,
'hot': num if isinstance(num, int) else 0,
'category': category,
'url': search_url,
})
return items
def canonical_json(obj):
"""Deterministic JSON string for hashing."""
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(',', ':'))
def sha256_hex(text):
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def read_file(path):
try:
with open(path, 'r', encoding='utf-8') as f:
return f.read().strip()
except FileNotFoundError:
return ''
def safe_replace(src, dst):
"""Atomic-ish file replace that works on Windows NTFS."""
if os.path.exists(dst):
bak = dst + '.bak'
try:
if os.path.exists(bak):
os.remove(bak)
os.rename(dst, bak)
except OSError:
os.remove(dst)
os.rename(src, dst)
def write_file(path, content):
tmp = path + '.tmp'
with open(tmp, 'w', encoding='utf-8') as f:
f.write(content)
safe_replace(tmp, path)
def write_json_file(path, obj):
tmp = path + '.tmp'
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(obj, f, ensure_ascii=False, indent=2)
safe_replace(tmp, path)
def format_hot_list(items):
"""Format hot list for console display (matches weibo skill style)."""
if not items:
return 'No hot search data.'
lines = ['\U0001f525 ' + chr(0x5fae) + chr(0x535a) + chr(0x70ed) + chr(0x641c) + chr(0x699c),
chr(0x2500) * 50]
for item in items:
rank = item['rank']
title = item['title']
hot = item['hot']
hot_str = f'{hot:,}' if isinstance(hot, int) else str(hot)
lines.append(f'{rank:2d}. {title:<30} \U0001f525 {hot_str}')
return '\n'.join(lines)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description='Fetch Weibo hot searches')
parser.add_argument('-l', '--limit', type=int, default=DEFAULT_LIMIT,
help=f'Number of items to fetch (default: {DEFAULT_LIMIT})')
parser.add_argument('--raw', action='store_true',
help='Output raw JSON data')
args = parser.parse_args()
ensure_state_dir()
print('[fetch] Requesting Weibo hot search API...')
data = fetch_raw()
items = parse_items(data, limit=args.limit)
if not items:
print('[warn] No items parsed from API response.', file=sys.stderr)
sys.exit(1)
print(f'[fetch] Parsed {len(items)} hot search items.')
# If --raw, just dump and exit (no state management)
if args.raw:
print(json.dumps(items, ensure_ascii=False, indent=2))
sys.exit(0)
now = datetime.now(CST).isoformat()
hot_obj = {
'fetched_at': now,
'source': 'weibo.com',
'items': items,
}
# Dedup check: hash only the title+hot pairs (ignore url which may vary)
dedup_payload = [{'title': it['title'], 'hot': it['hot']} for it in items]
new_hash = sha256_hex(canonical_json(dedup_payload))
old_hash = read_file(HOT_SHA)
if new_hash == old_hash:
print('[dedup] no change')
write_file(LAST_FETCH, now)
sys.exit(0)
# Hotlist changed - write state files
write_json_file(HOT_JSON, hot_obj)
write_file(HOT_SHA, new_hash)
write_file(LAST_FETCH, now)
print(f'[fetch] Hotlist updated. New hash: {new_hash[:16]}...')
# Print formatted list
print()
print(format_hot_list(items))
# Exit 42 signals "changed" to the scheduler / runner wrapper
sys.exit(42)
if __name__ == '__main__':
try:
main()
except Exception as e:
print(f'[error] {e}', file=sys.stderr)
sys.exit(1)
FILE:scripts/flush_queue_to_obsidian.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
flush_queue_to_obsidian.py - Flush queued summaries to Obsidian via notesmd-cli.
Called by skill_runner.py, which optionally loads env.ps1 first.
Environment variables such as NOTESMD_BIN and OBSIDIAN_VAULT may come from env.ps1 or existing process env.
Exit codes: 0 = Success, 1 = Error
"""
import json, os, subprocess, sys, io
from datetime import datetime, timezone, timedelta
from pathlib import Path
# Force UTF-8 output
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
STATE_DIR = r"C:\Users\Intel\.openclaw\state\weibo_hot"
QUEUE_JSONL = os.path.join(STATE_DIR, "queue.jsonl")
QUEUE_DONE = os.path.join(STATE_DIR, "queue.done.jsonl")
WRITTEN_IDS = os.path.join(STATE_DIR, "written_ids.json")
LAST_FLUSH = os.path.join(STATE_DIR, "last_flush.txt")
NOTESMD_BIN = os.environ.get("NOTESMD_BIN", r"C:\Users\Intel\scoop\shims\notesmd-cli.exe")
VAULT_NAME = os.environ.get("OBSIDIAN_VAULT", "test")
NOTE_PATH = os.environ.get("OBSIDIAN_NOTE_PATH", r"Inbox\Weibo Hot.md")
WRITE_MODE = os.environ.get("OBSIDIAN_WRITE_MODE", "append_to_file")
CST = timezone(timedelta(hours=8))
def ensure_notesmd_config():
"""Ensure notesmd-cli config directory exists.
notesmd-cli uses Go's os.UserConfigDir() which on Windows looks at %APPDATA%.
Under NSSM SYSTEM, APPDATA may not be set or point to a non-existent dir.
We force APPDATA and create the notesmd-cli config dir if needed.
"""
appdata = r"C:\Users\Intel\AppData\Roaming"
os.environ["APPDATA"] = appdata
os.environ["USERPROFILE"] = r"C:\Users\Intel"
os.environ["HOME"] = r"C:\Users\Intel"
config_dir = os.path.join(appdata, "notesmd-cli")
os.makedirs(config_dir, exist_ok=True)
# If no config file exists, create a minimal one with the default vault
config_file = os.path.join(config_dir, "config.yaml")
if not os.path.isfile(config_file):
print(f"[notesmd] Creating default config at {config_file}")
with open(config_file, "w", encoding="utf-8") as f:
f.write(f"defaultVault: {VAULT_NAME}\n")
def load_written_ids():
try:
with open(WRITTEN_IDS, "r", encoding="utf-8") as f: return set(json.load(f))
except (FileNotFoundError, json.JSONDecodeError): return set()
def save_written_ids(ids):
tmp = WRITTEN_IDS + ".tmp"
with open(tmp, "w", encoding="utf-8") as f: json.dump(sorted(ids), f)
safe_replace(tmp, WRITTEN_IDS)
def safe_replace(src, dst):
if os.path.exists(dst):
bak = dst + ".bak"
try:
if os.path.exists(bak): os.remove(bak)
os.rename(dst, bak)
except OSError: os.remove(dst)
os.rename(src, dst)
def read_queue():
if not os.path.isfile(QUEUE_JSONL): return []
records = []
with open(QUEUE_JSONL, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try: records.append(json.loads(line))
except json.JSONDecodeError: pass
return records
def get_markdown(record):
if record.get("md"): return record["md"]
p = record.get("md_path", "")
if p and os.path.isfile(p):
with open(p, "r", encoding="utf-8") as f: return f.read()
return None
def write_to_obsidian(content):
note = datetime.now(CST).strftime("%Y-%m-%d") + ".md" if WRITE_MODE == "daily_append" else NOTE_PATH
cmd = [NOTESMD_BIN, "create", note, "--content", content, "--append"]
if VAULT_NAME: cmd.extend(["--vault", VAULT_NAME])
# Build env with APPDATA set for notesmd-cli
env = os.environ.copy()
env["APPDATA"] = r"C:\Users\Intel\AppData\Roaming"
env["USERPROFILE"] = r"C:\Users\Intel"
env["HOME"] = r"C:\Users\Intel"
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30,
encoding="utf-8", errors="replace", env=env)
if r.returncode != 0:
print(f"[notesmd] CLI error (code {r.returncode}): {(r.stderr or '')[:500]}", file=sys.stderr)
if r.stdout: print(f"[notesmd] stdout: {r.stdout[:500]}", file=sys.stderr)
return False
return True
except subprocess.TimeoutExpired:
print("[notesmd] Timed out", file=sys.stderr); return False
except FileNotFoundError:
print(f"[notesmd] Not found: {NOTESMD_BIN}", file=sys.stderr); return False
except Exception as e:
print(f"[notesmd] Error: {e}", file=sys.stderr); return False
def write_atomic(path, content):
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f: f.write(content)
safe_replace(tmp, path)
def main():
os.makedirs(STATE_DIR, exist_ok=True)
# Ensure notesmd-cli config exists before any calls
ensure_notesmd_config()
print(f"[config] NOTESMD={NOTESMD_BIN} VAULT={VAULT_NAME} NOTE={NOTE_PATH}")
records = read_queue()
if not records: print("[flush] Queue empty."); return
written_ids = load_written_ids()
pending = [r for r in records if r.get("id") not in written_ids]
if not pending: print(f"[flush] All {len(records)} already written."); return
print(f"[flush] {len(pending)} pending...")
ok, fail, done = 0, 0, []
for rec in pending:
rid, title = rec.get("id", "?"), rec.get("title", "untitled")
print(f" Writing: {title} ({rid})...")
md = get_markdown(rec)
if not md: fail += 1; print(" [skip] No content.", file=sys.stderr); continue
if write_to_obsidian("\n\n---\n\n" + md):
written_ids.add(rid); done.append(rec); ok += 1; print(" [ok]")
else:
fail += 1; print(" [fail]")
save_written_ids(written_ids)
if done:
with open(QUEUE_DONE, "a", encoding="utf-8") as f:
for r in done: f.write(json.dumps(r, ensure_ascii=False) + "\n")
remaining = [r for r in records if r.get("id") not in written_ids]
tmp = QUEUE_JSONL + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
for r in remaining: f.write(json.dumps(r, ensure_ascii=False) + "\n")
safe_replace(tmp, QUEUE_JSONL)
write_atomic(LAST_FLUSH, datetime.now(CST).isoformat())
print(f"[flush] Done. ok={ok} fail={fail} remaining={len(remaining)}")
if __name__ == "__main__":
try: main()
except Exception as e: print(f"[error] {e}", file=sys.stderr); sys.exit(1)
FILE:scripts/skill_runner.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
skill_runner.py - Python entry point for Weibo TrendNote AI PC.
Replaces the previous PowerShell wrappers with a single Python runner so the
skill bundle can be published to ClawHub without .ps1 files.
Modes:
once [--force] Run fetch -> summarize -> flush one time.
cron-fetch Run fetch; summarize only when data changed.
cron-flush Flush queued summaries to Obsidian.
install-crons Register optional recurring OpenClaw cron jobs.
Behavior notes:
- Loads optional values from env.ps1 if that file exists.
- Preserves the original Python business logic by delegating to:
fetch_weibo_hot.py, summarize_weibo_hot.py, flush_queue_to_obsidian.py
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
STATE_DIR = Path(r"C:\Users\Intel\.openclaw\state\weibo_hot")
ENV_FILE = STATE_DIR / "env.ps1"
CRON_TZ = "Asia/Shanghai"
def _print_header(title: str) -> None:
print("\n" + "=" * 60)
print(f" {title}")
print("=" * 60 + "\n")
def load_env_ps1(path: Path = ENV_FILE) -> None:
"""Parse a minimal subset of PowerShell env assignments.
Supported lines:
$env:NAME = "value"
$env:NAME='value'
$env:NAME = value
This keeps compatibility with the user's existing env.ps1 without requiring
PowerShell at runtime.
"""
if not path.exists():
return
pattern = re.compile(r'^\s*\$env:([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$')
for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
m = pattern.match(line)
if not m:
continue
key, raw_val = m.groups()
value = raw_val.strip()
if value and value[0] not in ('"', "'"):
value = value.split("#", 1)[0].strip()
if (len(value) >= 2) and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
value = value.replace("`\"", '"').replace("`'", "'")
os.environ[key] = value
def python_executable() -> str:
return sys.executable or shutil.which("python") or "python"
def run_python(script_name: str, *args: str) -> int:
script_path = Path(__file__).resolve().parent / script_name
cmd = [python_executable(), str(script_path), *args]
print(f"[runner] exec: {' '.join(cmd)}")
completed = subprocess.run(cmd)
return completed.returncode
def run_once(force: bool = False) -> int:
_print_header("Weibo TrendNote AI PC — One-Time Run")
print("[pipeline] [1/3] Fetching...")
rc = run_python("fetch_weibo_hot.py")
if rc == 1:
print("[pipeline] Fetch failed.", file=sys.stderr)
return 1
if rc == 0 and not force:
print("[pipeline] Hot list unchanged; skipping summarize/flush.")
return 0
print("\n[pipeline] [2/3] Summarizing...")
rc2 = run_python("summarize_weibo_hot.py")
print(f"[pipeline] Summarize exited with code {rc2}")
print("\n[pipeline] [3/3] Flushing to Obsidian...")
rc3 = run_python("flush_queue_to_obsidian.py")
print(f"[pipeline] Flush exited with code {rc3}")
return 0 if rc2 == 0 and rc3 == 0 else 1
def run_cron_fetch() -> int:
_print_header("Weibo TrendNote AI PC — Cron Fetch")
rc = run_python("fetch_weibo_hot.py")
if rc == 0:
print("[cron-fetch] Hot list unchanged.")
return 0
if rc == 42:
print("[cron-fetch] Hot list changed; running summarize step.")
rc2 = run_python("summarize_weibo_hot.py")
print(f"[cron-fetch] Summarize exited with code {rc2}")
return 0 if rc2 == 0 else rc2
print(f"[cron-fetch] Fetch failed with code {rc}", file=sys.stderr)
return rc
def run_cron_flush() -> int:
_print_header("Weibo TrendNote AI PC — Cron Flush")
rc = run_python("flush_queue_to_obsidian.py")
print(f"[cron-flush] Exited with code {rc}")
return rc
def resolve_openclaw_bin() -> str:
env_bin = os.environ.get("OPENCLAW_BIN")
if env_bin:
return env_bin
found = shutil.which("openclaw")
if found:
return found
found_cmd = shutil.which("openclaw.CMD")
if found_cmd:
return found_cmd
return "openclaw"
def _quote_for_exec(value: str) -> str:
return '"' + value.replace('"', '\\"') + '"'
def openclaw_run(*args: str) -> subprocess.CompletedProcess:
cmd = [resolve_openclaw_bin(), *args]
print(f"[runner] exec: {' '.join(cmd)}")
return subprocess.run(cmd)
def install_crons() -> int:
_print_header("Weibo TrendNote AI PC — Install Cron Jobs")
oc = resolve_openclaw_bin()
runner_path = str(Path(__file__).resolve())
py = python_executable()
msg_fetch = f'exec: {_quote_for_exec(py)} {_quote_for_exec(runner_path)} cron-fetch'
msg_flush = f'exec: {_quote_for_exec(py)} {_quote_for_exec(runner_path)} cron-flush'
stale_names = [
"weibo-search-fetch-5m",
"weibo-hot-fetch-5m",
"weibo-obsidian-flush-10m",
"weibo-trendnote-aipc-fetch-5m",
"weibo-trendnote-aipc-obsidian-flush-10m",
]
for name in stale_names:
subprocess.run([oc, "cron", "remove", "--name", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
result1 = openclaw_run(
"cron", "add",
"--name", "weibo-trendnote-aipc-fetch-5m",
"--cron", "*/5 * * * *",
"--tz", CRON_TZ,
"--session", "isolated",
"--message", msg_fetch,
)
print("[cron] fetch job:", "OK" if result1.returncode == 0 else f"FAIL ({result1.returncode})")
result2 = openclaw_run(
"cron", "add",
"--name", "weibo-trendnote-aipc-obsidian-flush-10m",
"--cron", "*/10 * * * *",
"--tz", CRON_TZ,
"--session", "isolated",
"--message", msg_flush,
)
print("[cron] flush job:", "OK" if result2.returncode == 0 else f"FAIL ({result2.returncode})")
openclaw_run("cron", "list")
return 0 if result1.returncode == 0 and result2.returncode == 0 else 1
def main() -> int:
load_env_ps1()
parser = argparse.ArgumentParser(description="Weibo TrendNote AI PC runner")
sub = parser.add_subparsers(dest="command", required=True)
p_once = sub.add_parser("once", help="Run fetch -> summarize -> flush once")
p_once.add_argument("--force", action="store_true", help="Run summarize/flush even when hot list is unchanged")
sub.add_parser("cron-fetch", help="Fetch and summarize when the hot list changes")
sub.add_parser("cron-flush", help="Flush queued summaries to Obsidian")
sub.add_parser("install-crons", help="Register recurring OpenClaw cron jobs")
args = parser.parse_args()
if args.command == "once":
return run_once(force=args.force)
if args.command == "cron-fetch":
return run_cron_fetch()
if args.command == "cron-flush":
return run_cron_flush()
if args.command == "install-crons":
return install_crons()
parser.error(f"Unknown command: {args.command}")
return 2
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("[runner] Interrupted.", file=sys.stderr)
raise SystemExit(130)
FILE:scripts/summarize_weibo_hot.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
summarize_weibo_hot.py - Read hot.json, summarize via @steipete/summarize CLI,
produce Markdown digest, enqueue for Obsidian writing.
Called by skill_runner.py, which optionally loads env.ps1 first.
Environment variables such as SUMMARIZE_BIN may come from env.ps1 or existing process env.
Exit codes: 0 = Success, 1 = Error
"""
import hashlib, json, msvcrt, os, subprocess, sys, traceback, io
from datetime import datetime, timezone, timedelta
# Force UTF-8 output (fixes garbled Chinese in NSSM/PowerShell)
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
STATE_DIR = r"C:\Users\Intel\.openclaw\state\weibo_hot"
HOT_JSON = os.path.join(STATE_DIR, "hot.json")
SUMMARY_MD = os.path.join(STATE_DIR, "summary_latest.md")
QUEUE_JSONL = os.path.join(STATE_DIR, "queue.jsonl")
LAST_SUMMARIZE = os.path.join(STATE_DIR, "last_summarize.txt")
SUMMARIZE_INPUT = os.path.join(STATE_DIR, "summarize_input.txt")
TOP_N = 20
# Default to npm path. skill_runner.py may set SUMMARIZE_BIN from env.ps1 before this script runs.
SUMMARIZE_BIN = os.environ.get("SUMMARIZE_BIN", r"C:\Users\Intel\AppData\Roaming\npm\summarize.CMD")
SUMMARIZE_TIMEOUT = 180
CST = timezone(timedelta(hours=8))
def read_hot_json():
if not os.path.isfile(HOT_JSON):
raise FileNotFoundError(f"hot.json not found at {HOT_JSON}")
with open(HOT_JSON, "r", encoding="utf-8") as f:
data = json.load(f)
if not data.get("items"):
raise ValueError("hot.json has no items")
return data
def build_input_text(items):
lines = ["\u5fae\u535a\u70ed\u641c\u699c\u5b9e\u65f6\u6570\u636e", "=" * 40, ""]
for it in items[:TOP_N]:
cat = f" [{it.get('category','')}]" if it.get("category") else ""
lines.append(f"{it['rank']}. {it['title']} (\u70ed\u5ea6: {it.get('hot',0):,}){cat}")
return "\n".join(lines)
def call_summarize(input_file):
cmd = [SUMMARIZE_BIN, input_file,
"--lang", "zh", "--length", "medium",
"--plain", "--no-cache", "--timeout", "2m", "--retries", "1"]
print(f"[summarize] Running: {' '.join(cmd)}")
print(f"[summarize] SUMMARIZE_BIN = {SUMMARIZE_BIN}")
try:
r = subprocess.run(cmd, capture_output=True, text=True,
timeout=SUMMARIZE_TIMEOUT, encoding="utf-8", errors="replace")
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip()
print(f"[summarize] CLI failed (code {r.returncode}): {(r.stderr or '')[:300]}", file=sys.stderr)
except subprocess.TimeoutExpired:
print("[summarize] CLI timed out", file=sys.stderr)
except FileNotFoundError:
print(f"[summarize] CLI not found: {SUMMARIZE_BIN}", file=sys.stderr)
except Exception as e:
print(f"[summarize] CLI error: {e}", file=sys.stderr)
return None
def fmt(score):
if score is None: return "\u2014"
try:
n = int(score)
return f"{n/10000:.1f}\u4e07" if n >= 10000 else str(n)
except (ValueError, TypeError): return str(score)
def themes(items):
out, seen = [], set()
for t in [i["title"] for i in items[:TOP_N]][:8]:
k = t[:4]
if k not in seen: seen.add(k); out.append(t)
if len(out) >= 5: break
return out
def build_markdown(data, summary):
items = data["items"]
ts = data.get("fetched_at", "unknown")
try: dt = datetime.fromisoformat(ts).strftime("%Y-%m-%d %H:%M")
except Exception: dt = ts
L = [f"# \u5fae\u535a\u70ed\u641c \u2014 {dt} (CST)", "",
f"## Top {min(TOP_N, len(items))} \u70ed\u641c\u699c", "",
"| \u6392\u540d | \u8bdd\u9898 | \u70ed\u5ea6 |", "|------|------|------|"]
for i in items[:TOP_N]:
L.append(f"| {i['rank']} | {i['title']} | {fmt(i.get('hot'))} |")
L.append("")
th = themes(items)
if th:
L.extend(["## \u70ed\u70b9\u4e3b\u9898 Key Themes", ""])
L.extend(f"- {t}" for t in th)
L.append("")
L.extend(["## \u70ed\u641c\u6458\u8981 Summary", ""])
if summary:
L.append(summary)
else:
L.append("*(\u6458\u8981\u751f\u6210\u5931\u8d25)*\n")
L.extend(f"- **{i['title']}** (\u70ed\u5ea6: {fmt(i.get('hot'))})" for i in items[:10])
L.append("")
h = hashlib.sha256(json.dumps([i["title"] for i in items], ensure_ascii=False).encode()).hexdigest()[:12]
L.extend(["---", f"<!-- weibo-hot-digest sha:{h} ts:{ts} -->", ""])
return "\n".join(L)
def safe_replace(src, dst):
if os.path.exists(dst):
bak = dst + ".bak"
try:
if os.path.exists(bak): os.remove(bak)
os.rename(dst, bak)
except OSError: os.remove(dst)
os.rename(src, dst)
def write_atomic(path, content):
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f: f.write(content)
safe_replace(tmp, path)
def enqueue(record):
line = json.dumps(record, ensure_ascii=False) + "\n"
with open(QUEUE_JSONL, "a", encoding="utf-8") as f:
fd = f.fileno()
msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
try: f.write(line); f.flush()
finally:
try: msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
except OSError: pass
def main():
os.makedirs(STATE_DIR, exist_ok=True)
print("[summarize] Reading hot.json...")
data = read_hot_json()
items = data["items"]
ts = data.get("fetched_at", "")
print(f"[summarize] {len(items)} items.")
txt = build_input_text(items)
with open(SUMMARIZE_INPUT, "w", encoding="utf-8") as f: f.write(txt)
print(f"[summarize] Input: {len(txt)} chars")
summary = call_summarize(SUMMARIZE_INPUT)
md = build_markdown(data, summary)
write_atomic(SUMMARY_MD, md)
print(f"[summarize] Written: {SUMMARY_MD}")
now = datetime.now(CST).isoformat()
write_atomic(LAST_SUMMARIZE, now)
h = hashlib.sha256(md.encode()).hexdigest()[:12]
try: dt = datetime.fromisoformat(ts).strftime("%Y-%m-%d %H:%M")
except Exception: dt = ts
enqueue({"id": h, "ts": now, "type": "weibo_hot",
"md_path": SUMMARY_MD,
"title": f"\u5fae\u535a\u70ed\u641c \u2014 {dt} (CST)"})
print(f"[summarize] Enqueued id={h}")
print("\n" + "=" * 60 + "\n" + md + "=" * 60)
if __name__ == "__main__":
try: main()
except Exception as e:
print(f"[error] {e}", file=sys.stderr)
traceback.print_exc(file=sys.stderr); sys.exit(1)