Skill: huangfeng1995/citywalk-map (version 2.0.7)

# Citywalk Map Skill | 城市步行路线图生成器 > 无需API！零成本生成真实 OSM 地图 + OSRM 步行路线的精美 Citywalk 路线图，一行命令搞定自定义主题色、全屏布局、详细步行数据 --- ## 为什么选择我们？| Why Us? | 对比项 | 其他方案（API/MCP）| 我们的方案 | |--------|-------------------|-----------| | 成本 | 需申请 API Key、付费、限流 | **零成本**，curl + OSM 公开接口 | | 部署 | 需服务器中转、签权 | **直接本地生成**，文件输出 | | 地图 | 商业地图 API（有水印/限制）| **OSM 真实地图**，无水印 | | 截图 | 需额外截图服务 | **OpenClaw browser** 一键截图 | | 定制 | API 返回固定样式 | **HTML/CSS 完全可控** | | 上手 | 写代码调用 API | **一行命令就搞定** | --- ## 功能 | Features - 🌏 **零成本**：无 API Key，无付费，无限流 - 🚶 **真实路线**：OSRM 步行导航，不穿墙不穿海 - 🖥️ **全屏适配**：横向全屏布局，电脑手机都完美 - 🎨 **主题色**：一行命令切换任意主题色（国家/城市/品牌色） - 📊 **详细数据**：总距离、步行时长、游览时长、站点列表 - ⚡ **零依赖**：Python 内置库 + curl，无需 pip install --- ## 使用 | Usage ```bash python3 skills/citywalk-map/scripts/generate.py "路线标题" "lat1,lon1,name1,desc1|lat2,lon2,name2,desc2|..." ``` ### 示例 | Examples **巴黎 | Paris** ```bash COLOR=#0055A4 python3 skills/citywalk-map/scripts/generate.py \ "巴黎卢浮宫Citywalk" \ "48.8606,2.3376,卢浮宫|48.8641,2.3276,杜乐丽花园|48.8656,2.3211,协和广场|48.8637,2.3130,亚历山大三世桥|48.8738,2.2950,凯旋门|48.8595,2.3122,荣军院|48.8584,2.2945,埃菲尔铁塔" ``` **海口 | Haikou** ```bash python3 skills/citywalk-map/scripts/generate.py \ "海口万绿园 → 观海台" \ "20.0352,110.3104,万绿园|20.0315,110.2468,观海台" ``` ### 主题色 | Theme Colors | 国家 | 颜色 | |------|-------| | 🇨🇳 中国 | `#DE2910` | | 🇫🇷 法国 | `#0055A4` | | 🇬🇧 英国 | `#012169` | | 🇯🇵 日本 | `#BC002D` | | 🇺🇸 美国 | `#3C3B6E` | | 默认 | `#e94560` | ### 截图流程 | Screenshot ```bash python3 -m http.server 18767 --directory /tmp & # 等待 20 秒 → browser screenshot → 飞书发送 pkill -f "http.server 18767" ``` --- ## 坐标获取 | Coordinates 打开 [Nominatim](https://nominatim.openstreetmap.org/ui/search.html?q=地点名)，点击 **"detail"** 获取精确坐标。 --- ## 输出 | Output - 横向全屏 HTML 地图卡片 - OSM 真实地图底图 + OSRM 步行路线 - 主题色虚线路径 + 景点标记 - 统计区：总距离、步行时长、游览时长、站点数 - 途经景点列表 + 实用贴士 --- ## 文件 | Files ``` skills/citywalk-map/ ├── SKILL.md # 本文档 ├── scripts/generate.py # 地图生成（输出 /tmp/citywalk_map.html） ├── README.md # 中文说明 ├── README.en.md # English ├── package.json └── LICENSE # MIT ``` ## 开源许可 | License MIT License

path: README.en.md
content: # Citywalk Map Generator 🗺️

> Generate beautiful walking route maps with a single command, based on real OSM maps with horizontal full-width design.

---

## ✨ Features

- 🌏 **Real Maps** - OpenStreetMap data, routes never go through buildings
- 🚶 **Real Routes** - OSRM walking navigation, actual road geometry
- 🖥️ **Horizontal Full-Width** - Perfect on both desktop and mobile
- 🎨 **Custom Themes** - Change accent color with one env var (supports country colors)
- ⚡ **Zero Dependencies** - Pure Python + HTML + curl, no build tools

---

## 🚀 Quick Start

```bash
# Basic usage
python3 scripts/generate.py "My Route" "lat1,lon1,Start,desc|lat2,lon2,End,desc"

# Custom theme color
COLOR=#0055A4 python3 scripts/generate.py "Paris Walk" "..."

# View result
open /tmp/citywalk_map.html
```

---

## 📖 Usage

### Input Format

```
lat,lon,name,description
```

- `lat,lon` - Required, coordinates
- `name` - Required, display name
- `description` - Optional, shown in popup

Separate multiple waypoints with `|`.

### Getting Coordinates

