← 返回 Skills 市场
ffsszzll

Self-drive Travel Roadbook

作者 ffsszzll · GitHub ↗ · v1.0.0 · MIT-0
cross-platform ⚠ suspicious
44
总下载
0
收藏
0
当前安装
1
版本数
在 OpenClaw 中安装
/install ravel-roadbook
功能描述
自驾旅游路书完整工具箱 — 路书创建/更新、照片归档、路线图生成(静态PNG + 交互HTML)、OSRM实际道路数据
使用说明 (SKILL.md)

🚗 自驾路书完整工具箱

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

文件路径规范

路书文件:   /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)
\x3C!DOCTYPE html>
\x3Chtml lang="zh-CN">
\x3Chead>
    \x3Cmeta charset="UTF-8">
    \x3Ctitle>行程路线图\x3C/title>
    \x3Clink rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
    \x3Cscript src="https://unpkg.com/[email protected]/dist/leaflet.js">\x3C/script>
    \x3Cstyle>
        * { 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;
        }
    \x3C/style>
\x3C/head>
\x3Cbody>
    \x3Cdiv class="header">
        \x3Ch2>🗺️ 行程路线图\x3C/h2>
        \x3Cp>X天 · X,XXXkm · 数据来源:OpenStreetMap + OSRM\x3C/p>
        \x3Cp style="margin-top:5px;font-size:12px;">⚠️ 虚线表示返程路线\x3C/p>
    \x3C/div>
    \x3Cdiv class="legend">\x3C!-- 动态生成图例,虚线用 class="legend-dashed" -->\x3C/div>
    \x3Cdiv id="map">\x3C/div>
    \x3Cscript>
        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 = \x3C!-- 从 /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: '\x3Cdiv 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\x3C/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
                ? '\x3Cdiv class="legend-dashed" style="border-color:' + colors[i] + '">\x3C/div>'
                : '\x3Cdiv class="legend-color" style="background:' + colors[i] + '">\x3C/div>';
            item.innerHTML = colorBar + '\x3Cspan>' + r.day + '日 ' + r.end + (r.mileage ? ' ' + r.mileage + 'km' : '') + '\x3C/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);
        }
    \x3C/script>
\x3C/body>
\x3C/html>

嵌入路线数据

# 在HTML中替换 \x3C!-- 从 /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.07 30.57
雅江 101.02 30.03
如美镇 98.42 30.08
波密 95.77 30.87
拉萨 91.10 29.65
日喀则 89.58 29.28
珠峰 86.93 28.53
那曲 91.00 31.47

川藏线景点

景点 经度 纬度
折多山 101.56 30.29
高尔寺山 101.12 30.04
卡子拉山 100.77 30.08
理塘 100.27 30.00
姊妹湖 99.92 30.12
巴塘 99.52 30.02
芒康 98.78 30.03
东达山 97.77 30.33
怒江72拐 97.42 30.20
然乌湖 96.68 30.13
色季拉山 95.62 30.72
鲁朗 95.33 30.47
林芝 94.37 30.02
米拉山 93.48 30.37

青藏线/西藏景点

景点 经度 纬度
羊卓雍错 90.35 29.13
卡若拉山 90.22 29.05
纳木措 90.53 30.73
念青唐古拉山 90.55 30.46
羊八井 90.08 30.05
嘉措拉山 88.08 28.88
加乌拉山 87.08 28.63
珠峰大本营 86.93 28.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+) — .+? \| (.+?) → (.+?)(?:\
|$)"
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 拉萨休整(无驾车)
安全使用建议
Before installing, make sure you are comfortable with the agent organizing local trip photos, writing to the specified Desktop paths, and sending route coordinates to public mapping services. Consider changing the hard-coded paths to your own folders and asking for confirmation before file-copy or rename operations.
功能分析
Type: OpenClaw Skill Name: ravel-roadbook Version: 1.0.0 The skill bundle contains hardcoded absolute paths targeting a specific user's Windows desktop via WSL (/mnt/c/Users/zhou/Desktop/) and instructions for the agent to execute an external script (generate_map.py) that is not provided in the bundle. While the logic for route generation via the OSRM API and Leaflet map rendering aligns with the stated travel purpose, the reliance on specific local user paths and the execution of unverified local scripts pose a security risk and suggest a lack of environment isolation.
能力评估
Purpose & Capability
The stated travel-roadbook purpose matches the documented capabilities: creating/updating markdown roadbooks, organizing trip photos, and generating route maps. These activities naturally involve personal photos and travel-route data.
Instruction Scope
The instructions are mostly scoped to roadbook work, but they include immediate copying and later renaming of image-cache files, so users should confirm the intended trip/photo set before use.
Install Mechanism
There is no install spec and no code files, reducing execution risk. Generated HTML does rely on remote map/CDN resources, and the registry source/homepage are not provided.
Credentials
The skill uses hard-coded WSL/Windows Desktop paths for a specific username and has no OS restriction, so it may fail or write to unintended locations unless adapted.
Persistence & Privilege
No credentials, background service, or elevated privileges are requested. It does create persistent local roadbook/photo outputs and a temporary route cache.
如何使用
  1. 确保已安装 OpenClaw(本地或 Docker 部署)
  2. 在对话框中输入安装命令:/install ravel-roadbook
  3. 安装完成后,直接呼叫该 Skill 的名称或使用 /ravel-roadbook 触发
  4. 根据 Skill 的参数说明提供必要输入,即可获得结构化输出
版本历史
v1.0.0
First release
元数据
Slug ravel-roadbook
版本 1.0.0
许可证 MIT-0
累计安装 0
当前安装数 0
历史版本数 1
常见问题

Self-drive Travel Roadbook 是什么?

自驾旅游路书完整工具箱 — 路书创建/更新、照片归档、路线图生成(静态PNG + 交互HTML)、OSRM实际道路数据. 它是一个面向 Claude Code / OpenClaw 的 AI Agent Skill 插件,目前累计下载 44 次。

如何安装 Self-drive Travel Roadbook?

在 OpenClaw 或 Claude Code 对话框中运行命令「/install ravel-roadbook」即可一键安装,无需额外配置。

Self-drive Travel Roadbook 是免费的吗?

是的,Self-drive Travel Roadbook 完全免费,采用 MIT-0 许可证,可自由下载、安装和使用。

Self-drive Travel Roadbook 支持哪些平台?

Self-drive Travel Roadbook 跨平台运行,可在任意部署了 OpenClaw / Claude Code 的环境中使用(cross-platform)。

谁开发了 Self-drive Travel Roadbook?

由 ffsszzll(@ffsszzll)开发并维护,当前版本 v1.0.0。

💬 留言讨论