roadbook

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

Safety Notice

This listing is from the official public ClawHub registry. Review SKILL.md and referenced scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "roadbook" with this command: npx skills add ffsszzll/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拉萨休整(无驾车)

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.