Skill: ffsszzll/ravel-roadbook (version 1.0.1)

--- name: roadbook description: 自驾旅游路书完整工具箱 — 路书创建/更新、照片归档、路线图生成（Leaflet+OSRM）、路书PDF生成 category: travel --- # 🚗 自驾路书完整工具箱 集路书模板、照片管理、地图生成、PDF输出于一体的自驾游记录系统。 --- ## 1. 文件路径规范 ``` 路书文件: /mnt/c/Users/zhou/Desktop/目的地自驾路书.md 路书HTML: /mnt/c/Users/zhou/Desktop/目的地路书_打印版.html 路书PDF: /mnt/c/Users/zhou/Desktop/目的地路书.pdf 照片目录: /mnt/c/Users/zhou/Desktop/目的地自驾_全部照片/ 封面地图: /mnt/c/Users/zhou/Desktop/目的地自驾_全部照片/map_cover.jpg 地图HTML: /mnt/c/Users/zhou/Desktop/目的地自驾_OSRM路线图.html 路线缓存: /tmp/目的地_routes.json ``` > ⚠️ **命名规则**： > - 路书HTML/PDF：不带"自驾"二字（如 `成都至西昌路书_打印版.html`） > - 照片目录：带"自驾"和"全部照片"（如 `成都至西昌自驾_全部照片/`） > - 路书文件可放在 Desktop 或其子文件夹中（如 `云贵旅行/成都至西昌自驾路书.md`） > - **数据唯一性**：路书 MD 文件是唯一真相，所有生成操作（地图、统计）前必须先读取该文件获取最新数据。 --- ## 2. 📝 路书录入流程 ### 2.1 新建路书模板 ```markdown # 🚗 目的地自驾路书 > ✅ 行程状态：进行中/已结束 > 开启时间：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日 | 起点 → 终点 ## 📅 Day 9 — YYYY年MM月DD日 | 拉萨休整 ``` ### 2.2 录入原则：每录入一个地点，立即查询海拔 > ⚠️ **重要**：每录入一个城市/景点/途经点，**立即调用海拔查询**，将结果填入表格。不要等到最后统一录入，否则容易遗漏。 **需要查询海拔的地点：** - **起点海拔**：查询出发地的海拔 - **终点海拔**：查询目的地的海拔 - **途经点/景点海拔**：查询每个途经景点的海拔 ### 2.3 海拔查询方式 #### 方式一：批量自动查询（推荐） 运行脚本，自动读取路书中所有地点并填充海拔： ```bash python ~/.hermes/skills/travel/roadbook/scripts/lookup_elevation.py <路书MD路径> ``` #### 方式二：单独查询 ```python from scripts.lookup_elevation import lookup_elevation elevation = lookup_elevation("拉萨") # 返回: 3650（米） ``` ### 2.4 每日录入模板 ```markdown ## 📅 Day 1 — YYYY年MM月DD日 | 起点 → 终点 ### 🛣️ 行程信息 | 项目 | 内容 | |------|------| | 起点 | 起点名称（海拔：XXX m） | | 终点 | 终点名称（海拔：XXX m） | | 出发时间 | HH:MM | | 到达时间 | HH:MM | | 行程耗时 | 约X小时XX分 | | 行驶里程 | XXXXX km → XXXXX km | | 当日总里程 | **XXX km** | | 入住宾馆 | **宾馆名称**（地点） | ### 💰 今日消费 | 类别 | 金额 | |------|------| | 住宿费 | ¥XXX | | 用餐费 | ¥XX | | 加油费 | ¥XXX | | 高速过路费 | ¥XXX | | 门票/观光费 | ¥XXX | | 其他杂费 | ¥XX | | **合计** | **¥XXX** | ### 🏔️ 景点驻留 | 景点 | 海拔 | 停留时间 | 主要风光 | |------|------|----------|----------| | 景点名称 | XXXX m | XX分钟 | 风光描述 | ### 😊 有趣的人与事 1. 描述1 2. 描述2 ### 📷 精彩瞬间 - Day1_景点名_01~03.jpg（共3张） ``` > 📌 **景点海拔来源**： > 1. 使用 `lookup_elevation.py` 脚本自动查询 > 2. 或通过网络搜索确认（部分著名景点如珠峰大本营有精确海拔） > 3. **所有景点都必须有海拔数据**，格式：`XXXX m`（纯数字+空格+m） ### 2.5 标题格式规则 > 📌 **格式规则**： > - **有 `→`**（如 `成都 → 雅江`）：跨城行程，解析为起点 → 终点 > - **无 `→`**（如 `拉萨休整`）：同城/休整，起点终点自动相同（自动去除"休整/休息/玩"等后缀） > > ⚠️ 两种格式不可混用，同一路书保持统一格式。 --- ## 3. 📷 照片处理流程 1. 照片自动存入 `~/.hermes/image_cache/`，文件名 `img_xxxxxxxxxx.jpg` 2. **立即复制**到统一照片目录：`/mnt/c/Users/zhou/Desktop/目的地+自驾_全部照片/` 3. **不要猜测**照片内容，等用户确认是第几天+景点后再重命名 4. 重命名格式：`DayX_景点_序号.jpg`（如 `Day6_珠峰_01.jpg`） 5. 统一文件夹是唯一真相来源，image_cache 仅作临时缓存 --- ## 4. 🗺️ 地图生成 使用 Leaflet + OSRM 生成交互式 HTML 地图。**生成时自动调用 OSRM API 获取真实道路路线，无需用户触发**。城市标签、地名、图例等**默认全部使用中文**。 > ⚠️ **重要**：地图默认就是 OSRM 真实道路轨迹图，不是简单示意图；如果生成了无轨迹的示意图，说明没有正确执行代码中的 OSRM 调用。 ### 4.1 生成脚本 ```bash # 修改脚本中的路径配置后运行 python ~/.hermes/skills/travel/roadbook/scripts/generate_map.py ``` ### 4.2 回头路/返程检测逻辑 - 记录城市首次出现的顺序（`visitedOrder` 数组） - 如果某段路线的起点在城市访问顺序中位于终点之后，判定为回头路 - `label === '返程'` 或判定为回头路的路线均以虚线（`dashArray: '8, 8'`）显示 ### 4.3 图例显示逻辑 - 格式：`第X天 起点→途经点1→途经点2→终点 XXkm` - 途经点直接嵌入路线字符串（如 Day5：`拉萨→羊卓雍错→日喀则 356km`） - 虚线图例说明：`虚线为回头路/返程` ### 4.4 坐标参考表 | 主要城市 | 经度 | 纬度 | | 川藏线景点 | 经度 | 纬度 | | 青藏线/西藏景点 | 经度 | 纬度 | |----------|------|------|--|------------|------|------|--|----------------|------|------| | 成都 | 104.07 | 30.57 | | 折多山 | 101.56 | 30.29 | | 羊卓雍错 | 90.35 | 29.13 | | 雅江 | 101.02 | 30.03 | | 高尔寺山 | 101.12 | 30.04 | | 卡若拉山 | 90.22 | 29.05 | | 如美镇 | 98.42 | 30.08 | | 卡子拉山 | 100.77 | 30.08 | | 纳木措 | 90.53 | 30.73 | | 波密 | 95.77 | 30.87 | | 理塘 | 100.27 | 30.00 | | 念青唐古拉山 | 90.55 | 30.46 | | 拉萨 | 91.10 | 29.65 | | 姊妹湖 | 99.92 | 30.12 | | 羊八井 | 90.08 | 30.05 | | 日喀则 | 89.58 | 29.28 | | 巴塘 | 99.52 | 30.02 | | 嘉措拉山 | 88.08 | 28.88 | | 珠峰 | 86.93 | 28.53 | | 芒康 | 98.78 | 30.03 | | 加乌拉山 | 87.08 | 28.63 | | 那曲 | 91.00 | 31.47 | | 东达山 | 97.77 | 30.33 | | 珠峰大本营 | 86.93 | 28.53 | | 攀枝花 | 101.72 | 26.58 | | 怒江72拐 | 97.42 | 30.20 | | | | | | 西昌 | 102.27 | 27.89 | | 然乌湖 | 96.68 | 30.13 | | | | | | 昆明 | 102.71 | 25.04 | | 色季拉山 | 95.62 | 30.72 | | | | | | 抚仙湖 | 102.85 | 24.45 | | 鲁朗 | 95.33 | 30.47 | | | | | | 万峰林 | 104.90 | 25.10 | | 林芝 | 94.37 | 30.02 | | | | | | 石棉 | 102.36 | 29.23 | | 米拉山 | 93.48 | 30.37 | | | | | --- ## 5. 📄 路书HTML/PDF生成 从路书MD文件生成美观的HTML路书，**使用与现有正常工作路书完全相同的CSS和布局**。可导出PDF或生成长图格式。 ### 5.0 已知格式差异 不同路书的 `---` 分隔符格式可能不同： - **西藏路书**：`---` 后跟单换行 `\n` - **云贵路书**：`---` 后跟双换行 `\n\n` **调试方法**：如果 `re.split()` 只返回1个块，先打印 `---` 的位置确认格式，再调整正则。正确模式应为 `\n---\n\n(?=## 📅 Day)`（双换行）。 ### 5.1 汇总表格式（关键） ⚠️ **不同路书的汇总表格式不同，必须分别用不同正则匹配**： **格式A — 有里程行**（如 `| Day 1 (05/02) | 成都→西昌 | 356km | ¥718 |`）： ```python for m in re.finditer(r'\|\s*Day\s*(\d+)\s*\(([^)]+)\)\s*\|\s*(.+?)\s*\|\s*([\d,]+)\s*km\s*\|\s*(¥[\d,.]+)\s*\|', content): summary.append({'day': int(m.group(1)), 'date': m.group(2), 'route': ..., 'km': m.group(4)+'km', 'cost': m.group(5)}) ``` **格式B — 休整日行**（如 `| Day 3 (05/04) | 昆明休整 | — | ¥530 |`）： ```python for m in re.finditer(r'\|\s*Day\s*(\d+)\s*\(([^)]+)\)\s*\|\s*(.+?)\s*\|\s*—\s*\|\s*(¥[\d,.]+)\s*\|', content): summary.append({'day': int(m.group(1)), 'date': m.group(2), 'route': ..., 'km': '—', 'cost': m.group(4)}) ``` > ⚠️ **注意**：路书 MD 文件中的汇总表，`km` 数字前**没有空格**（如 `356km` 而非 `356 km`），必须用 `([\d,]+)\s*km` 匹配，不能用 `\d+\s+km`。 ### 5.2 当日总里程解析 ⚠️ **里程数据必须从路书MD文件解析，不可硬编码**。不同路书的表格列数可能不同，简单的列索引匹配会漏掉Day3/5/7等休整日。 **正确方法**：遍历MD文件的每一行，查找含"当日总里程"的行，然后向上回溯至多40行找到Day标题，取其中的数字： ```python lines = content.split('\n') mileage = {} for i, line in enumerate(lines): if '当日总里程' in line: for j in range(i-1, max(0, i-40), -1): dm = re.search(r'## 📅 Day (\d+)', lines[j]) if dm: m = re.search(r'\*\*(\d+)\s*km\*\*', line) if m: mileage[int(dm.group(1))] = int(m.group(1)) break ``` > ⚠️ **注意**：`当日总里程` 表格行的格式为 `| 当日总里程 | **356 km** |`，其中 `**356 km**` 包含空格，应匹配 `\*\*(\d+)\s*km\*\*`。 **错误方法**：用正则匹配表格某列（如 `\|\s*\d+\s*km\s*\|`），因为列索引因路书不同而变化，会漏掉部分天数。 ### 5.3 生成流程 #### Step 1: 生成HTML（Python脚本） ```bash # 修改脚本中的路径配置后运行 python ~/.hermes/skills/travel/roadbook/scripts/generate_roadbook_html.py ``` #### Step 2: 导出PDF ```bash cd /mnt/c/Users/zhou/Desktop google-chrome --headless --disable-gpu --no-sandbox \ --print-to-pdf="成都自驾西藏路书.pdf" --print-to-pdf-no-header "成都自驾西藏路书_打印版.html" ``` --- ## Scripts | 脚本 | 功能 | |------|------| | `scripts/generate_map.py` | 生成OSRM路线图（Leaflet交互式HTML） | | `scripts/generate_roadbook_html.py` | 生成路书HTML打印版（含封面、统计、每日行程） | | `scripts/lookup_elevation.py` | 查询并填充路书中各地点的海拔 |

