Self-drive Travel Roadbook

自驾旅游路书完整工具箱 — 路书创建/更新、照片归档、路线图生成(静态PNG + 交互HTML)、OSRM实际道路数据

Audits

Pass

Install

openclaw skills install ravel-roadbook

🚗 自驾路书完整工具箱

集路书模板、照片管理、地图生成于一体的自驾游记录系统。

文件路径规范

路书文件:   /mnt/c/Users/zhou/Desktop/目的地+自驾路书.md
照片目录:   /mnt/c/Users/zhou/Desktop/目的地+自驾_全部照片/(统一存放)
地图HTML:  /mnt/c/Users/zzhou/Desktop/目的地+自驾路书_地图.html
地图PNG:   /mnt/c/Users/zhou/Desktop/目的地+自驾_行程图.png
路线缓存:  /tmp/路书名_routes.json

📝 路书模板格式

# 🚗 目的地自驾路书

> ✅ 行程状态:进行中/已结束
> 开启时间:YYYY年MM月DD日 HH:MM
> 结束时间:YYYY年MM月DD日
> 路线总览:出发地 → 途经地1 → 途经地2 → 目的地

---

## 📅 行程统计
| 天数 | 路线 | 里程 | 消费 |
|------|------|------|------|
| Day 1 (MM/DD) | 起点→终点 | XXXkm | ¥XXX |
| **累计** | — | **X,XXXkm** | **¥XX,XXX** |

---

## 📅 Day 1 — YYYY年MM月DD日 | 起点 → 终点

### 🛣️ 行程信息
| 项目 | 内容 |
|------|------|
| 起点 | 起点名称 |
| 终点 | 终点名称 |
| 出发时间 | HH:MM |
| 到达时间 | HH:MM |
| 行程耗时 | 约X小时XX分 |
| 行驶里程 | XXXXX km → XXXXX km |
| 当日总里程 | **XXX km** |
| 入住宾馆 | **宾馆名称**(地点) |

### 💰 今日消费
| 类别 | 金额 |
|------|------|
| 住宿费 | ¥XXX |
| 用餐费 | ¥XX |
| 加油费 | ¥XXX |
| 高速过路费 | ¥XXX |
| 门票/观光费 | ¥XXX |
| 其他杂费 | ¥XX |
| **合计** | **¥XXX** |

### 🏔️ 景点驻留
| 景点 | 海拔 | 停留时间 | 主要风光 |
|------|------|----------|----------|
| 景点名称 | XXXXm | XX分钟 | 风光描述 |

### 😊 有趣的人与事
1. 描述1
2. 描述2

### 📷 精彩瞬间
- Day1_景点名_01~03.jpg(共3张)

---

*路书持续更新中...*