Use [Nominatim](https://nominatim.openstreetmap.org/ui/search.html) to search for places, click "details" for exact coordinates.

### Common Theme Colors

| Country | Color Code | Description |
|--------|-----------|-------------|
| China | `#DE2910` | Chinese Red |
| France | `#0055A4` | French Blue |
| UK | `#012169` | British Blue |
| Japan | `#BC002D` | Japanese Red |
| USA | `#3C3B6E` | American Blue |
| Default | `#e94560` | Vibrant Pink |

### Screenshot Flow

```bash
# 1. Start HTTP server
python3 -m http.server 18767 --directory /tmp &

# 2. Open http://localhost:18767/citywalk_map.html in browser
# 3. Wait 20 seconds for OSM tiles to load
# 4. Screenshot
# 5. Stop server
pkill -f "http.server 18767"
```

---

## 🌍 Paris Example

```bash
COLOR=#0055A4 python3 scripts/generate.py \
  "Paris Louvre Citywalk" \
  "48.8606,2.3376,Louvre Pyramid|48.8641,2.3276,Tuileries Garden|48.8656,2.3211,Place de la Concorde|48.8637,2.3130,Pont Alexandre III|48.8738,2.2950,Arc de Triomphe|48.8595,2.3122,Les Invalides|48.8584,2.2945,Eiffel Tower"
```

---

## 🔧 Tech Stack

- **Map**: Leaflet.js + OpenStreetMap
- **Routing**: OSRM Walking Routing (router.project-osrm.org)
- **Geocoding**: Nominatim (OpenStreetMap)
- **Generation**: Python 3 (no pip dependencies, uses curl for OSRM)

---

## 📄 License

MIT License


path: README.md
content: # Citywalk Map Generator 🗺️

> 一行命令生成精美的步行路线图，基于真实 OSM 地图底图，横向全屏设计。

[English](README.en.md) | 中文

---

## ✨ 功能特点

- 🌏 **真实地图** - 基于 OpenStreetMap 数据，不穿墙不穿海
- 🚶 **真实路线** - 使用 OSRM 步行导航，获取实际道路几何
- 🇨🇳 **国内适配** - OSM 加载失败自动切换高德地图（国内秒开）
- 🖥️ **横向全屏** - 横向全屏布局，电脑和手机都完美展示
- 🎨 **自定义主题** - 一行命令切换任意主题色（支持国家/城市主题色）
- 🌦️ **天气查询** - 自动查终点城市当前天气（wttr.in 免费，无需 API Key）
- 🌐 **双语界面** - 支持中文 / English 切换
- 🖼️ **离线渲染** - `render.py` 纯 Python 生成路线图图片（无需浏览器）
- ⚡ **零依赖** - generate.py 仅需 Python 标准库 + curl，无需 pip install

---

## 🚀 快速开始

### 安装

```bash
# 克隆或下载后直接运行
python3 scripts/generate.py "我的路线" "lat1,lon1,起点,描述|lat2,lon2,终点,描述"
```

### 生成地图

```bash
# 基本用法
python3 scripts/generate.py "海口一日游" \
  "20.0352,110.3104,万绿园,起点-热带海滨公园|20.0315,110.2468,观海台,终点-百年钟楼"

# 自定义主题色
COLOR=#1a3a5c python3 scripts/generate.py "海边路线" "..."
```

### 查看结果

```bash
# macOS
open /tmp/citywalk_map.html

# Linux
xdg-open /tmp/citywalk_map.html

# 或启动本地服务（截图用）
python3 -m http.server 18767 --directory /tmp
# 访问 http://localhost:18767/citywalk_map.html
```

---

## 📖 使用说明

### 输入格式

```
lat,lon,名称,描述
```

- `lat,lon` - 必填，地点坐标（用逗号分隔）
- `名称` - 必填，地点显示名称
- `描述` - 可选，弹窗中的详细说明

多个地点用 `|` 分隔。

### 获取坐标

推荐使用 [Nominatim](https://nominatim.openstreetmap.org/ui/search.html) 搜索地点，点击结果中的 "details" 获取精确坐标。

### 常用主题色

| 国家/城市 | 颜色代码 | 说明 |
|---------|---------|------|
| 中国 | `#DE2910` | 中国红 |
| 法国 | `#0055A4` | 法国蓝 |
| 英国 | `#012169` | 英国蓝 |
| 日本 | `#BC002D` | 日本红 |
| 美国 | `#3C3B6E` | 美国蓝 |
| 默认 | `#e94560` | 活力粉 |

### 截图流程

```bash
# 1. 启动 HTTP server
python3 -m http.server 18767 --directory /tmp &

# 2. 用浏览器打开 http://localhost:18767/citywalk_map.html
# 3. 等待 20 秒让 OSM tiles 全部加载
# 4. 截图
# 5. 停掉 server
pkill -f "http.server 18767"
```

### 🖼️ 离线渲染（无需浏览器）

直接生成 PNG 图片，使用高德地图底图（国内可访问）：

```bash
# 需要 Pillow: pip install Pillow requests
python3 scripts/render.py \
  --accent "#228B22" \
  --zoom 13 \
  output.png \
  /tmp/citywalk_map.html
```

常用参数：
- `--accent #hex` 主题色
- `--zoom 13` 缩放级别（数字越大越清晰，但 tile 越多）
- `--help` 查看完整帮助

---

## 🗺️ 效果预览

```
┌────────────────────────────────────────────────────────────┐
│ 🚶 CITYWALK  │         路线标题                    │      │
│              ├────────────────────────────────────────────┤
│  9.4km      │                                            │
│  步行距离    │                                            │
│  5h23m      │           [OSM 地图]                        │
│  游览时长    │           显示完整路线                      │
│  7站        │           + 景点标记                        │
│  途经景点    │                                            │
│  1h53m      │                                            │
│  纯步行      ├────────────────────────────────────────────┤
│              │  途经景点              │  实用贴士         │
│              │  ① 起点               │  • 建议1          │
│              │  ② 途经点             │  • 建议2          │
│              │  ③ 终点               │  • 建议3          │
└────────────────────────────────────────────────────────────┘
```

---

## 🌍 巴黎示例（含天气查询）

```bash
python3 scripts/generate.py \
  --color "#0055A4" \
  --weather \
  "巴黎卢浮宫Citywalk" \
  "48.8606,2.3376,卢浮宫玻璃金字塔,世界最大博物馆之一|48.8641,2.3276,杜乐丽花园,法式皇家园林|48.8656,2.3211,协和广场,法国大革命时期著名广场|48.8637,2.3130,亚历山大三世桥,塞纳河上最华丽金桥|48.8738,2.2950,凯旋门,香榭丽舍尽头地标|48.8595,2.3122,荣军院,拿破仑陵墓所在|48.8584,2.2945,埃菲尔铁塔,巴黎最高点，日落最佳观景点"
```

---

## 🔧 技术栈

- **地图**: Leaflet.js + OpenStreetMap
- **路线**: OSRM Walking Routing (router.project-osrm.org)
- **地理编码**: Nominatim (OpenStreetMap)
- **生成**: Python 3 (无第三方依赖，用 curl 调用 OSRM)

---

## 📄 License

MIT License - 自由使用、修改、分发

---

## 🙏 致谢

- [OpenStreetMap](https://www.openstreetmap.org/) - 地图数据
- [Leaflet.js](https://leafletjs.com/) - 地图库
- [OSRM](https://project-osrm.org/) - 路线规划
- [Nominatim](https://nominatim.openstreetmap.org/) - 地理编码


path: package.json
content: {
  "name": "citywalk-map",
  "version": "3.1.0",
  "description": "Zero-cost walking route maps with OSM+OSRM routing, 高德 tile fallback for China, pure Python renderer",
  "description_en": "Zero-cost walking route maps with real OSM tiles + OSRM routing, one-command setup, custom colors, full-width layout, detailed walk stats",
  "keywords": [
    "citywalk",
    "walking-route",
    "map",
    "openstreetmap",
    "leaflet",
    "osrm",
    "travel",
    "itinerary"
  ],
  "license": "MIT",
  "author": "huangfeng1995",
  "files": [
    "README.md",
    "README.en.md",
    "LICENSE",
    "package.json",
    "scripts/generate.py"
  ],
  "scripts": {
    "generate": "python3 scripts/generate.py",
    "demo-haikou": "python3 scripts/generate.py '海口一日游' '20.0352,110.3104,万绿园,起点|20.0315,110.2468,观海台,终点'",
    "demo-paris": "COLOR=#0055A4 python3 scripts/generate.py '巴黎卢浮宫Citywalk' '48.8606,2.3376,卢浮宫|48.8641,2.3276,杜乐丽花园|48.8656,2.3211,协和广场|48.8637,2.3130,亚历山大三世桥|48.8738,2.2950,凯旋门|48.8595,2.3122,荣军院|48.8584,2.2945,埃菲尔铁塔'",
    "demo-london": "COLOR=#012169 python3 scripts/generate.py '伦敦一日游' '51.5055,-0.0754,Tower Bridge|51.5138,-0.0984,St Pauls Cathedral|51.5080,-0.1277,Trafalgar Square|51.5007,-0.1246,Big Ben'"
  },
  "python-requires": ">=3.7",
  "dependencies": {},
  "repository": {
    "type": "git",
    "url": "https://github.com/openclaw/skill-citywalk-map"
  },
  "clawhub": {
    "name": "citywalk-map",
    "description": "无需API！零成本生成真实OSM地图+OSRM步行路线的精美Citywalk路线图，一行命令搞定自定义主题色、全屏布局、详细步行数据",
    "description_en": "Zero-cost walking route maps with real OSM tiles + OSRM routing, one-command setup, custom colors, full-width layout, detailed walk stats",
    "tags": [
      "map",
      "walking",
      "travel",
      "route",
      "osrm",
      "openstreetmap"
    ],
    "platforms": [
      "openclaw"
    ]
  }
}

path: scripts/demo.py
content: #!/usr/bin/env python3
"""
Citywalk Map Demo Script
Run example routes to see the generator in action.
"""
import subprocess
import os
import sys

DEMOS = {
    'haikou': {
        'title': '海口万绿园 → 观海台 海岸漫步',
        'route': (
            '20.0352,110.3104,万绿园（起点）,海南最大热带海滨公园，椰林大道超适合拍照|'
            '20.0340,110.2931,海口湾公园,沿海健身步道，有休闲长椅可以歇脚看海|'
            '20.0315,110.2468,观海台（折返点）,百年钟楼，登顶俯瞰整个海口湾'
        ),
        'color': '#e94560',
    },
    'london': {
        'title': '伦敦经典5小时徒步路线',
        'route': (
            '51.5055,-0.0754,Tower Bridge（起点）,伦敦标志性地标，桥塔可登顶俯瞰|'
            '51.5138,-0.0984,St Pauls Cathedral,圣保罗大教堂，穹顶震撼，登顶看全城|'
            '51.5117,-0.1223,Covent Garden,最文艺广场，街头表演超赞，美食多|'
            '51.5080,-0.1277,Trafalgar Square,特拉法加广场，国家美术馆旁|'
            '51.5014,-0.1419,Buckingham Palace,白金汉宫，11:30换岗仪式|'
            '51.5007,-0.1246,Big Ben & Parliament,大本钟+国会大厦，伦敦最经典'
        ),
        'color': '#1a3a5c',
    },
    'paris': {
        'title': '巴黎经典1日路线',
        'route': (
            '48.8584,2.2945,埃菲尔铁塔（起点）,巴黎地标，登顶俯瞰全城|'
            '48.8606,2.3376,卢浮宫,世界最大博物馆|'
            '48.8530,2.3499,巴黎圣母院,哥特式建筑典范|'
            '48.8867,2.3206,蒙马特高地,艺术街区，俯瞰巴黎全景'
        ),
        'color': '#8b5cf6',
    },
}


def run_demo(name):
    demo = DEMOS[name]
    print(f"\n{'='*50}")
    print(f"Running demo: {name}")
    print(f"Title: {demo['title']}")
    print(f"Color: {demo['color']}")
    print('='*50)
    
    env = os.environ.copy()
    env['COLOR'] = demo['color']
    
    result = subprocess.run(
        ['python3', __file__.replace('/demo.py', '/generate.py'), demo['title'], demo['route']],
        env=env,
        capture_output=False,
    )
    print(f"\n✅ Demo '{name}' complete!")
    print(f"📍 Output: /tmp/citywalk_map.html")
    print(f"🌐 Open with: open /tmp/citywalk_map.html")


def main():
    if len(sys.argv) > 1:
        name = sys.argv[1]
        if name in DEMOS:
            run_demo(name)
        else:
            print(f"Unknown demo: {name}")
            print(f"Available: {', '.join(DEMOS.keys())}")
    else:
        print("Citywalk Map Demo Script")
        print("=" * 40)
        print("Usage: python3 demo.py <demo-name>")
        print()
        print("Available demos:")
        for name, demo in DEMOS.items():
            print(f"  • {name}: {demo['title']}")
        print()
        print("Example:")
        print("  python3 demo.py haikou")


if __name__ == '__main__':
    main()


path: scripts/generate.py
content: #!/usr/bin/env python3
"""
Citywalk Map Generator — 零成本生成真实 OSM 城市步行路线图

Usage:
    python3 generate.py "路线标题" "lat1,lon1,name1,desc1|lat2,lon2,name2,desc2|..."
    COLOR=hex python3 generate.py ...          # 自定义主题色（默认 #e94560）
    python3 generate.py --help                # 查看完整帮助

Options:
    --output PATH     输出 HTML 路径（默认 /tmp/citywalk_map.html）
    --visit-time N    每站停留分钟数（默认 30）
    --no-tips         隐藏实用贴士
    --lang LANG       界面语言：zh / en（默认 zh）
    --weather         查询并显示目的地天气（wttr.in 免费接口）
    --validate        用 Nominatim 验坐标有效性

依赖: 仅 Python 3 标准库（无需 pip install）
"""
import sys
import os
import json
import math
import time
import subprocess
import argparse

# ── Formatting helpers ────────────────────────────────────────────────────────

def fmt_d(m):
    return f"{m / 1000:.1f}km" if m >= 1000 else f"{int(m)}m"

def fmt_t(s):
    h = int(s // 3600)
    m = int((s % 3600) // 60)
    return f"{h}h{m}m" if h > 0 else f"{m}min"

def darken(hex_color):
    """把颜色加深 25%，处理极端值防止溢出"""
    if hex_color.startswith('#'):
        try:
            r = min(255, int(int(hex_color[1:3], 16) * 0.75))
            g = min(255, int(int(hex_color[3:5], 16) * 0.75))
            b = min(255, int(int(hex_color[5:7], 16) * 0.75))
            return f'#{r:02x}{g:02x}{b:02x}'
        except ValueError:
            return '#e94560'
    return '#e94560'

# ── OSRM routing (with retry + backoff) ──────────────────────────────────────

def get_osrm_route(waypoints, retries=2):
    """并发调用 OSRM 步行路由，失败自动退避重试"""
    total_distance = 0
    total_duration = 0
    full_route_coords = []

    for i in range(len(waypoints) - 1):
        p1, p2 = waypoints[i], waypoints[i + 1]
        lon1, lat1 = p1['lon'], p1['lat']
        lon2, lat2 = p2['lon'], p2['lat']
        url = f"https://router.project-osrm.org/route/v1/foot/{lon1},{lat1};{lon2},{lat2}"

        for attempt in range(retries + 1):
            cmd = [
                'curl', '-s', '--max-time', '10',
                url, '-G',
                '--data-urlencode', 'overview=full',
                '--data-urlencode', 'geometries=geojson'
            ]
            try:
                result = subprocess.run(cmd, capture_output=True, text=True, timeout=12)
                data = json.loads(result.stdout)
                if data.get('code') == 'Ok':
                    route = data['routes'][0]
                    total_distance += route['distance']
                    # OSRM duration 是骑行速度，改用步行速度 5 km/h 换算
                    total_duration += route['distance'] / 1.39
                    coords = route['geometry']['coordinates']
                    full_route_coords.extend([[c[1], c[0]] for c in coords])
                    break
                # 429 / 限流，等待后重试
                if data.get('code') in ('Busy', 'Too Many Requests') and attempt < retries:
                    wait = 2 ** attempt
                    print(f"  OSRM 限流，{wait}s 后重试...", file=sys.stderr)
                    time.sleep(wait)
                    continue
                raise ValueError(f"OSRM error: {data.get('code')}")
            except Exception as e:
                if attempt < retries:
                    wait = 2 ** attempt
                    print(f"  OSRM error: {e}，{wait}s 后重试...", file=sys.stderr)
                    time.sleep(wait)
                    continue
                # 最后一次也失败了，用直线 fallback
                print(f"  OSRM 失败，使用直线 fallback: {e}", file=sys.stderr)
                dlat = (lat2 - lat1) * 111000
                dlon = (lon2 - lon1) * 111000 * math.cos(math.radians(lat1))
                dist = math.sqrt(dlat ** 2 + dlon ** 2)
                total_distance += dist * 1.3
                total_duration += dist * 1.3 / 1.1
                full_route_coords.append([lat1, lon1])
                full_route_coords.append([lat2, lon2])

    return total_distance, total_duration, full_route_coords

# ── Weather (wttr.in — free, no API key) ──────────────────────────────────────

def get_weather(lat, lon, lang='zh'):
    """用 wttr.in 获取目的地当前天气描述"""
    url = f"https://wttr.in/{lat},{lon}?format=j1&lang={lang}"
    try:
        result = subprocess.run(
            ['curl', '-s', '--max-time', '8', url],
            capture_output=True, text=True, timeout=10
        )
        data = json.loads(result.stdout)
        current = data.get('current_condition', [{}])[0]
        temp = current.get('temp_C', '?')
        desc = current.get('weatherDesc', [{}])[0].get('value', '')
        icon = current.get('weatherCode', '113')
        # wttr.in 图标码映射到 emoji
        code_map = {
            '113': '☀️', '116': '⛅', '119': '☁️', '122': '☁️',
            '143': '🌫️', '176': '🌦️', '179': '🌨️', '182': '🌨️',
            '200': '⛈️', '227': '🌨️', '230': '❄️', '248': '🌫️',
            '260': '🌫️', '263': '🌧️', '266': '🌧️', '281': '🌨️',
        }
        emoji = code_map.get(icon, '🌡️')
        return f"{emoji} {temp}°C {desc}", temp, desc, emoji
    except Exception as e:
        print(f"  天气查询失败: {e}", file=sys.stderr)
        return None, None, None, None

# ── Nominatim coordinate validation ─────────────────────────────────────────

def validate_coords(lat, lon, name):
    """用 Nominatim 验证坐标是否落在有效陆地区域"""
    url = f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json"
    try:
        result = subprocess.run(
            ['curl', '-s', '--max-time', '6', '-H', 'User-Agent: CitywalkMap/2.0',
             url],
            capture_output=True, text=True, timeout=8
        )
        data = json.loads(result.stdout)
        addr = data.get('address', {})
        # 排除纯海域 / 未知区域
        klass = addr.get('landuse', '') or addr.get('place', '')
        if not data.get('display_name'):
            return False, f"未知区域（{name}）"
        return True, None
    except Exception:
        return True, None  # 验证失败不阻塞，允许放行

# ── CLI ───────────────────────────────────────────────────────────────────────

parser = argparse.ArgumentParser(
    description='Citywalk Map Generator — 零成本生成真实 OSM 步行路线图',
    formatter_class=argparse.RawDescriptionHelpFormatter,
    epilog=__doc__
)
parser.add_argument('title', nargs='?', help='路线标题')
parser.add_argument('waypoints', nargs='?', help='站点列表，格式: lat,lon,name,desc|...')
parser.add_argument('--output', '-o', default='/tmp/citywalk_map.html',
                    help='输出 HTML 路径（默认 /tmp/citywalk_map.html）')
parser.add_argument('--visit-time', '-t', type=int, default=30,
                    help='每站停留分钟数（默认 30）')
parser.add_argument('--no-tips', action='store_true',
                    help='隐藏实用贴士栏')
parser.add_argument('--lang', '-l', choices=['zh', 'en'], default='zh',
                    help='界面语言（默认 zh）')
parser.add_argument('--weather', '-w', action='store_true',
                    help='查询并显示目的地天气')
parser.add_argument('--validate', action='store_true',
                    help='用 Nominatim 验证坐标有效性')
parser.add_argument('--color', '-c', dest='color', default=None,
                    help='主题色（覆盖 COLOR 环境变量）')
parser.add_argument('--tile-url', default=None,
                    help='自定义地图 Tile URL，带 {z}/{x}/{y} 占位符（默认 OSM）')
parser.add_argument('--tile-attrib', default='© OpenStreetMap contributors',
                    help='Tile 版权信息（默认 © OpenStreetMap contributors）')

args = parser.parse_args()

# 读取环境变量作为默认值
ACCENT = args.color or os.environ.get('COLOR', '#e94560')

# ── Input parsing ─────────────────────────────────────────────────────────────

if not args.title or not args.waypoints:
    parser.print_help()
    sys.exit(1)

title = args.title
parts = args.waypoints.split('|')
waypoints = []
for i, p in enumerate(parts):
    s = p.split(',')
    if len(s) < 3:
        print(f"⚠️  站点 #{i+1} 格式错误（需 lat,lon,name[,desc]）: {p}", file=sys.stderr)
        sys.exit(1)
    lat, lon = float(s[0].strip()), float(s[1].strip())
    if args.validate:
        ok, err = validate_coords(lat, lon, s[2].strip())
        if not ok:
            print(f"⚠️  {err}", file=sys.stderr)
        else:
            print(f"  ✓ 坐标有效: {s[2].strip()} ({lat},{lon})")
    waypoints.append({
        'num': str(i + 1),
        'lat': lat,
        'lon': lon,
        'name': s[2].strip(),
        'desc': s[3].strip() if len(s) > 3 else ''
    })

clat = sum(w['lat'] for w in waypoints) / len(waypoints)
clon = sum(w['lon'] for w in waypoints) / len(waypoints)

# ── Weather ───────────────────────────────────────────────────────────────────

weather_info = None
if args.weather:
    dest = waypoints[-1]  # 用终点城市查天气
    print(f"正在查询天气: {dest['name']} ({dest['lat']},{dest['lon']})...")
    desc, temp, desc_str, emoji = get_weather(dest['lat'], dest['lon'], args.lang)
    if desc:
        weather_info = desc
        print(f"  天气: {desc}")

# ── Routing ───────────────────────────────────────────────────────────────────

print(f"正在查询 OSRM 步行路由 ({len(waypoints)} 个站点)...")
td, walk_sec, route_coords = get_osrm_route(waypoints)

visit_sec = len(waypoints) * args.visit_time * 60
total_sec = walk_sec + visit_sec

# ── Build HTML ────────────────────────────────────────────────────────────────

route_html_lines = []
for i, w in enumerate(waypoints):
    cls = 's1' if i == 0 else ('sN' if i == len(waypoints) - 1 else 'sx')
    route_html_lines.append(f'          <li><span class="route-num {cls}">{w["num"]}</span>{w["name"]}</li>')
route_html = '\n'.join(route_html_lines)

TIPS = {
    'zh': ['建议提前出发，合理安排时间', '穿着舒适的步行鞋', '携带水瓶和防晒用品', '注意安全，遵守当地规定'],
    'en': ['Start early and plan your time well', 'Wear comfortable walking shoes',
           'Bring water and sun protection', 'Stay safe and follow local rules'],
}
tips_list = [] if args.no_tips else TIPS.get(args.lang, TIPS['zh'])
if weather_info:
    tips_list.insert(0, f"📍 终点天气: {weather_info}")
tips_html = '\n'.join(f'          <li>{t}</li>' for t in tips_list)

stats_extra = ''
stats_col = 'repeat(4, 1fr)'

HTML = """<!DOCTYPE html>
<html lang="{lang}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{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>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
  font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
  background: {accent};
  min-height: 100vh;
  padding: 16px 0;
  display: flex;
  align-items: flex-start;
}}
.card {{
  width: 100%;
  max-width: 100%;
  background: white;
  border-radius: 20px;
  overflow: hidden;
  box-shadow: 0 8px 32px rgba(0,0,0,0.15);
}}
.header {{
  background: linear-gradient(135deg, {accent} 0%, {accent_dark} 100%);
  padding: 18px 20px 16px;
  color: white;
}}
.header .tag {{
  display: inline-block;
  background: rgba(255,255,255,0.2);
  border-radius: 10px;
  padding: 4px 12px;
  font-size: 10px;
  letter-spacing: 1px;
  margin-bottom: 6px;
}}
.header h1 {{ font-size: 22px; font-weight: 700; margin-bottom: 4px; }}
.header p {{ font-size: 13px; opacity: 0.85; }}
#map {{ width: 100%; height: 520px; }}
#map .tile-error {{ padding: 40px; text-align: center; color: #888; font-size: 14px; }}
.info {{ padding: 16px 18px; }}
.stats {{
  display: grid;
  grid-template-columns: {stats_col};
  gap: 10px;
  margin-bottom: 14px;
}}
.stat {{
  background: #f5f7fa;
  border-radius: 10px;
  padding: 10px 6px;
  text-align: center;
}}
.stat .num {{ font-size: 20px; font-weight: 700; color: {accent}; }}
.stat .label {{ font-size: 11px; color: #888; margin-top: 3px; }}
.bottom {{ display: flex; gap: 12px; }}
.bottom-left {{ flex: 1.5; background: #f5f7fa; border-radius: 12px; padding: 12px 14px; }}
.bottom-right {{ flex: 1; background: #f5f7fa; border-radius: 12px; padding: 12px 14px; }}
.section-title {{ font-size: 11px; color: #888; letter-spacing: 0.5px; text-transform: uppercase; margin-bottom: 8px; font-weight: 600; }}
.route-list {{ list-style: none; padding: 0; margin: 0; }}
.route-list li {{ display: flex; align-items: center; gap: 10px; padding: 7px 0; font-size: 13px; color: #333; border-bottom: 1px solid #eee; }}
.route-list li:last-child {{ border-bottom: none; }}
.route-num {{ width: 22px; height: 22px; border-radius: 50%; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: white; }}
.route-num.s1 {{ background: {accent}; }}
.route-num.sx {{ background: #ff9f43; }}
.route-num.sN {{ background: #10b981; }}
.tips {{ list-style: none; padding: 0; margin: 0; }}
.tips li {{ font-size: 12px; color: #333; line-height: 1.6; padding: 6px 0; border-bottom: 1px solid #eee; }}
.tips li:last-child {{ border-bottom: none; }}
.popup-title {{ font-size: 14px; font-weight: 700; color: #222; margin-bottom: 4px; }}
.popup-desc {{ font-size: 12px; color: #666; }}
.popup-num {{ display: inline-block; width: 20px; height: 20px; background: {accent}; color: white; border-radius: 50%; text-align: center; line-height: 20px; font-size: 10px; font-weight: 700; margin-right: 6px; }}
</style>
</head>
<body>
<div class="card">
  <div class="header">
    <div class="tag">🚶 CITYWALK</div>
    <h1>{title}</h1>
    <p>{subtitle}</p>
  </div>
  <div id="map"></div>
  <div class="info">
    <div class="stats">
      <div class="stat"><div class="num">{dist}</div><div class="label">{lbl_dist}</div></div>
      <div class="stat"><div class="num">{dur}</div><div class="label">{lbl_dur}</div></div>
      <div class="stat"><div class="num">{n}站</div><div class="label">{lbl_n}</div></div>
      <div class="stat"><div class="num">{walk}</div><div class="label">{lbl_walk}</div></div>
      {stats_extra}
    </div>
    <div class="bottom">
      <div class="bottom-left">
        <div class="section-title">{lbl_route}</div>
        <ul class="route-list">
{route_html}
        </ul>
      </div>
      <div class="bottom-right">
        <div class="section-title">{lbl_tips}</div>
        <ul class="tips">
{tips_html}
        </ul>
      </div>
    </div>
  </div>
</div>
<script>
var waypoints = {waypoints_json};
var routeCoords = {route_json};
var map = L.map('map', {{zoomControl:true, scrollWheelZoom:false}}).setView([{center_lat},{center_lon}], 14);

// 多源瓦片：优先 OSM，5秒内失败则切换高德（国内可访问）
var osmUrl = "https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png";
var gaodeUrl = "https://wprd01.is.autonavi.com/appmaptile?x={{x}}&y={{y}}&z={{z}}&lang=zh_cn&size=1&style=7";
var tileLayer = L.tileLayer(osmUrl, {{maxZoom:19, attribution:"© OpenStreetMap contributors", subdomains:'abc', errorTileUrl:'https://unpkg.com/leaflet@1.9.4/dist/images/tile_404.png'}});
var osmFailed = false;

tileLayer.on('tileerror', function(e) {{
  if (osmFailed) return;
  osmFailed = true;
  console.log('OSM tiles failed, switching to 高德...');
  map.removeLayer(tileLayer);
  var gaodeLayer = L.tileLayer(gaodeUrl, {{maxZoom:18, attribution:"© 高德地图 | © OpenStreetMap contributors"}});
  gaodeLayer.addTo(map);
}});

// 超时回退：OSM 5秒内没成功全部加载则切高德
var loadedCount = 0;
var requiredTiles = 6;
setTimeout(function() {{
  if (loadedCount < requiredTiles && !osmFailed) {{
    osmFailed = true;
    console.log('OSM loading slow, switching to 高德...');
    try {{ map.removeLayer(tileLayer); }} catch(e) {{}}
    var gaodeLayer = L.tileLayer(gaodeUrl, {{maxZoom:18, attribution:"© 高德地图 | © OpenStreetMap contributors"}});
    gaodeLayer.addTo(map);
  }}
}}, 5000);

// OSM 加载计数
map.eachLayer(function(l) {{
  if (l instanceof L.TileLayer) {{
    l.on('load', function() {{ loadedCount++; }});
  }}
}});

tileLayer.addTo(map);
L.polyline(routeCoords, {{color:'{accent}', weight:5, opacity:0.85, dashArray:'10, 5'}}).addTo(map);
var ds = Math.max(1, Math.floor(routeCoords.length/12));
for(var i=0;i<routeCoords.length-1;i+=ds){{
  L.circleMarker(routeCoords[i], {{radius:4, color:'{accent}', fillColor:'{accent}', fillOpacity:0.9}}).addTo(map);
}}
waypoints.forEach(function(wp){{
  var last = waypoints[waypoints.length-1].num;
  var color = wp.num==='1'?'{accent}':(wp.num===last?'#10b981':'#ff9f43');
  L.circleMarker([wp.lat,wp.lon], {{radius:10, color:'white', weight:3, fillColor:color, fillOpacity:1}})
    .addTo(map).bindPopup('<div class="popup-title"><span class="popup-num" style="background:'+color+'">'+wp.num+'</span>'+wp.name+'</div><div class="popup-desc">'+(wp.desc||'')+'</div>');
  var lbl = L.divIcon({{html:'<div style="background:white;padding:3px 8px;border-radius:8px;font-size:11px;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,0.2);white-space:nowrap;border-left:3px solid '+color+';">'+wp.name+'</div>', className:'', iconSize:[0,0], iconAnchor:[0,0]}});
  L.marker([wp.lat,wp.lon], {{icon:lbl}}).addTo(map);
}});
var b = L.latLngBounds(waypoints.map(function(wp){{return[wp.lat,wp.lon];}}));
map.fitBounds(b, {{padding:[10,10]}});
var n=L.control({{position:'bottomleft'}});n.onAdd=function(){{
  var d=L.DomUtil.create('div');
  d.innerHTML='<div style="background:white;padding:6px 8px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.2);text-align:center;font-size:10px;font-weight:700;color:#333;">⬆ N</div>';
  return d;
}};n.addTo(map);
</script>
</body>
</html>
"""

LANG_STRINGS = {
    'zh': dict(lbl_dist='步行距离', lbl_dur='游览时长', lbl_n='途经景点', lbl_walk='纯步行',
               lbl_route='途经景点', lbl_tips='实用贴士'),
    'en': dict(lbl_dist='Distance', lbl_dur='Total Time', lbl_n='Stops', lbl_walk='Walking',
               lbl_route='Route', lbl_tips='Tips'),
}

lang_str = LANG_STRINGS.get(args.lang, LANG_STRINGS['zh'])
total_dur_str = fmt_t(total_sec)
walk_str = fmt_t(walk_sec)
subtitle_parts = [f"{len(waypoints)}{lang_str['lbl_n']}", f"全程{fmt_d(td)}", f"{'约' if args.lang=='zh' else '~'}{total_dur_str}"]
if weather_info:
    subtitle_parts.append(weather_info)
subtitle = ' · '.join(subtitle_parts)

html = HTML.format(
    lang='zh-CN' if args.lang == 'zh' else 'en',
    title=title,
    subtitle=subtitle,
    accent=ACCENT,
    accent_dark=darken(ACCENT),
    waypoints_json=json.dumps(waypoints),
    route_json=json.dumps(route_coords),
    center_lat=clat,
    center_lon=clon,
    dist=fmt_d(td),
    dur=total_dur_str,
    n=len(waypoints),
    walk=walk_str,
    route_html=route_html,
    tips_html=tips_html,
    stats_col=stats_col,
    stats_extra=stats_extra,
    tile_url=args.tile_url or 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
    tile_attrib=args.tile_attrib.replace("'", "\\'"),
    **lang_str,
)

with open(args.output, 'w', encoding='utf-8') as f:
    f.write(html)

ok_str = 'OK' if args.lang == 'en' else '完成'
print(f"{ok_str}: {title} | {ACCENT} | {len(waypoints)} pts | {fmt_d(td)} | {fmt_t(walk_sec)} walk | {total_dur_str} total")
print(f"输出: {args.output}")


path: scripts/render.py
content: #!/usr/bin/env python3
"""
MapTileRenderer — 纯 Python 渲染完整路线图卡片（含地图+统计+景点+贴士）
用法: python3 render.py <input.html> <output.png> [--accent #hex] [--zoom N]
"""
import sys, os, json, math, time, re, io, argparse
import requests
from PIL import Image, ImageDraw, ImageFont

TILE_SIZE = 256
DEFAULT_ZOOM = 13
TARGET_W = 700  # 输出图片宽度

# ── 字体（回退方案）─────────────────────────────────────────────────────────

def make_font(size, bold=False):
    """加载字体，优先用支持中文的"""
    candidates = [
        "/System/Library/Fonts/STHeiti Light.ttc",
        "/System/Library/Fonts/PingFang.ttc",
    ] if not bold else [
        "/System/Library/Fonts/STHeiti Medium.ttc",
        "/System/Library/Fonts/PingFang.ttc",
    ]
    for f in candidates:
        if os.path.exists(f):
            try:
                return ImageFont.truetype(f, size)
            except Exception:
                pass
    return ImageFont.load_default()

# ── 地理计算──────────────────────────────────────────────────────────────────

# WGS84 → GCJ-02（高德/腾讯坐标）
def wgs84_to_gcj02(lat, lon):
    a, ee = 6378245.0, 0.00669342
    def tl(y, x):
        ret = -100.0 + 2.0*x + 3.0*y + 0.2*y*y + 0.1*x*y + 0.2*math.sqrt(abs(x))
        ret += (20.0*math.sin(6.0*x*math.pi) + 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0
        ret += (20.0*math.sin(y*math.pi) + 40.0*math.sin(y/3.0*math.pi)) * 2.0/3.0
        ret += (160.0*math.sin(y/12.0*math.pi) + 320.0*math.sin(y*math.pi/30.0)) * 2.0/3.0
        return ret
    def tL(y, x):
        ret = 300.0 + x + 2.0*y + 0.1*x*x + 0.1*x*y + 0.1*math.sqrt(abs(x))
        ret += (20.0*math.sin(6.0*x*math.pi) + 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0
        ret += (20.0*math.sin(x*math.pi) + 40.0*math.sin(x/3.0*math.pi)) * 2.0/3.0
        ret += (150.0*math.sin(x/12.0*math.pi) + 300.0*math.sin(x/30.0*math.pi)) * 2.0/3.0
        return ret
    dLat = tl(lon - 105.0, lat - 35.0)
    dLon = tL(lon - 105.0, lat - 35.0)
    rlat = lat / 180.0 * math.pi
    magic = 1.0 - ee * (math.sin(rlat) ** 2)
    dLat = (dLat * 180.0) / ((a * (1.0 - ee)) / (magic * math.sqrt(magic)) * math.pi)
    dLon = (dLon * 180.0) / (a / math.sqrt(magic) * math.cos(rlat) * math.pi)
    return lat + dLat, lon + dLon

def lat2tile(lat, lon, zoom):
    n = 2 ** zoom
    x = int((lon + 180.0) / 360.0 * n)
    lat_r = math.radians(lat)
    y_merc = (1.0 - math.log(math.tan(lat_r) + 1.0/math.cos(lat_r)) / math.pi) / 2.0 * n
    return max(0, x), max(0, int(y_merc))

def deg2px(lat, lon, zoom, tx_min, ty_min):
    n = 2 ** zoom
    x_px = ((lon + 180.0) / 360.0 * n - tx_min) * TILE_SIZE
    lat_r = math.radians(lat)
    y_px = ((1.0 - math.log(math.tan(lat_r) + 1/math.cos(lat_r)) / math.pi) / 2.0 * n - ty_min) * TILE_SIZE
    return x_px, y_px

def accent_to_rgb(h):
    if h.startswith('#') and len(h) >= 7:
        return (int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16))
    return (233, 69, 96)

def darken_rgb(rgb, factor=0.72):
    return tuple(min(255, int(c * factor)) for c in rgb)

# ── Tile 下载─────────────────────────────────────────────────────────────────

def get_tile(tx, ty, zoom, retries=3):
    # OSM 法国节点（图块数据质量高、国外覆盖好）
    url = f"https://a.tile.openstreetmap.fr/osmfr/{zoom}/{tx}/{ty}.png"
    for i in range(retries):
        try:
            r = requests.get(url, headers={'User-Agent': 'CitywalkMapRenderer/3.0'}, timeout=15)
            if r.status_code == 200 and len(r.content) > 5000 and r.content[:4] == b'\x89PNG':
                img = Image.open(io.BytesIO(r.content))
                if img.mode == 'P':
                    img = img.convert('RGB')
                return img
            time.sleep(1)
        except Exception:
            if i < retries - 1:
                time.sleep(2 ** i)
    return Image.new('RGB', (TILE_SIZE, TILE_SIZE), (220, 220, 220))

# ── 完整渲染────────────────────────────────────────────────────────────────

def render_full_card(waypoints, route_coords, title, subtitle, dist, dur, walk,
                     tips, accent, output, zoom=DEFAULT_ZOOM):
    """渲染完整路线图卡片（地图+统计+景点+贴士）"""

    # ── 底图 ────────────────────────────────────────────────────────────────
    all_c = route_coords + [[w['lat'], w['lon']] for w in waypoints]
    lats = [c[0] for c in all_c]
    lons = [c[1] for c in all_c]
    pad = 0.008
    tx_lo, ty_hi = lat2tile(max(lats) + pad, min(lons) - pad, zoom)
    tx_hi, ty_lo = lat2tile(min(lats) - pad, max(lons) + pad, zoom)
    tx_min = max(0, tx_lo - 1)
    tx_max = tx_hi + 2
    ty_min = max(0, ty_hi - 1)
    ty_max = ty_lo + 2
    cols = tx_max - tx_min
    rows = ty_max - ty_min
    total = cols * rows
    print(f"  底图: zoom={zoom}, tiles={cols}x{rows} ({total}张)")

    base = Image.new('RGB', (cols * TILE_SIZE, rows * TILE_SIZE))
    for ty_i in range(ty_min, ty_max):
        for tx_i in range(tx_min, tx_max):
            base.paste(get_tile(tx_i, ty_i, zoom),
                       ((tx_i - tx_min) * TILE_SIZE, (ty_i - ty_min) * TILE_SIZE))
        pct = (ty_i - ty_min + 1) * 100 // rows
        print(f"  下载进度 {pct}%...", end='\r')
    print(f"  底图完成 ({total}张)")

    # 画路线
    draw = ImageDraw.Draw(base)
    pts = [deg2px(c[0], c[1], zoom, tx_min, ty_min) for c in route_coords]
    if len(pts) > 1:
        draw.line(pts, fill=accent_to_rgb(accent), width=5)

    # 画站点
    for i, wp in enumerate(waypoints):
        px, py = deg2px(wp['lat'], wp['lon'], zoom, tx_min, ty_min)
        col = (accent_to_rgb(accent) if i == 0
               else (34, 197, 94) if i == len(waypoints) - 1
               else (255, 159, 67))
        r = 12
        draw.ellipse([px - r, py - r, px + r, py + r], fill=col, outline='white', width=3)
        name = wp['name']
        draw.text((px + 16, py - 9), name, fill=(30, 30, 30))

    # 缩放到底图目标尺寸（宽=TARGET_W）
    ratio = TARGET_W / base.width
    map_h = int(base.height * ratio)
    base = base.resize((TARGET_W, map_h), Image.LANCZOS)

    # ── 布局参数 ────────────────────────────────────────────────────────────
    bar_h  = 60    # 标题栏
    stats_h = 72   # 统计栏
    pad_h   = 12   # 间距
    info_h  = 90   # 下方景点+贴士栏高度
    radius  = 16    # 卡片圆角

    total_h = bar_h + map_h + stats_h + pad_h + info_h
    accent_rgb = accent_to_rgb(accent)
    accent_dark = darken_rgb(accent_rgb)
    bg = (245, 247, 250)   # 卡片背景灰
    white = (255, 255, 255)

    # 创建成品图
    card = Image.new('RGB', (TARGET_W, total_h), bg)
    d = ImageDraw.Draw(card)

    # ── 标题栏 ─────────────────────────────────────────────────────────────
    card.paste(base, (0, bar_h))                          # 底图贴在标题栏下方

    # 统计栏背景（白色圆角卡片）
    stats_y = bar_h + map_h
    card_raw = Image.new('RGB', (TARGET_W, stats_h + radius), bg)
    card_raw.paste(white, (0, 0, TARGET_W, stats_h))
    # 简单圆角遮罩（只做顶部）
    for x in range(TARGET_W):
        for dy in range(radius):
            alpha = int(255 * (1 - dy / radius) ** 2)
            if 0 <= x < radius:
                card_raw.putpixel((x, dy), white)
            if TARGET_W - radius <= x < TARGET_W:
                card_raw.putpixel((x, dy), white)
    card.paste(card_raw, (0, stats_y))

    # ── 统计栏数据 ─────────────────────────────────────────────────────────
    stat_items = [
        (dist,  '步行距离'),
        (dur,   '游览时长'),
        (f"{len(waypoints)}站", '途经景点'),
        (walk,  '纯步行'),
    ]
    stat_w = TARGET_W // 4
    fnt_num  = make_font(18, bold=True)
    fnt_lbl  = make_font(10)
    for i, (val, lbl) in enumerate(stat_items):
        x = i * stat_w
        # 数字
        d.text((x + stat_w//2, stats_y + 14), str(val),
               fill=accent_rgb, font=fnt_num, anchor='mm')
        # 标签
        d.text((x + stat_w//2, stats_y + 44), lbl,
               fill=(136, 136, 136), font=fnt_lbl, anchor='mm')

    # ── 下方信息栏 ──────────────────────────────────────────────────────────
    info_y = stats_y + stats_h + pad_h
    info_raw = Image.new('RGB', (TARGET_W, info_h), bg)
    # 左栏：途经景点
    left = Image.new('RGB', (int(TARGET_W * 0.58) - 6, info_h - 12), white)
    ld = ImageDraw.Draw(left)
    ld.text((10, 8), '途经景点', fill=(136, 136, 136), font=make_font(10))
    for i, wp in enumerate(waypoints):
        col = (accent_rgb if i == 0
               else (34, 197, 94) if i == len(waypoints) - 1
               else (255, 159, 67))
        num_str = str(i + 1)
        y = 26 + i * 22
        # 圆形编号
        ld.ellipse([8, y - 8, 8 + 16, y + 8], fill=col)
        ld.text((8 + 8, y), num_str, fill=white, font=make_font(9), anchor='mm')
        # 景点名
        ld.text((32, y), wp['name'], fill=(50, 50, 50), font=make_font(11), anchor='mm')
    info_raw.paste(left, (6, 6))

    # 右栏：实用贴士
    right = Image.new('RGB', (TARGET_W - int(TARGET_W * 0.58) - 12, info_h - 12), white)
    rd = ImageDraw.Draw(right)
    rd.text((10, 8), '实用贴士', fill=(136, 136, 136), font=make_font(10))
    for j, tip in enumerate(tips[:4]):
        rd.text((10, 26 + j * 18), f"• {tip}", fill=(60, 60, 60), font=make_font(10))
    info_raw.paste(right, (int(TARGET_W * 0.58) + 6, 6))

    card.paste(info_raw, (0, info_y))

    # ── 标题栏（覆盖在地图顶部）─────────────────────────────────────────────
    title_raw = Image.new('RGBA', (TARGET_W, bar_h + radius), (0, 0, 0, 0))
    td = ImageDraw.Draw(title_raw)
    # 渐变标题栏
    for y in range(bar_h):
        alpha = int(200 * (1 - y / bar_h))
        col = accent_dark + (alpha,)
        td.line([(0, y), (TARGET_W, y)], fill=accent_dark)
    td.rectangle([0, 0, TARGET_W, bar_h], fill=accent_dark)
    title_img = Image.new('RGB', (TARGET_W, bar_h), accent_dark)
    card.paste(title_img, (0, 0))
    d = ImageDraw.Draw(card)
    d.text((18, 14), f"🚶  {title}", fill=white, font=make_font(17, bold=True))
    d.text((18, 36), subtitle, fill=(230, 230, 230), font=make_font(10))

    card.save(output)
    print(f"  输出: {output}  ({TARGET_W}x{total_h})")
    return output

# ── CLI ─────────────────────────────────────────────────────────────────────

if __name__ == '__main__':
    p = argparse.ArgumentParser()
    p.add_argument('html',   help='输入 HTML 路径')
    p.add_argument('output', help='输出 PNG 路径')
    p.add_argument('--accent', '-a', default=None, help='主题色，如 #228B22')
    p.add_argument('--zoom', '-z', type=int, default=DEFAULT_ZOOM,
                   help=f'缩放级别（默认 {DEFAULT_ZOOM}）')
    args = p.parse_args()

    with open(args.html, 'r', encoding='utf-8') as f:
        html = f.read()

    # 解析 HTML 中的数据
    wp_match  = re.search(r'var waypoints = (\[.*?\]);', html, re.DOTALL)
    rt_match  = re.search(r'var routeCoords = (\[.*?\]);', html, re.DOTALL)
    title_m   = re.search(r'<title>(.*?)</title>', html)
    # stats: 从 .stat .num 抓
    stat_nums = re.findall(r'<div class="num">(.*?)</div>', html)
    stat_lbls = re.findall(r'<div class="label">(.*?)</div>', html)
    # tips — 只取 class="tips" 列表中的项
    tips_section = re.search(r'<ul class="tips">(.*?)</ul>', html, re.DOTALL)
    tips = re.findall(r'<li>(.*?)</li>', tips_section.group(1)) if tips_section else []

    if not wp_match or not rt_match:
        print("错误: 无法解析 HTML 中的 waypoints/routeCoords"); sys.exit(1)

    waypoints = json.loads(wp_match.group(1))
    route     = json.loads(rt_match.group(1))
    title     = title_m.group(1) if title_m else 'Citywalk Map'

    # 副标题：.header 里的 <p>
    sub_m = re.search(r'<div class="header".*?<p>(.*?)</p>', html, re.DOTALL)
    subtitle = sub_m.group(1) if sub_m else ''

    # 主题色
    accent = args.accent
    if not accent:
        m = re.search(r'background:\s*#([0-9a-fA-F]{6})', html)
        accent = '#' + m.group(1) if m else '#e94560'

    # stats（按顺序取前4个）
    stats = list(zip(stat_nums[:4], stat_lbls[:4]))
    dist = stats[0][0] if len(stats) > 0 else '—'
    dur  = stats[1][0] if len(stats) > 1 else '—'
    walk = stats[3][0] if len(stats) > 3 else '—'

    # tips 清理 HTML 标签
    clean_tips = []
    for t in tips:
        t = re.sub(r'<[^>]+>', '', t).strip()
        if t:
            clean_tips.append(t)
    # 如果上面提取失败，用原始 <li> 文本兜底
    if not clean_tips:
        li_items = re.findall(r'<li>(.*?)</li>', html)
        for t in li_items:
            t = re.sub(r'<[^>]+>', '', t).strip()
            if t and len(t) > 3:
                clean_tips.append(t)

    print(f"主题色: {accent} | 标题: {title}")
    print(f"统计: {dist} / {dur} / {len(waypoints)}站 / {walk}")
    print(f"贴士: {clean_tips[:3]}")

    render_full_card(
        waypoints=waypoints,
        route_coords=route,
        title=title,
        subtitle=subtitle,
        dist=dist,
        dur=dur,
        walk=walk,
        tips=clean_tips,
        accent=accent,
        output=args.output,
        zoom=args.zoom,
    )


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