添加市场监控频道 Rust 独立网页版方案文档
直读 SQLite airport_obs_log,零 Python 依赖,Axum+Askama+HTMX 纯展示
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
# 市场监控频道 — Rust 独立网页版
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
浏览器 http://VPS:3001
|
||||
│
|
||||
▼
|
||||
Rust 独立进程 (Axum + Askama + HTMX)
|
||||
│ 直接读 SQLite
|
||||
▼
|
||||
polyweather.db ← Python 采集管道写入
|
||||
└ airport_obs_log 表
|
||||
└ deb_predictions 表(今日最高实测)
|
||||
```
|
||||
|
||||
零 Python 依赖。Rust 直接从 SQLite 拿温度数据,自给自足。
|
||||
|
||||
## 技术选型
|
||||
|
||||
| 层 | 库 | 理由 |
|
||||
|---|---|---|
|
||||
| HTTP 服务 | **Axum** 0.8 | Tokio 生态,类型安全路由 |
|
||||
| 模板引擎 | **Askama** 0.12 | 编译期检查模板,零运行时开销 |
|
||||
| SQLite | **rusqlite** | 读 airport_obs_log 拿温度趋势 |
|
||||
| 自动更新 | **HTMX** | 一个 `<script>` 标签,`hx-get` + `hx-trigger` 轮询 |
|
||||
| 样式 | 手写 CSS | 暗色主题,无框架依赖 |
|
||||
|
||||
编译出来 ~10MB 二进制,内存 < 25MB。
|
||||
|
||||
## 展示字段(精简)
|
||||
|
||||
| 字段 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| 城市名 + 机场名 | config 写死 | 11 个城市固定映射 |
|
||||
| 当地时间 | 后台实时计算 | UTC + 时区偏移 |
|
||||
| 当前温度 | `airport_obs_log` 最近一条 | 大字显示 |
|
||||
| 今日实测最高 | DB 或配置文件 | 日常刷新 |
|
||||
| 趋势 | 最近 6 条 obs 线性拟合 | 升 ↑ / 降 ↓ / 平 → |
|
||||
| 跑道温度 | 首尔/釜山 AMOS 数据 | 有多条跑道时展示 |
|
||||
| 新高温标记 | 当前 >= 今日最高 + 0.3 | 蓝紫色角标 |
|
||||
|
||||
## 页面布局
|
||||
|
||||
一屏扫完 11 城。
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 🔥 市场监控 刷新 14:30:00 │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ 首尔 / Incheon │ │ 东京 / Haneda │ │ 釜山 / Gimhae │ │
|
||||
│ │ 13:30 KST │ │ 14:30 JST │ │ 14:30 KST │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ 28.5°C │ │ 31.2°C ◆新高 │ │ 25.8°C ↓ │ │
|
||||
│ │ 今日最高 29.1°C │ │ 今日最高 31.2°C │ │ 今日最高 26.5°C │ │
|
||||
│ │ ↑ │ │ ↑ │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ 18L/36R 28.5°C │ │ │ │ 18L/36R 25.8°C │ │
|
||||
│ │ 18R/36L 27.9°C │ │ │ │ 18R/36L 25.1°C │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ 安卡拉 / Esenboğa│ │赫尔辛基 / Vantaa │ │阿姆斯特丹/Schiphol│ │
|
||||
│ │ ... │ │ ... │ │ ... │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │伊斯坦布尔/Airport│ │ 巴黎 /Le Bourget │ │ 香港 / Observatory│ │
|
||||
│ │ ... │ │ ... │ │ ... │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ 流浮山/LauFauShan│ │ 台北 / Songshan │ │
|
||||
│ │ ... │ │ ... │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 单张卡片
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ 首尔 / Incheon │ ← 城市名 / 机场名
|
||||
│ 13:30 KST │ ← 当地时间
|
||||
│ │
|
||||
│ 28.5°C │ ← 大字当前温度
|
||||
│ │
|
||||
│ 今日最高 29.1°C ↑ │ ← 最高温 + 趋势箭头
|
||||
│ │
|
||||
│ ── 跑道 ── │ ← 首尔/釜山有
|
||||
│ 18L/36R 28.5°C │
|
||||
│ 18R/36L 27.9°C │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
温度较高或触达新高时,温度数字用暖色高亮。趋势指示用箭头(↑ 绿色 / ↓ 蓝色 / → 灰色)。
|
||||
|
||||
## 数据源
|
||||
|
||||
直接读 SQLite `airport_obs_log` 表:
|
||||
|
||||
```sql
|
||||
SELECT temp_c, obs_time, created_at
|
||||
FROM airport_obs_log
|
||||
WHERE icao = ? AND created_at > datetime('now', '-2 hours')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 12;
|
||||
```
|
||||
|
||||
- 最新一条 → 当前温度
|
||||
- 最近 6 条 → 算趋势(线性拟合斜率)
|
||||
- 今日最高从 DB 或单独配置拿
|
||||
|
||||
11 个城市的 ICAO 映射写死在 Rust config 里(与 telegram_push.py 一致):
|
||||
|
||||
```rust
|
||||
const CITIES: &[(&str, &str, &str, &str, i32)] = &[
|
||||
("seoul", "首尔", "RKSI", "Incheon", 9),
|
||||
("busan", "釜山", "RKPK", "Gimhae", 9),
|
||||
("tokyo", "东京", "44166", "Haneda", 9),
|
||||
("ankara", "安卡拉", "17128", "Esenboğa", 3),
|
||||
("helsinki", "赫尔辛基", "EFHK", "Vantaa", 3),
|
||||
("amsterdam","阿姆斯特丹", "EHAM", "Schiphol", 2),
|
||||
("istanbul", "伊斯坦布尔", "17058", "Airport", 3),
|
||||
("paris", "巴黎", "LFPB", "Le Bourget", 2),
|
||||
("hong kong","香港", "HKO", "Observatory", 8),
|
||||
("lau fau shan","流浮山", "LFS", "Lau Fau Shan", 8),
|
||||
("taipei", "台北", "466920", "Songshan", 8),
|
||||
];
|
||||
```
|
||||
|
||||
## Rust 项目结构
|
||||
|
||||
```
|
||||
monitoring-web/ ← 放在 PolyWeather 仓库根目录下
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── main.rs ← Axum 启动,路由注册
|
||||
│ ├── db.rs ← SQLite 查询(rusqlite)
|
||||
│ ├── model.rs ← CitySnapshot 数据结构
|
||||
│ ├── trend.rs ← 温度趋势计算
|
||||
│ └── templates/
|
||||
│ └── monitor.html ← Askama 模板(完整页面)
|
||||
└── static/
|
||||
└── style.css
|
||||
```
|
||||
|
||||
### `Cargo.toml`
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "market-monitor"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
askama = { version = "0.12", features = ["with-axum"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
chrono = "0.4"
|
||||
```
|
||||
|
||||
### 核心逻辑伪代码
|
||||
|
||||
```rust
|
||||
// main.rs
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let db_path = std::env::var("POLYWEATHER_DB_PATH")
|
||||
.unwrap_or_else(|_| "data/polyweather.db".into());
|
||||
let state = Arc::new(AppState { db_path });
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index)) // 完整页面
|
||||
.route("/api/data", get(cards)) // HTMX 轮询
|
||||
.nest_service("/static", ServeDir::new("static"))
|
||||
.with_state(state);
|
||||
|
||||
let addr = std::env::var("MONITOR_LISTEN_ADDR")
|
||||
.unwrap_or_else(|_| "0.0.0.0:3001".into());
|
||||
tracing::info!("市场监控: http://{}", addr);
|
||||
axum::serve(TcpListener::bind(&addr).await.unwrap(), app).await.unwrap();
|
||||
}
|
||||
|
||||
// GET / → 渲染完整 HTML 页面
|
||||
async fn index(state: State<Arc<AppState>>) -> Html<String> {
|
||||
let cities = load_all_cities(&state.db_path).await;
|
||||
let tmpl = MonitorTemplate { cities, full_page: true };
|
||||
Html(tmpl.render().unwrap())
|
||||
}
|
||||
|
||||
// GET /api/data → HTMX 轮询,只返回卡片网格(无完整 HTML 壳)
|
||||
async fn cards(state: State<Arc<AppState>>) -> Html<String> {
|
||||
let cities = load_all_cities(&state.db_path).await;
|
||||
let tmpl = MonitorTemplate { cities, full_page: false };
|
||||
Html(tmpl.render().unwrap())
|
||||
}
|
||||
```
|
||||
|
||||
### DB 查询
|
||||
|
||||
```rust
|
||||
// db.rs
|
||||
fn get_recent_obs(db_path: &str, icao: &str, limit: usize) -> Vec<Obs> {
|
||||
let conn = Connection::open(db_path)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT temp_c, obs_time, created_at
|
||||
FROM airport_obs_log
|
||||
WHERE icao = ?1 AND created_at > datetime('now', '-3 hours')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?2"
|
||||
)?;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 趋势计算
|
||||
|
||||
```rust
|
||||
// trend.rs
|
||||
enum Trend { Rising, Falling, Flat }
|
||||
|
||||
fn calc_trend(obs: &[Obs]) -> Trend {
|
||||
// 最近 6 条温度做线性回归
|
||||
// 斜率 > +0.2 → Rising (↑)
|
||||
// 斜率 < -0.2 → Falling (↓)
|
||||
// 否则 → Flat (→)
|
||||
}
|
||||
```
|
||||
|
||||
### HTMX 轮询
|
||||
|
||||
```html
|
||||
<!-- monitor.html -->
|
||||
<div id="card-grid"
|
||||
hx-get="/api/data"
|
||||
hx-trigger="every 60s"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#spinner">
|
||||
{% for city in cities %}
|
||||
<div class="card">
|
||||
<!-- 卡片内容 -->
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="spinner" class="htmx-indicator">刷新中...</div>
|
||||
```
|
||||
|
||||
HTMX 从 CDN 加载,一个 `<script>` 标签搞定。
|
||||
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
cd monitoring-web
|
||||
cargo build --release
|
||||
|
||||
# 环境变量
|
||||
export POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
export MONITOR_LISTEN_ADDR=0.0.0.0:3001
|
||||
|
||||
./target/release/market-monitor &
|
||||
```
|
||||
|
||||
systemd unit 或 supervisor 托管即可。不占资源,VPS 上完全无感。
|
||||
|
||||
## 与 Push 的对比
|
||||
|
||||
| | Telegram Push | 网页监控 |
|
||||
|---|---|---|
|
||||
| 目的 | 精准触达:该不该发消息 | 全景扫描:一眼看 11 城 |
|
||||
| 逻辑 | DEB + 三条件 + 去重 | 温度 + 趋势 + 最高 |
|
||||
| 频率 | 条件触发 | 60s 轮询 |
|
||||
| 数据源 | `_analyze()` 完整分析 | SQLite 直接读 |
|
||||
| 用户 | 频道订阅者 | 打开网页的任何人 |
|
||||
|
||||
两者互补,不冲突。Push 仍然跑,网页给想主动扫一眼的人。
|
||||
Reference in New Issue
Block a user