🚗 自驾路书完整工具箱
集路书模板、照片管理、地图生成于一体的自驾游记录系统。
文件路径规范
路书文件: /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张)
---
*路书持续更新中...*
📷 照片处理流程
- 照片自动存入
~/.hermes/image_cache/,文件名img_xxxxxxxxxx.jpg - 立即复制到统一照片目录:
/mnt/c/Users/zhou/Desktop/目的地+自驾_全部照片/ - 不要猜测照片内容,等用户确认是第几天+景点后再重命名
- 重命名格式:
DayX_景点_序号.jpg(如Day6_珠峰_01.jpg) - 统一文件夹是唯一真相来源,image_cache 仅作临时缓存
🗺️ 地图生成
使用 Leaflet + OSRM 生成交互式 HTML 地图。
流程:
- 从路书 MD 文件读取路线数据
- 使用 OSRM API 获取真实道路坐标
- 生成 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.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+) — .+? \| (.+?) → (.+?)(?:\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 | 拉萨休整(无驾车) | — |