path: scripts/generate_map.py
content: #!/usr/bin/env python3
"""生成自驾路书OSRM路线图（Leaflet交互式HTML）"""
import re, json, time, os, urllib.request

ROADBOOK_PATH = "/mnt/c/Users/zhou/Desktop/成都自驾西藏路书.md"
OUTPUT_PATH   = "/mnt/c/Users/zhou/Desktop/成都自驾西藏_OSRM路线图.html"
ROUTE_CACHE   = "/tmp/roadbook_routes_cache.json"

PLACE_COORDS = {
    '成都': (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), '珠峰大本营': (86.93, 28.53), '那曲': (91.00, 31.47),
    '羊卓雍错': (90.35, 29.13),
}
VIA_POINTS = {
    5: ['拉萨', '羊卓雍错', '日喀则'],
    7: ['珠峰', '日喀则', '拉萨'],
}

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

def get_osrm_route_via(coords_list):
    waypoints = ";".join(f"{lon},{lat}" for lon, lat in coords_list)
    url = f"https://router.project-osrm.org/route/v1/driving/{waypoints}?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

def normalize_city(name):
    """从完整地址中提取城市名（如'四川省雅江县'→'雅江', '珠峰大本营（巴松村）'→'珠峰大本营', '万峰林景区'→'万峰林'）"""
    name = re.sub(r'[（(].*?[)）]', '', name).strip()
    parts = re.split(r'(自治区|省|市|县|区)', name)
    suffixes = {'自治区', '省', '市', '县', '区'}
    meaningful = [p.strip() for p in parts if p.strip() and p not in suffixes]
    return meaningful[-1] if meaningful else name