📷 照片处理流程

  1. 照片自动存入 ~/.hermes/image_cache/,文件名 img_xxxxxxxxxx.jpg
  2. 立即复制到统一照片目录:/mnt/c/Users/zhou/Desktop/目的地+自驾_全部照片/
  3. 不要猜测照片内容,等用户确认是第几天+景点后再重命名
  4. 重命名格式:DayX_景点_序号.jpg(如 Day6_珠峰_01.jpg
  5. 统一文件夹是唯一真相来源,image_cache 仅作临时缓存

🗺️ 地图生成

使用 Leaflet + OSRM 生成交互式 HTML 地图。

流程

  1. 从路书 MD 文件读取路线数据
  2. 使用 OSRM API 获取真实道路坐标
  3. 生成 Leaflet 交互地图 HTML
# Step 1: 获取OSRM路线数据
import urllib.request, json, time

def get_osrm_route(lon1, lat1, lon2, lat2):
    url = (f"https://router.project-osrm.org/route/v1/driving/"
           f"{lon1},{lat1};{lon2},{lat2}?overview=full&geometries=geojson")
    req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
    with urllib.request.urlopen(req, timeout=20) as resp:
        data = json.loads(resp.read().decode())
        if data.get('code') == 'Ok' and data['routes']:
            return data['routes'][0]['geometry']['coordinates']
    return None

route_segments = [
    (1, (104.07, 30.57), (101.02, 30.03), '雅江', 397),
    (2, (101.02, 30.03), (98.42, 30.08), '如美镇', 451),
    (3, (98.42, 30.08), (95.77, 30.87), '波密', 514),
    (4, (95.77, 30.87), (91.10, 29.65), '拉萨', 617),
    (5, (91.10, 29.65), (89.58, 29.28), '日喀则', 356),
    (6, (89.58, 29.28), (86.93, 28.53), '珠峰', 327),
    (7, (86.93, 28.53), (91.10, 29.65), '拉萨', 556),
    # Day8往返需分段
    (8, (91.10, 29.65), (91.00, 31.47), '那曲', 334),
]

all_routes = []
for day, (lon1, lat1), (lon2, lat2), end_name, mileage in route_segments:
    coords = get_osrm_route(lon1, lat1, lon2, lat2)
    if coords:
        all_routes.append({'day': day, 'coords': [[c[1], c[0]] for c in coords], 'end': end_name, 'mileage': mileage})
    time.sleep(0.6)

with open("/tmp/routes.json", 'w') as f:
    json.dump(all_routes, f, ensure_ascii=False)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>行程路线图</title>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
    <style>
        * { box-sizing: border-box; }
        body { font-family: -apple-system, sans-serif; background: #1a1a2e; color: #eee; margin: 0; }
        .header { padding: 15px; background: rgba(26,26,46,0.95); border-bottom: 1px solid #333; }
        .header h2 { margin: 0 0 5px 0; font-size: 18px; }
        .header p { margin: 0; font-size: 13px; color: #888; }
        .legend { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 15px; background: rgba(26,26,46,0.9); }
        .legend-item { display: flex; align-items: center; gap: 4px; font-size: 12px; }
        .legend-color { width: 18px; height: 3px; border-radius: 2px; }
        .legend-dashed { width: 18px; height: 0; border-top: 3px dashed; opacity: 0.7; border-radius: 0; }
        .distance-label {
            font-size: 11px;
            font-weight: 700;
            box-shadow: 0 1px 4px rgba(0,0,0,0.3);
            border-radius: 10px;
            padding: 3px 8px;
            background: rgba(255,255,255,0.9);
        }
        #map { height: calc(100vh - 110px); }
        .city-label {
            background: rgba(230, 230, 230, 0.95);
            border: 1px solid rgba(180, 180, 180, 0.8);
            border-radius: 4px;
            padding: 5px 12px;
            font-size: 13px;
            font-weight: 600;
            color: #111;
            text-align: center;
            white-space: nowrap;
            box-shadow: 0 2px 6px rgba(0,0,0,0.3);
            line-height: 1.2;
        }
    </style>
</head>
<body>
    <div class="header">
        <h2>🗺️ 行程路线图</h2>
        <p>X天 · X,XXXkm · 数据来源:OpenStreetMap + OSRM</p>
        <p style="margin-top:5px;font-size:12px;">⚠️ 虚线表示返程路线</p>
    </div>
    <div class="legend"><!-- 动态生成图例,虚线用 class="legend-dashed" --></div>
    <div id="map"></div>
    <script>
        var map = L.map('map').setView([30, 95], 5);
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {opacity: 0.65}).addTo(map);
        
        var colors = ['#ff6b6b', '#ffd93d', '#4d96ff', '#4ecdc4', '#e67e22', '#9b59b6', '#e74c3c', '#2ecc71'];
        var routes = <!-- 从 /tmp/routes.json 读取 -->;

        // 绘制路线:只有真正返程(返回之前去过的城市)才用虚线
        var visitedCities = [];
        routes.forEach(function(r, i) {
            // 判断是否返程:终点是之前去过的城市(且不是当天起点)
            var isReturn = visitedCities.indexOf(r.end) !== -1 && r.end !== r.start;
            var dashArray = isReturn ? '8, 8' : null;
            visitedCities.push(r.end);
            // 注意:r.end 可能是 "那曲→拉萨" 格式
            if (r.end.includes('→')) {
                var parts = r.end.split('→');
                visitedCities.push(parts[0]); // 那曲
            }

            L.polyline(r.coords, {
                color: colors[i], weight: 4, opacity: 0.85, dashArray: dashArray
            }).addTo(map);

            // 里程标签:显示在路线中点
            if (r.mileage) {
                var midIdx = Math.floor(r.coords.length / 2);
                var midCoord = r.coords[midIdx];
                var labelColor = colors[i];
                L.marker([midCoord[1], midCoord[0]], {
                    icon: L.divIcon({
                        html: '<div style="background:rgba(255,255,255,0.95);border-radius:10px;padding:3px 8px;font-size:11px;font-weight:700;color:' + labelColor + ';box-shadow:0 1px 4px rgba(0,0,0,0.3);white-space:nowrap;text-align:center;display:flex;align-items:center;justify-content:center;">' + r.mileage + 'km</div>',
                        iconSize: [60, 22],
                        iconAnchor: [30, -5]
                    })
                }).addTo(map);
            }
        });

        // 图例:实线/虚线 + 每天颜色(使用相同visitedCities逻辑)
        var legend = document.querySelector('.legend');
        var legendVisitedCities = [];
        routes.forEach(function(r, i) {
            var isReturn = legendVisitedCities.indexOf(r.end) !== -1 && r.end !== r.start;
            legendVisitedCities.push(r.end);
            if (r.end.includes('→')) {
                var parts = r.end.split('→');
                legendVisitedCities.push(parts[0]);
            }
            var item = document.createElement('div');
            item.className = 'legend-item';
            var colorBar = isReturn
                ? '<div class="legend-dashed" style="border-color:' + colors[i] + '"></div>'
                : '<div class="legend-color" style="background:' + colors[i] + '"></div>';
            item.innerHTML = colorBar + '<span>' + r.day + '日 ' + r.end + (r.mileage ? ' ' + r.mileage + 'km' : '') + '</span>';
            legend.appendChild(item);
        });
        
        // 城市坐标配置
        var cities = {
            '成都': { coords: [30.57, 104.07], major: true },
            '雅江': { coords: [30.03, 101.02], major: false },
            '如美镇': { coords: [30.08, 98.42], major: false },
            '波密': { coords: [30.87, 95.77], major: false },
            '拉萨': { coords: [29.65, 91.10], major: true },
            '日喀则': { coords: [29.28, 89.58], major: false },
            '珠峰': { coords: [28.53, 86.93], major: true },
            '那曲': { coords: [31.47, 91.00], major: false }
        };
        
        for (var city in cities) {
            var c = cities[city];
            var fillColor = city === '拉萨' || city === '珠峰' ? '#ff6b6b' : '#00d4ff';
            var radius = c.major ? 10 : 7;
            
            // 圆点标记
            L.circleMarker(c.coords, {
                radius: radius, color: 'white', fillColor: fillColor, fillOpacity: 1, weight: 2
            }).addTo(map).bindPopup(city);
            
            // 城市名称标签
            L.marker(c.coords, {
                icon: L.divIcon({
                    className: 'city-label',
                    html: city,
                    iconSize: [60, 20],
                    iconAnchor: [30, c.major ? -12 : -10 - radius]
                })
            }).addTo(map);
        }
    </script>
</body>
</html>

嵌入路线数据

# 在HTML中替换 <!-- 从 /tmp/routes.json 读取 --> 为实际数据
python3 -c "import json; print(json.dumps(json.load(open('/tmp/routes.json')), ensure_ascii=False))"

🚙 OSRM实际道路路线获取

问题:直线连接城市只是示意图,不能反映真实自驾路线。 方案:使用 OSRM API 获取实际驾车路线坐标。

import urllib.request, json, time

def get_osrm_route(lon1, lat1, lon2, lat2):
    """调用OSRM API获取实际驾车路线 GeoJSON坐标"""
    url = (f"https://router.project-osrm.org/route/v1/driving/"
           f"{lon1},{lat1};{lon2},{lat2}?overview=full&geometries=geojson")
    req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
    with urllib.request.urlopen(req, timeout=20) as resp:
        data = json.loads(resp.read().decode())
        if data.get('code') == 'Ok' and data['routes']:
            return data['routes'][0]['geometry']['coordinates']
    return None

# 使用示例(分段获取)
route_segments = [
    (1, (104.07, 30.57), (101.02, 30.03)),  # Day1: 成都→雅江
    (2, (101.02, 30.03), (98.42, 30.08)),    # Day2: 雅江→如美镇
]

all_routes = []
for day, (lon1, lat1), (lon2, lat2), end_name, mileage in route_segments:
    coords = get_osrm_route(lon1, lat1, lon2, lat2)
    all_routes.append({'day': day, 'coords': coords, 'end': end_name, 'mileage': mileage})
    time.sleep(0.6)  # OSRM请求间隔

# 缓存到JSON
with open("/tmp/routes.json", 'w') as f:
    json.dump(all_routes, f, ensure_ascii=False)

返回格式[[经度, 纬度], ...],逆序后用于 Leaflet L.polyline(latlngs)

注意:多途经点(如 Day5 拉萨→羊湖→日喀则)需要分段请求再合并坐标数组。


📍 常用城市/景点经纬度

主要城市

城市经度纬度
成都104.0730.57
雅江101.0230.03
如美镇98.4230.08
波密95.7730.87
拉萨91.1029.65
日喀则89.5829.28
珠峰86.9328.53
那曲91.0031.47

川藏线景点

景点经度纬度
折多山101.5630.29
高尔寺山101.1230.04
卡子拉山100.7730.08
理塘100.2730.00
姊妹湖99.9230.12
巴塘99.5230.02
芒康98.7830.03
东达山97.7730.33
怒江72拐97.4230.20
然乌湖96.6830.13
色季拉山95.6230.72
鲁朗95.3330.47
林芝94.3730.02
米拉山93.4830.37

青藏线/西藏景点

景点经度纬度
羊卓雍错90.3529.13
卡若拉山90.2229.05
纳木措90.5330.73
念青唐古拉山90.5530.46
羊八井90.0830.05
嘉措拉山88.0828.88
加乌拉山87.0828.63
珠峰大本营86.9328.53

⚠️ WSL环境注意事项

问题解决方案
Chrome headless PDF生成超时不生成PDF
微信发送媒体文件超时使用QQ邮箱发送附件
HTML地图在邮件/微信无法渲染发送HTML附件或邮件正文中嵌入截图

🔧 常用计算公式

总里程 = 最后一天里程表读数 - 第一天里程表读数
总消费 = sum(每日消费)
日均消费 = 总消费 / 天数
每公里成本 = 总消费 / 总里程

🗺️ 一键生成路线图

运行以下命令从路书文件生成完整地图:

python3 ~/.hermes/skills/travel/roadbook/scripts/generate_map.py

功能

  • 自动从路书MD文件读取路线数据
  • 获取OSRM真实道路坐标
  • 生成Leaflet交互地图
  • 包含城市名称标签(浅灰背景+黑色文字)
  • 包含每日里程标注(从路书读取,非OSRM估算)
  • 往返路线自动拆分为去程/返程

输出/mnt/c/Users/zhou/Desktop/成都自驾西藏_OSRM路线图.html


路书 patch 技巧

  • 新增 Day N 时,用前一天"### 📷 精彩瞬间"部分的内容 + --- 分隔线 作为 old_string 定位点
  • 如果内容在多处匹配,加入更多上下文使其唯一
  • 插入位置:前一天"### 📷 精彩瞬间"之后、"路书持续更新中..."之前

示例:西藏路书关键数据

⚠️ 数据来源/mnt/c/Users/zhou/Desktop/成都自驾西藏路书.md 是唯一真相,每次生成地图前必须先读取该文件获取最新路线数据,不要使用下方的静态数据表

  • 路书文件: /mnt/c/Users/zhou/Desktop/成都自驾西藏路书.md
  • 照片目录: /mnt/c/Users/zhou/Desktop/成都自驾西藏_全部照片/
  • 照片数量: 43张(截至行程结束)
  • 累计里程: 3,886km
  • 累计消费: ¥10,768.81

从路书提取坐标的方法

路书中每天的行程格式如下,从 ### 🛣️ 行程信息 表格中提取起止点:

## 📅 Day N — YYYY年MM月DD日 | 起点 → 终点
### 🛣️ 行程信息
| 项目 | 内容 |
|------|------|
| 起点 | 成都 |
| 终点 | 雅江 |

search_files 搜索 起点 |终点 | 行来批量提取每天的起止点名称,再从经纬度表查找对应坐标。

从路书读取行程数据生成地图的完整流程

import re, json, time, urllib.request

# 地名→坐标映射(来自路书中实际出现的地点)
place_coords = {
    '成都': (104.07, 30.57), '雅江': (101.02, 30.03),
    '如美镇': (98.42, 30.08), '波密': (95.77, 30.87), '波密县': (95.77, 30.87),
    '拉萨': (91.10, 29.65), '拉萨市': (91.10, 29.65),
    '日喀则': (89.58, 29.28), '珠峰': (86.93, 28.53),
    '珠峰大本营': (86.93, 28.53), '那曲': (91.00, 31.47),
}

def get_osrm_route(lon1, lat1, lon2, lat2):
    url = (f"https://router.project-osrm.org/route/v1/driving/"
           f"{lon1},{lat1};{lon2},{lat2}?overview=full&geometries=geojson")
    req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
    with urllib.request.urlopen(req, timeout=20) as resp:
        data = json.loads(resp.read().decode())
        if data.get('code') == 'Ok' and data['routes']:
            return data['routes'][0]['geometry']['coordinates']
    return None

# Step 1: 读取路书文件
with open("/mnt/c/Users/zhou/Desktop/成都自驾西藏路书.md", "r") as f:
    content = f.read()

# Step 2: 提取每天的起点终点
day_pattern = r"## 📅 Day (\d+) — .+? \| (.+?) → (.+?)(?:\n|$)"
matches = re.findall(day_pattern, content)

all_routes = []
for day_raw, start_raw, end_raw in matches:
    day_num = int(day_raw)
    start = start_raw.strip()
    end = end_raw.strip()
    
    if day_num == 9:  # Day9休整日,无驾车
        continue
    
    # Day8: 拉萨→那曲→拉萨(往返),end格式是"那曲 → 拉萨"
    if '→' in end and '拉萨' in end:
        via = end.split('→')[0].strip()  # 那曲
        coords1 = get_osrm_route(*place_coords[start], *place_coords[via])
        coords2 = get_osrm_route(*place_coords[via], *place_coords[start])
        if coords1 and coords2:
            all_routes.append({
                'day': day_num, 'start': start, 'end': f'{via}→拉萨',
                'coords': [[c[1], c[0]] for c in (coords1 + coords2)]
            })
        time.sleep(0.6)
    elif start in place_coords and end in place_coords:
        coords = get_osrm_route(*place_coords[start], *place_coords[end])
        if coords:
            all_routes.append({
                'day': day_num, 'start': start, 'end': end,
                'coords': [[c[1], c[0]] for c in coords]
            })
        time.sleep(0.6)

print(f"共 {len(all_routes)} 段路线")
for r in all_routes:
    print(f"  Day{r['day']}: {r['start']} → {r['end']}")

with open("/tmp/routes.json", 'w') as f:
    json.dump(all_routes, f)

关键解析逻辑

  • end 中表示是往返路线(如 Day8 的 那曲 → 拉萨
  • Day 9 休整日跳过,不获取路线
  • 坐标映射表中的地名必须与路书中实际写法匹配(如 波密县 vs 波密珠峰大本营 vs 珠峰

完整9天路线参考(来自实际路书)

天数路线里程
Day 1成都→雅江397km
Day 2雅江→如美镇451km
Day 3如美镇→波密514km
Day 4波密→拉萨617km
Day 5拉萨→日喀则356km
Day 6日喀则→珠峰大本营327km
Day 7珠峰大本营→拉萨556km
Day 8拉萨→那曲→拉萨(往返)668km
Day 9拉萨休整(无驾车)