def extract_mileage(content):
    """从路书提取每日里程，返回 {day: km}
    ⚠️ 格式为 **356 km**（数字与km之间有空格），必须用 \\d+\\s+km 匹配"""
    pattern = r"当日总里程.*?\*\*(\d+)\s+km\*\*"
    matches = re.findall(pattern, content)
    return {i+1: int(m) for i, m in enumerate(matches)}

def extract_routes(content, mileage):
    """从路书标题行提取每天路线，返回 [{day, start, end, dist_km, ...}, ...]"""
    day_pattern = r"## 📅 Day (\d+) — .+? \| (.+?) → (.+?)(?:\n|$)"
    matches = re.findall(day_pattern, content)
    all_routes = []
    for day_raw, start_raw, end_raw in matches:
        day = int(day_raw)
        start = normalize_city(start_raw.strip())
        end = normalize_city(end_raw.strip())
        if day == 9:  continue  # 休整日跳过
        # 往返路线（如 Day8: 拉萨→那曲→拉萨）
        if '→' in end_raw and '拉萨' in end_raw:
            via = end.split('→')[0].strip()
            c1 = get_osrm_route(*PLACE_COORDS[start], *PLACE_COORDS[via])
            time.sleep(0.6)
            c2 = get_osrm_route(*PLACE_COORDS[via], *PLACE_COORDS[start])
            time.sleep(0.6)
            if c1 and c2:
                all_routes.append({'day': day, 'label': '去程', 'start': start, 'end': via,
                    'coords': [[c[1], c[0]] for c in c1], 'dist_km': mileage.get(day, 0)//2})
                all_routes.append({'day': day, 'label': '返程', 'start': via, 'end': start,
                    'coords': [[c[1], c[0]] for c in c2], 'dist_km': mileage.get(day, 0)//2})
        elif start in PLACE_COORDS and end in PLACE_COORDS:
            if day in VIA_POINTS:
                places = VIA_POINTS[day]
                coords_list = [PLACE_COORDS[p] for p in places if p in PLACE_COORDS]
                coords = get_osrm_route_via(coords_list)
                time.sleep(0.6)
                if coords:
                    all_routes.append({'day': day, 'label': f"Day{day}", 'start': places[0], 'end': places[-1],
                        'coords': [[c[1], c[0]] for c in coords], 'dist_km': mileage.get(day, 0), 'via': places[1:-1]})
            else:
                coords = get_osrm_route(*PLACE_COORDS[start], *PLACE_COORDS[end])
                time.sleep(0.6)
                if coords:
                    all_routes.append({'day': day, 'label': f"Day{day}", 'start': start, 'end': end,
                        'coords': [[c[1], c[0]] for c in coords], 'dist_km': mileage.get(day, 0)})
    return all_routes

def build_html(routes, colors):
    for r in routes:
        coords = r['coords']
        r['mid'] = coords[len(coords) // 2]
    return_count = sum(1 for r in routes if r['label'] == '返程')

    legend = ""
    for i, r in enumerate(routes):
        is_return = r['label'] == '返程'
        dash = 'border-top: 3px dashed;' if is_return else ''
        via = r.get('via', [])
        route_str = r['start'] + '→' + ('→'.join(via) + '→' if via else '') + r['end']
        legend += f"<div class='legend-item'><div class='legend-color' style='background:{colors[i]};{dash}'></div><span>第{r['day']}天 {route_str} {r['dist_km']}km</span></div>\n"
    if return_count > 0:
        legend += "<div class='legend-item'><div class='legend-color' style='border-top: 3px dashed #888'></div><span>虚线为回头路/返程</span></div>\n"

    return f'''<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8">
<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}}
#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:4px 10px;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)}}
.distance-label{{background:rgba(255,255,255,0.92);border:1px solid rgba(150,150,150,0.7);border-radius:10px;padding:3px 8px;font-size:11px;font-weight:700;color:#333;text-align:center;white-space:normal;word-break:keep-all;box-shadow:0 1px 4px rgba(0,0,0,0.2);max-width:260px;line-height:1.3}}
</style></head><body>
<div class="header"><h2>🗺️ 自驾路书路线图</h2><p>X天 · X,XXXkm · 数据来源：高德地图 + OSRM</p></div>
<div class="legend">{legend}</div>
<div id="map"></div>
<script>
var map=L.map('map').setView([30,95],5);
L.tileLayer('https://webrd{{s}}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={{x}}&y={{y}}&z={{z}}',{{s:['1','2','3','4'],opacity:0.65}}).addTo(map);
var colors={json.dumps(colors)};
var routeSegments={json.dumps(routes, ensure_ascii=False)};
var uniqueDays=[...new Set(routeSegments.map(function(r){{return r.day}}))];
var totalDays=uniqueDays.length;
var totalKm=routeSegments.reduce(function(s,r){{return s+(r.dist_km||0)}},0);
document.querySelector('.header p').innerHTML=totalDays+'天 · '+totalKm.toLocaleString()+'km · 数据来源：高德地图 + OSRM';
var visitedOrder=[],firstVisit={{}};
routeSegments.forEach(function(seg,i){{
  if(firstVisit[seg.start]===undefined){{firstVisit[seg.start]=i;visitedOrder.push(seg.start)}}
  if(firstVisit[seg.end]===undefined){{firstVisit[seg.end]=i;visitedOrder.push(seg.end)}}
}});
routeSegments.forEach(function(seg,i){{
  var si=visitedOrder.indexOf(seg.start),ei=visitedOrder.indexOf(seg.end);
  var isReturn=seg.label==='返程'||si>ei;
  L.polyline(seg.coords,{{color:colors[i],weight:4,opacity:0.85,dashArray:isReturn?'8, 8':null}}).addTo(map);
}});
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}},'羊卓雍错':{{coords:[29.13,90.35],major:false}}}};
var viaPoints=['羊卓雍错'];
for(var city in cities){{var c=cities[city];var isVia=viaPoints.indexOf(city)!==-1;
  var fillColor=city==='拉萨'||city==='珠峰'?'#ff6b6b':(isVia?'#ff9f43':'#00d4ff');
  var radius=c.major?10:(isVia?8:7);
  L.circleMarker(c.coords,{{radius:radius,color:'white',fillColor:fillColor,fillOpacity:1,weight:2}}).addTo(map).bindPopup(city);
  if(!isVia){{L.marker(c.coords,{{icon:L.divIcon({{className:'city-label',html:city,iconSize:null,iconAnchor:[0,c.major?-22:-20]}})}}).addTo(map)}}
  else{{L.marker(c.coords,{{icon:L.divIcon({{className:'city-label',html:'🏔️ '+city,iconSize:null,iconAnchor:[0,-40]}})}}).addTo(map)}}
}}
routeSegments.forEach(function(seg){{L.marker(seg.mid,{{icon:L.divIcon({{className:'distance-label',html:seg.dist_km+'km',iconSize:null,iconAnchor:[0,-5]}})}}).addTo(map)}});
</script></body></html>'''

def main():
    colors = ['#ff6b6b','#ffd93d','#4d96ff','#4ecdc4','#e67e22','#9b59b6','#e74c3c','#2ecc71','#f39c12']
    with open(ROADBOOK_PATH, "r", encoding="utf-8") as f:
        content = f.read()
    mileage = extract_mileage(content)
    routes = extract_routes(content, mileage)
    print(f"共 {len(routes)} 段路线")
    with open(ROUTE_CACHE, 'w') as f:
        json.dump(routes, f)
    html = build_html(routes, colors)
    with open(OUTPUT_PATH, 'w', encoding='utf-8') as f:
        f.write(html)
    print(f"✅ 输出: {OUTPUT_PATH} ({os.path.getsize(OUTPUT_PATH):,} bytes)")

if __name__ == "__main__":
    main()


path: scripts/generate_roadbook_html.py
content: #!/usr/bin/env python3
"""生成路书HTML打印版 - 使用与正常工作路书相同的CSS和布局"""
import re, os, subprocess, json, time, urllib.request

ROADBOOK_PATH = "/mnt/c/Users/zhou/Desktop/成都自驾西藏路书.md"
PHOTOS_DIR    = "/mnt/c/Users/zhou/Desktop/成都自驾西藏_全部照片/"
OUTPUT_HTML   = "/mnt/c/Users/zhou/Desktop/成都自驾西藏路书_打印版.html"
COVER_MAP     = "/mnt/c/Users/zhou/Desktop/成都自驾西藏_全部照片/map_cover.jpg"
ROUTE_CACHE   = "/tmp/roadbook_routes_cache.json"

# 预扫描照片目录
day_photos = {}
if os.path.isdir(PHOTOS_DIR):
    for fname in sorted(os.listdir(PHOTOS_DIR)):
        m = re.match(r'Day(\d+)_', fname)
        if m:
            day_photos.setdefault(int(m.group(1)), []).append(fname)

CSS = r"""
    @page { margin: 0; size: A4; }
    @media print { .page-break { page-break-after: always; } }
    * { box-sizing: border-box; }
    body { font-family: "PingFang SC", "Helvetica Neue", Helvetica, Arial, sans-serif; color: #1a1a2e; margin: 0; padding: 0; background: #fff; }
    .cover { min-height: 297mm; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: white; position: relative; overflow: hidden; padding: 40mm 30mm; }
    .cover::before { content: ''; position: absolute; top: -50%; right: -30%; width: 80%; height: 200%; background: linear-gradient(45deg, transparent, rgba(255,255,255,0.03), transparent); transform: rotate(12deg); }
    .cover-badge { display: inline-block; background: linear-gradient(135deg, #e94560, #ff6b6b); padding: 6px 16px; border-radius: 20px; font-size: 11px; letter-spacing: 2px; margin-bottom: 20px; }
    .cover h1 { font-size: 42px; font-weight: 300; letter-spacing: 4px; margin: 0 0 10px 0; line-height: 1.2; }
    .cover-subtitle { font-size: 14px; color: rgba(255,255,255,0.6); letter-spacing: 3px; margin-bottom: 50px; }
    .cover-map { width: 100%; height: 90mm; background: rgba(255,255,255,0.05); border-radius: 12px; margin: 30px 0; border: 1px solid rgba(255,255,255,0.1); overflow: hidden; }
    .cover-map img { width: 100%; height: 100%; object-fit: contain; background: rgba(0,0,0,0.3); }
    .cover-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-top: 30px; }
    .cover-stat { background: rgba(255,255,255,0.08); border-radius: 16px; padding: 25px 20px; text-align: center; border: 1px solid rgba(255,255,255,0.1); }
    .cover-stat .number { font-size: 36px; font-weight: 200; background: linear-gradient(135deg, #fff, #a8dadc); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
    .cover-stat .unit { font-size: 14px; color: rgba(255,255,255,0.5); margin-top: 5px; }
    .cover-date { position: absolute; bottom: 40mm; left: 30mm; right: 30mm; display: flex; justify-content: space-between; align-items: center; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); color: rgba(255,255,255,0.5); font-size: 12px; }
    .content { padding: 20mm 25mm; }
    .section-title { font-size: 22px; font-weight: 300; color: #1a1a2e; margin: 0 0 20px 0; padding-bottom: 10px; border-bottom: 2px solid #e94560; display: inline-block; }
    .summary-table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 20px 0 40px 0; font-size: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); border-radius: 12px; overflow: hidden; }
    .summary-table th { background: linear-gradient(135deg, #1a1a2e, #16213e); color: white; padding: 14px 12px; text-align: left; font-weight: 400; }
    .summary-table td { padding: 12px; border-bottom: 1px solid #f0f0f0; }
    .summary-table tr:last-child td { border-bottom: none; }
    .summary-table tr:nth-child(even) { background: #f8f9fa; }
    .summary-table tr:last-child { background: linear-gradient(135deg, #e94560, #ff6b6b); color: white; font-weight: 500; }
    .summary-table .mileage { color: #4d96ff; font-weight: 500; }
    .summary-table tr:last-child .mileage { color: white; }
    .summary-table .cost { color: #e67e22; }
    .summary-table tr:last-child .cost { color: white; }
    .day-page { padding: 20mm 25mm; page-break-after: always; }
    .day-header { display: flex; align-items: flex-start; margin-bottom: 25px; }
    .day-number { width: 60px; height: 60px; background: linear-gradient(135deg, #e94560, #ff6b6b); border-radius: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: white; margin-right: 20px; flex-shrink: 0; }
    .day-number .day { font-size: 20px; font-weight: 500; line-height: 1; }
    .day-number .label { font-size: 9px; opacity: 0.8; }
    .day-info h2 { font-size: 20px; font-weight: 400; margin: 0 0 5px 0; color: #1a1a2e; }
    .day-info .date { font-size: 12px; color: #888; }
    .day-info .route { font-size: 14px; color: #4d96ff; margin-top: 5px; }
    .info-cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; margin: 20px 0; }
    .info-card { background: linear-gradient(135deg, #f8f9fa, #fff); border-radius: 12px; padding: 16px; border: 1px solid #f0f0f0; }
    .info-card-header { display: flex; align-items: center; margin-bottom: 12px; }
    .info-card-icon { width: 32px; height: 32px; background: linear-gradient(135deg, #1a1a2e, #16213e); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-right: 10px; flex-shrink: 0; }
    .info-card-icon svg { width: 16px; height: 16px; fill: white; }
    .info-card-title { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; }
    .info-card-value { font-size: 14px; color: #1a1a2e; font-weight: 500; }
    .info-card-value.small { font-size: 12px; font-weight: 400; color: #666; }
    .three-col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px; margin: 20px 0; }
    .col-card { background: #f8f9fa; border-radius: 12px; padding: 15px; }
    .col-card h4 { font-size: 12px; color: #888; margin: 0 0 10px 0; text-transform: uppercase; letter-spacing: 1px; }
    .stories { margin: 20px 0; }
    .story-item { display: flex; align-items: flex-start; padding: 12px 15px; background: linear-gradient(135deg, #fff8f0, #fff); border-radius: 10px; margin-bottom: 10px; border-left: 3px solid #e67e22; }
    .story-emoji { font-size: 18px; margin-right: 10px; }
    .story-text { font-size: 12px; color: #555; line-height: 1.6; }
    .story-text strong { color: #1a1a2e; }
    .cost-breakdown { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin: 15px 0; }
    .cost-item { display: flex; justify-content: space-between; padding: 8px 12px; background: #f8f9fa; border-radius: 8px; font-size: 12px; }
    .cost-item.total { grid-column: span 2; background: linear-gradient(135deg, #1a1a2e, #16213e); color: white; font-weight: 500; }
    .cost-item.total .cost-value { color: #ff6b6b; }
    .photo-gallery { margin: 20px 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
    .photo-gallery img { width: 100%; aspect-ratio: 4/3; object-fit: cover; border-radius: 8px; border: 1px solid #eee; }
    .photo-gallery-2col { grid-template-columns: repeat(2, 1fr); }
    .photo-gallery-single { grid-template-columns: 1fr; }
    .photo-gallery img:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
    .photo-tip { font-size: 11px; color: #888; padding: 10px 15px; background: #f8f9fa; border-radius: 8px; margin-top: 15px; }
    .photo-tip::before { content: '📷 '; }
"""

SVG_ICON_1 = '<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>'
SVG_ICON_2 = '<svg viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>'

WEEKDAY = ['星期一','星期二','星期三','星期四','星期五','星期六','星期日']
SUFFIXES = '休整|休息|玩|自由活动|逛|环湖|环游|游览'


def parse_md(content):
    """解析路书MD，返回汇总数据和每天详情"""
    summary = []
    days = {}

    # ─── 汇总表解析 ───
    # 格式A（有里程）: | Day 1 (05/02) | 成都→西昌 | 356km | ¥718 |
    # 格式B（休整日）: | Day 3 (05/04) | 昆明休整 | — | ¥530 |
    for m in re.finditer(r'\|\s*Day\s*(\d+)\s*\(([^)]+)\)\s*\|\s*(.+?)\s*\|\s*([\d,]+)\s*km\s*\|\s*(¥[\d,.]+)\s*\|', content):
        summary.append({
            'day': int(m.group(1)), 'date': m.group(2).strip(),
            'route': m.group(3).strip(), 'km': m.group(4) + "km", 'cost': m.group(5)
        })
    for m in re.finditer(r'\|\s*Day\s*(\d+)\s*\(([^)]+)\)\s*\|\s*(.+?)\s*\|\s*—\s*\|\s*(¥[\d,.]+)\s*\|', content):
        summary.append({
            'day': int(m.group(1)), 'date': m.group(2).strip(),
            'route': m.group(3).strip(), 'km': '—', 'cost': m.group(4)
        })
    summary.sort(key=lambda x: x['day'])

    # 尝试双换行分隔符（云贵路书格式），fallback单换行（西藏路书格式）
    day_blocks = re.split(r'\n---\n\n(?=## 📅 Day)', content)
    if len(day_blocks) == 1:
        day_blocks = re.split(r'\n---\n(?=## 📅 Day)', content)

    for blk in day_blocks:
        dm = re.search(r"## 📅 Day (\d+) — (.+?) \|(.+?)(?:\n|$)", blk)
        if not dm:
            continue
        day = int(dm.group(1))
        date = dm.group(2).strip()
        route_part = dm.group(3).strip()

        parts = [p.strip() for p in route_part.split('→')]
        if len(parts) >= 2:
            start, end = parts[0], parts[1]
        else:
            end_tag = re.search(rf'^(.+?)({SUFFIXES})$', parts[0] if parts else '')
            city = end_tag.group(1).strip() if end_tag else (parts[0].strip() if parts else '')
            start = end = city

        block = re.search(r'(?:\n)(.*)', blk, re.DOTALL)
        block = block.group(1) if block else ''

        km_m = re.search(r'当日总里程.*?\*\*(\d+)\s+km\*\*', block)
        dep_m = re.search(r'出发时间.*?\|\s*(\d+:\d+)', block)
        arr_m = re.search(r'到达时间.*?\|\s*(\d+:\d+)', block)
        elev_s = re.search(r'海拔（起点）.*?\|\s*约?(\d+)\s*m', block)
        elev_e = re.search(r'海拔（终点）.*?\|\s*约?(.+?)\s*m', block)
        dur_m = re.search(r'行程耗时.*?\|\s*约?(.+?)\s*\|', block)
        cost_m = re.search(r'\*\*合计\*\*.*?¥([\d,]+(?:\.\d+)?)', block)
        hotel_m = re.search(r'入住宾馆.*?\*\*([^*]+)\*\*', block)
        odo_m = re.search(r'行驶里程.*?\|\s*([\d,]+)\s*km\s*→\s*([\d,]+)\s*km', block)

        # 提取景点
        spots = re.findall(r'([^\s（(]+(?:\([^)]+\))?)\s*（海拔(\d+)m）', block)

        # 提取故事
        story_items = re.findall(r'\d+\.\s*([^\n]+)', block)
        stories = []
        for s in story_items[:6]:
            s = s.strip()
            if any(e in s for e in '🚗🚑😴🌊🏯🏔️💧🚌🚙🏨'):
                emoji_char = s[0]
                text = re.sub(r'^[^\s]+ ', '', s).strip()
                text = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', text)
                stories.append({'emoji': emoji_char, 'text': text})

        photos = day_photos.get(day, [])

        days[day] = {
            'day': day, 'date': date, 'start': start, 'end': end,
            'dist_km': int(km_m.group(1)) if km_m else 0,
            'time': f"{dep_m.group(1)} - {arr_m.group(1)}" if (dep_m and arr_m) else "",
            'elev': f"{elev_s.group(1)}m → {elev_e.group(1)}m" if (elev_s and elev_e) else "",
            'duration': dur_m.group(1).strip() if dur_m else "",
            'cost': f"¥{cost_m.group(1)}" if cost_m else "",
            'hotel': hotel_m.group(1).strip() if hotel_m else "",
            'odo': f"{odo_m.group(1)} → {odo_m.group(2)} km" if odo_m else "",
            'spots': spots,
            'stories': stories, 'photos': photos
        }

    return summary, days


def make_cover(days):
    total_km = sum(d['dist_km'] for d in days.values())
    max_elev = 5276
    d1 = days.get(1, {})
    d_last = days.get(max(days.keys()), {})
    date_range = ""
    if d1 and d_last:
        d1_date = d1['date']
        dlast_date = d_last['date']
        if '/' in d1_date:
            date_range = f"{d1_date.split('/')[-1]} - {dlast_date.split('/')[-1]}"
        else:
            date_range = f"{d1_date[:5]} - {dlast_date[:5]}"

    return f'''<div class="cover">
    <div class="cover-badge">2026 SPRING</div>
    <h1>成都自驾<br>西藏路书</h1>
    <div class="cover-subtitle">CHENGDU TO TIBET</div>
    <div class="cover-map"><img src="file://{COVER_MAP}" alt="路线图"></div>
    <div class="cover-stats">
        <div class="cover-stat"><div class="number">{len(days)}</div><div class="unit">天行程</div></div>
        <div class="cover-stat"><div class="number">{total_km:,}</div><div class="unit">公里</div></div>
        <div class="cover-stat"><div class="number">{max_elev:,}</div><div class="unit">米最高海拔</div></div>
    </div>
    <div class="cover-date"><span>{date_range}</span><span>成都 → 西藏</span></div>
</div>'''


def make_summary_table(summary):
    rows = ""
    for s in summary:
        date_short = re.sub(r'.*?(\d+/\d+)', r'\1', s['date'])
        rows += f"<tr><td>Day {s['day']}</td><td>{date_short}</td><td>{s['route']}</td><td class=\"mileage\">{s['km']}</td><td class=\"cost\">{s['cost']}</td></tr>\n"
    total_km = sum(int(re.search(r'(\d+)', s['km']).group(1)) for s in summary)
    total_cost_raw = sum(float(re.search(r'¥([\d,.]+)', s['cost']).group(1).replace(',','')) for s in summary)
    total_cost = f"¥{total_cost_raw:,.2f}"
    rows += f"<tr><td colspan=\"3\">累计</td><td class=\"mileage\">{total_km:,}km</td><td class=\"cost\">{total_cost}</td></tr>"
    return f'''<div class="content">
    <h2 class="section-title">📅 行程总览</h2>
    <table class="summary-table"><tr><th>天数</th><th>日期</th><th>路线</th><th>里程</th><th>消费</th></tr>{rows}</table>
</div>
<div class="page-break"></div>'''


def make_day_page(d):
    # 计算星期几
    date_for_weekday = d['date'].replace('年', '/').replace('月', '/').replace('日', '')
    weekday_idx = sum(map(ord, date_for_weekday[:5])) % 7
    weekday_str = WEEKDAY[weekday_idx]

    # 照片画廊class
    n = len(d['photos'])
    if n == 1:
        gallery_cls = "photo-gallery photo-gallery-single"
    elif n == 2:
        gallery_cls = "photo-gallery photo-gallery-2col"
    else:
        gallery_cls = "photo-gallery"

    # 照片HTML
    if d['photos']:
        photos_html = "".join(f'<img src="{PHOTOS_DIR}{p}" alt="{p}">' for p in d['photos'])
    else:
        photos_html = '<div class="photo-tip">暂无照片</div>'

    # 景点
    spots = d.get('spots', [])
    via_items = []
    for s in spots[:8]:
        via_items.append(f"<div style=\"padding:3px 0;border-bottom:1px dashed #eee\">{s[0]} <span style=\"color:#4d96ff;font-size:11px\">({s[1]}m)</span></div>")
    via_html = "".join(via_items) if via_items else "—"

    # 故事
    stories_html = ""
    for st in d['stories']:
        stories_html += f'<div class="story-item"><span class="story-emoji">{st["emoji"]}</span><div class="story-text">{st["text"]}</div></div>'

    route_str = f"{d['start']} → {d['end']}"

    # 信息卡片数据
    card1_title = "出发 / 到达"
    card1_val = d['time'] if d['time'] else "—"
    card1_small = d['elev'] if d['elev'] else ""

    card2_title = "行程数据"
    card2_val = d['duration'] if d['duration'] else f"{d['dist_km']} km"
    card2_small = d['odo'] if d['odo'] else ""

    return f'''<!-- Day {d['day']} -->
<div class="day-page">
    <div class="day-header">
        <div class="day-number"><span class="day">{d['day']:02d}</span><span class="label">DAY</span></div>
        <div class="day-info">
            <h2>{d['start']} → {d['end']}</h2>
            <div class="date">{d['date']} · {weekday_str}</div>
            <div class="route">{route_str}</div>
        </div>
    </div>
    <div class="info-cards">
        <div class="info-card">
            <div class="info-card-header">
                <div class="info-card-icon">{SVG_ICON_1}</div>
                <div>
                    <div class="info-card-title">{card1_title}</div>
                    <div class="info-card-value">{card1_val}</div>
                </div>
            </div>
            {f'<div class="info-card-value small">{card1_small}</div>' if card1_small else ''}
        </div>
        <div class="info-card">
            <div class="info-card-header">
                <div class="info-card-icon">{SVG_ICON_2}</div>
                <div>
                    <div class="info-card-title">{card2_title}</div>
                    <div class="info-card-value">{card2_val}</div>
                </div>
            </div>
            {f'<div class="info-card-value small">{card2_small}</div>' if card2_small else ''}
        </div>
    </div>
    <div class="three-col">
        <div class="col-card"><h4>🏔️ 途经景点</h4><div>{via_html}</div></div>
        <div class="col-card"><h4>🏨 住宿</h4><div>{d['hotel'] or '—'}</div></div>
        <div class="col-card"><h4>💰 消费</h4><div>{d['cost']}</div></div>
    </div>
    {stories_html}
    <div class="{gallery_cls}">{photos_html}</div>
</div>'''


def build_html(content):
    summary, days = parse_md(content)
    sorted_days = [days[d] for d in sorted(days.keys())]
    cover = make_cover(days)
    summary_html = make_summary_table(summary)
    day_pages = "\n\n".join(make_day_page(d) for d in sorted_days)
    return f"<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"UTF-8\">\n<style>{CSS}</style>\n</head>\n<body>\n{cover}\n{summary_html}\n{day_pages}\n</body>\n</html>"


def main():
    # Step 1: 生成OSRM路线图
    print("🗺️ 正在生成路线图...")
    PLACE_COORDS = {
        '成都': (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), '珠峰大本营': (86.93, 28.53), '那曲': (91.00, 31.47),
        '羊卓雍错': (90.35, 29.13),
    }
    VIA_POINTS = {
        5: ['拉萨', '羊卓雍错', '日喀则'],
        7: ['珠峰', '日喀则', '拉萨'],
    }

    with open(ROADBOOK_PATH, encoding="utf-8") as f:
        content = f.read()

    mileage_pat = re.compile(r"当日总里程.*?\*\*(\d+) km\*\*")
    mileage = {i+1: int(m) for i, m in enumerate(mileage_pat.findall(content))}

    day_blocks = re.split(r'\n---\n\n(?=## 📅 Day)', content)
    if len(day_blocks) == 1:
        day_blocks = re.split(r'\n---\n(?=## 📅 Day)', content)
    print(f"  Split into {len(day_blocks)} day blocks")

    all_routes = []
    for blk in day_blocks:
        m_day = re.search(r"## 📅 Day (\d+) — .+?\|(.+?)(?:\n|$)", blk)
        if not m_day:
            continue
        day = int(m_day.group(1))
        route_part = m_day.group(2).strip()
        parts = [p.strip() for p in route_part.split('→')]
        if len(parts) >= 2:
            start_raw, end_raw = parts[0], parts[1]
        else:
            m2 = re.search(rf'^(.+?)({SUFFIXES})$', parts[0])
            start_raw = end_raw = m2.group(1).strip() if m2 else parts[0]

        def normalize_city(name):
            name = re.sub(r'[（(].*?[)）]', '', name).strip()
            parts2 = re.split(r'(自治区|省|市|县|区)', name)
            suffixes = {'自治区', '省', '市', '县', '区'}
            meaningful = [p.strip() for p in parts2 if p.strip() and p not in suffixes]
            return meaningful[-1] if meaningful else name

        start = normalize_city(start_raw)
        end = normalize_city(end_raw)
        if day == 9:
            continue

        def get_osrm_route(lon1, lat1, lon2, lat2):
            url = f"https://router.project-osrm.org/route/v1/driving/{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

        def get_osrm_route_via(coords_list):
            waypoints = ";".join(f"{lon},{lat}" for lon, lat in coords_list)
            url = f"https://router.project-osrm.org/route/v1/driving/{waypoints}?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

        if '→' in end_raw and '拉萨' in end_raw:
            via = normalize_city(end_raw.split('→')[0].strip())
            c1 = get_osrm_route(*PLACE_COORDS[start], *PLACE_COORDS[via])
            time.sleep(0.6)
            c2 = get_osrm_route(*PLACE_COORDS[via], *PLACE_COORDS[start])
            time.sleep(0.6)
            if c1 and c2:
                all_routes.append({'day': day, 'label': '去程', 'start': start, 'end': via, 'coords': [[c[1], c[0]] for c in c1], 'dist_km': mileage.get(day, 0)//2})
                all_routes.append({'day': day, 'label': '返程', 'start': via, 'end': start, 'coords': [[c[1], c[0]] for c in c2], 'dist_km': mileage.get(day, 0)//2})
        elif start in PLACE_COORDS and end in PLACE_COORDS:
            if day in VIA_POINTS:
                places = VIA_POINTS[day]
                coords_list = [PLACE_COORDS[p] for p in places if p in PLACE_COORDS]
                coords = get_osrm_route_via(coords_list)
                time.sleep(0.6)
                if coords:
                    all_routes.append({'day': day, 'label': f"Day{day}", 'start': places[0], 'end': places[-1], 'coords': [[c[1], c[0]] for c in coords], 'dist_km': mileage.get(day, 0), 'via': places[1:-1]})
            else:
                coords = get_osrm_route(*PLACE_COORDS[start], *PLACE_COORDS[end])
                time.sleep(0.6)
                if coords:
                    all_routes.append({'day': day, 'label': f"Day{day}", 'start': start, 'end': end, 'coords': [[c[1], c[0]] for c in coords], 'dist_km': mileage.get(day, 0)})

    with open(ROUTE_CACHE, 'w') as f:
        json.dump(all_routes, f)
    print(f"  ✅ 路线轨迹已缓存（{len(all_routes)}段）")

    # Step 2: 截图封面地图
    MAP_HTML = "/mnt/c/Users/zhou/Desktop/成都自驾西藏_OSRM路线图.html"
    print(f"📸 正在截图封面地图...")
    subprocess.run([
        "google-chrome", "--headless", "--disable-gpu", "--no-sandbox",
        "--window-size=1400,800",
        "--screenshot=" + COVER_MAP, MAP_HTML
    ], capture_output=True)
    print(f"  ✅ 封面地图已生成: {COVER_MAP}")

    # Step 3: 生成HTML路书
    html = build_html(content)
    with open(OUTPUT_HTML, "w", encoding="utf-8") as f:
        f.write(html)
    print(f"✅ HTML已生成: {OUTPUT_HTML} ({os.path.getsize(OUTPUT_HTML):,} bytes)")


if __name__ == "__main__":
    main()


path: scripts/lookup_elevation.py
content: #!/usr/bin/env python3
"""查询路书中各地点的海拔（自动填充到MD文件中）"""
import re, json, urllib.request, time

ELEV_CACHE = {}

def normalize_name(name):
    """标准化地名：去掉括号/省份/市县等前缀，只保留核心名称"""
    name = re.sub(r'[（(].*?[)）]', '', name).strip()
    parts = re.split(r'(自治区|省|市|县|镇|乡)', name)
    suffixes = {'自治区', '省', '市', '县', '镇', '乡'}
    meaningful = [p.strip() for p in parts if p.strip() and p not in suffixes]
    return meaningful[-1] if meaningful else name

def geocode(name):
    """用地名获取经纬度（OpenStreetMap Nominatim），返回 (lon, lat) 或 None"""
    url = f"https://nominatim.openstreetmap.org/search?q={name}&format=json&limit=1"
    req = urllib.request.Request(url, headers={'User-Agent': 'RoadbookBot/1.0'})
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read().decode())
            if data:
                return float(data[0]['lon']), float(data[0]['lat'])
    except Exception:
        pass
    return None

def get_elevation(lat, lon):
    """用经纬度查海拔（Open-Elevation API）"""
    url = f"https://api.open-elevation.com/api/v1/lookup?locations={lat},{lon}"
    req = urllib.request.Request(url, headers={'User-Agent': 'RoadbookBot/1.0'})
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read().decode())
            if data.get('results'):
                return data['results'][0]['elevation']
    except Exception:
        pass
    return None

def lookup_elevation(place_name):
    """查询任意地点的海拔，返回整数米或 None"""
    key = normalize_name(place_name)
    if key in ELEV_CACHE:
        return ELEV_CACHE[key]
    coords = geocode(key)
    if not coords:
        coords = geocode(place_name)
    if not coords:
        ELEV_CACHE[key] = None
        return None
    elev = get_elevation(coords[1], coords[0])
    ELEV_CACHE[key] = elev
    return elev

def fill_elevations_in_md(roadbook_path, output_path=None):
    """
    读取路书MD，自动查询每天的起点/终点海拔，
    将「海拔（起点/终点）」为空或「约XXXm」的地方替换为精确值。
    输出到 output_path（默认覆盖原文件）。
    """
    with open(roadbook_path, encoding='utf-8') as f:
        content = f.read()

    SUFFIXES = '休整|休息|玩|自由活动|逛|环湖|环游|游览'

    # 收集需要查询的地点（去重）
    to_query = {}  # name -> day_label
    for blk in re.finditer(r"## 📅 Day (\d+) — .+?\|(.+?)(?:\n|$)(.*?)(?=## 📅 Day |\Z)", content, re.DOTALL):
        day = blk.group(1)
        route_part = blk.group(2).strip()
        parts = [p.strip() for p in route_part.split('→')]
        if len(parts) >= 2:
            start, end = parts[0], parts[1]
        else:
            end_tag = re.search(r'^(.+?)(休整|休息|玩|自由活动|逛)$', parts[0] if parts else '')
            city = end_tag.group(1).strip() if end_tag else (parts[0].strip() if parts else '')
            start = end = city
        start = normalize_name(start)
        end = normalize_name(end)

        elev_s_m = re.search(r'海拔（起点）.+\|\s*(.*)', blk.group(0))
        elev_e_m = re.search(r'海拔（终点）.+\|\s*(.*)', blk.group(0))

        def needs_query(val):
            val = val.strip()
            if not val or val in ('约m', '约 m', 'm'):
                return True
            if re.match(r'^约\d+\s*m?$', val):
                return True
            return False

        if elev_s_m and needs_query(elev_s_m.group(1)):
            if start not in to_query:
                to_query[start] = f"Day{day}起点"
        if elev_e_m and needs_query(elev_e_m.group(1)):
            if end not in to_query:
                to_query[end] = f"Day{day}终点"

    if not to_query:
        print("所有海拔已填写，无需查询")
        return

    print(f"需要查询 {len(to_query)} 个地点的海拔：{list(to_query.keys())}")
    elev_results = {}
    for place in to_query:
        elev = lookup_elevation(place)
        elev_results[place] = elev
        if elev is not None:
            print(f"  ✅ {place}: {elev}m")
        else:
            print(f"  ❌ {place}: 未找到（请手动填写）")
        time.sleep(1.1)  # Nominatim 限制1次/秒

    new_content = content
    for blk in re.finditer(r"## 📅 Day (\d+) — .+?\|(.+?)(?:\n|$)(.*?)(?=## 📅 Day |\Z)", content, re.DOTALL):
        route_part = blk.group(2).strip()
        parts = [p.strip() for p in route_part.split('→')]
        if len(parts) >= 2:
            start, end = parts[0], parts[1]
        else:
            end_tag = re.search(rf'^(.+?)({SUFFIXES})$', parts[0] if parts else '')
            city = end_tag.group(1).strip() if end_tag else (parts[0].strip() if parts else '')
            start = end = city
        start = normalize_name(start)
        end = normalize_name(end)

        if start in elev_results and elev_results[start] is not None:
            pattern = rf'(海拔（起点）.+\|\s*)约?\d+\s*m'
            new_content = re.sub(pattern, rf'\g<1>{elev_results[start]} m', new_content, count=1)
        if end in elev_results and elev_results[end] is not None:
            pattern = rf'(海拔（终点）.+\|\s*)约?[^ \n]*m'
            new_content = re.sub(pattern, rf'\g<1>{elev_results[end]} m', new_content, count=1)

    out_path = output_path or roadbook_path
    with open(out_path, 'w', encoding='utf-8') as f:
        f.write(new_content)
    print(f"✅ 海拔已更新: {out_path}")

# 使用方式：
# from lookup_elevation import lookup_elevation, fill_elevations_in_md
# fill_elevations_in_md("/mnt/c/Users/zhou/Desktop/成都自驾西藏路书.md")

if __name__ == "__main__":
    import sys
    if len(sys.argv) < 2:
        print("用法: python lookup_elevation.py <路书MD路径>")
        sys.exit(1)
    fill_elevations_in_md(sys.argv[1])


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)