折腾三个月,我把摩旅路线和 AI 搞在一起了

3 阅读1分钟

折腾三个月,我把摩旅路线和 AI 搞在一起了

说实话,作为一个既写代码又骑车的程序员,我一直觉得现有的摩旅 App 差点意思。要么广告满天飞,要么路线质量参差不齐,找个靠谱的 ADV 路线跟大海捞针似的 🤷‍♂️

前段时间跟几个骑友聊天,都在吐槽这事。有人推荐了几十条"入门级"路线,结果一去发现全是铺装路面——拜托,我们要的是能撒野的路线好吗!也有人分享 GPX 文件,但得一个个下载导入,麻烦得很。

所以我干脆自己搞了个东西:ADV Moto Hub,一个带 AI 推荐的摩托车路线社区。

先上效果

👉 在线体验: adv-moto-hub.vercel.app
👉 开源仓库: github.com/ava-agent/a…

打开就能用,不需要注册。想体验 AI 推荐的话点击右下角的 🤖 机器人按钮,直接跟它说:

"我想找条有涉水路段的中级难度路线"

AI 会从现有路线里挑出最匹配的给你。挺有意思的,哈哈。

技术选型的一些纠结

刚开始其实挺纠结地图方案的。Google Maps 要 API Key,Mapbox 免费额度不够用,国内的高德百度又不太适合做这种小众工具。

最后选了 MapLibre GL JS + OpenStreetMap,完全免费,也不用申请 Key,部署到 Vercel 上直接就能跑。唯一的缺点是卫星图没那么清晰,不过对看路线来说够用。

后端直接用的 Supabase,省得自己搭服务器。认证、数据库、文件存储一套搞定,还有 Edge Function 可以跑 AI 逻辑。

// 路线数据的类型定义,算比较全了
interface Route {
  id: string;
  name: string;
  description: string;
  // GeoJSON LineString 存储轨迹
  geometry: {
    type: 'LineString';
    coordinates: [number, number][];
  };
  difficulty: 'beginner' | 'intermediate' | 'advanced' | 'expert';
  terrain: ('paved' | 'gravel' | 'dirt' | 'rock' | 'water' | 'sand')[];
  distance_km: number;
  elevation_gain_m: number;
  estimated_hours: number;
  gpx_file_url?: string;
  cover_image_url?: string;
  created_by: string;
  is_active: boolean;
  created_at: string;
}

GPX 解析踩过的坑

上传 GPX 文件解析轨迹这事,看起来简单,其实坑不少。

我最开始直接用浏览器原生的 DOMParser 解析 XML,结果有些 GPX 文件是 Garmin 导出的,命名空间写法不一样,解析出来是空的。搞了半天才发现有些文件用的是 xmlns="http://www.topografix.com/GPX/1/1",有些又是 xmlns="http://www.topografix.com/GPX/1/0"

最后写了这么个兼容版本:

// services/gpxParser.ts
export function parseGPX(gpxContent: string): ParsedRoute {
  const parser = new DOMParser();
  const xmlDoc = parser.parseFromString(gpxContent, 'text/xml');
  
  // 处理命名空间兼容性问题
  const nsResolver = (prefix: string | null) => {
    const ns = {
      gpx: 'http://www.topografix.com/GPX/1/1',
      gpx10: 'http://www.topografix.com/GPX/1/0',
      '': ''  // 处理无命名空间的情况
    };
    return ns[prefix as keyof typeof ns] || '';
  };
  
  // 尝试多种方式获取轨迹点
  let trackPoints: NodeListOf<Element> | null = null;
  const queries = [
    '//gpx:trkpt',
    '//gpx10:trkpt', 
    '//trkpt',
    '//*[local-name()="trkpt"]'
  ];
  
  for (const query of queries) {
    try {
      trackPoints = xmlDoc.evaluate(
        query, 
        xmlDoc, 
        nsResolver, 
        XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
        null
      ) as unknown as NodeListOf<Element>;
      if (trackPoints && trackPoints.length > 0) break;
    } catch {
      continue;
    }
  }
  
  if (!trackPoints || trackPoints.length === 0) {
    throw new Error('GPX 文件中未找到轨迹点');
  }
  
  // 提取坐标并计算海拔
  const coordinates: [number, number][] = [];
  const elevations: number[] = [];
  
  for (let i = 0; i < trackPoints.length; i++) {
    const point = trackPoints[i];
    const lat = parseFloat(point.getAttribute('lat') || '0');
    const lon = parseFloat(point.getAttribute('lon') || '0');
    const ele = parseFloat(point.querySelector('ele')?.textContent || '0');
    
    coordinates.push([lon, lat]); // GeoJSON 是 [lng, lat]
    if (ele) elevations.push(ele);
  }
  
  // 计算总距离和爬升
  const distance = calculateTotalDistance(coordinates);
  const elevationGain = calculateElevationGain(elevations);
  
  return {
    coordinates,
    distanceKm: Math.round(distance * 10) / 10,
    elevationGainM: Math.round(elevationGain),
    pointCount: coordinates.length
  };
}

// 用 Haversine 公式计算两点距离
function calculateTotalDistance(coords: [number, number][]): number {
  let total = 0;
  const R = 6371; // 地球半径 km
  
  for (let i = 1; i < coords.length; i++) {
    const [lon1, lat1] = coords[i - 1];
    const [lon2, lat2] = coords[i];
    
    const dLat = (lat2 - lat1) * Math.PI / 180;
    const dLon = (lon2 - lon1) * Math.PI / 180;
    const a = Math.sin(dLat/2) ** 2 + 
              Math.cos(lat1 * Math.PI / 180) * 
              Math.cos(lat2 * Math.PI / 180) * 
              Math.sin(dLon/2) ** 2;
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
    total += R * c;
  }
  
  return total;
}

这个版本我测了 Garmin、Strava、两步路、奥维导出的 GPX,基本都能正常解析。可能还有一些小众软件导出的格式会有问题,不过目前够用。

AI 推荐是怎么做的

核心思路其实很简单:用户用自然语言描述需求,AI 分析后从数据库里筛选匹配的路线返回。

但有个问题——我不想让前端直接调用 Claude API,那样 API Key 会暴露。所以用 Supabase Edge Function 做了一层代理:

// supabase/functions/ai-route-recommend/index.ts
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';

serve(async (req) => {
  const { query, routes } = await req.json();
  
  // 简单清洗一下用户输入
  const cleanQuery = query.trim().slice(0, 200);
  
  const systemPrompt = `你是 ADV Moto Hub 的 AI 路线推荐助手。
用户会用自然语言描述他们想要的摩托车骑行路线。
你的任务是从给定的路线列表中,选出最匹配的 3-5 条路线。

判断维度包括:
1. 难度等级匹配(beginner/intermediate/advanced/expert)
2. 地形偏好(paved/gravel/dirt/rock/water/sand)
3. 距离和海拔要求
4. 关键词匹配(如"川藏"、"新手"、"挑战"等)

只返回路线的 ID 数组,不要解释。格式:["id1", "id2", "id3"]`;

  const response = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': Deno.env.get('ANTHROPIC_API_KEY') || '',
      'anthropic-version': '2023-06-01'
    },
    body: JSON.stringify({
      model: 'claude-3-haiku-20240307',
      max_tokens: 500,
      system: systemPrompt,
      messages: [{
        role: 'user',
        content: `用户需求: ${cleanQuery}\n\n可选路线:\n${JSON.stringify(routes, null, 2)}`
      }]
    })
  });
  
  const data = await response.json();
  
  // 解析 AI 返回的路线 ID
  try {
    const content = data.content?.[0]?.text || '[]';
    const matchedIds = JSON.parse(content.match(/\[[^\]]*\]/)?.[0] || '[]');
    
    return new Response(JSON.stringify({ matchedIds }), {
      headers: { 'Content-Type': 'application/json' }
    });
  } catch {
    // 降级:关键词匹配
    const keywords = cleanQuery.toLowerCase().split(/\s+/);
    const fallbackIds = routes
      .filter((r: any) => 
        keywords.some(k => 
          r.name.toLowerCase().includes(k) ||
          r.description?.toLowerCase().includes(k) ||
          r.terrain?.some((t: string) => t.toLowerCase().includes(k))
        )
      )
      .slice(0, 5)
      .map((r: any) => r.id);
    
    return new Response(JSON.stringify({ 
      matchedIds: fallbackIds,
      fallback: true 
    }), {
      headers: { 'Content-Type': 'application/json' }
    });
  }
});

用了 claude-3-haiku,便宜够用。如果用户没配 API Key,或者调用失败了,会自动降级到关键词匹配,不影响核心功能。

说实话,有些问题还没解决

目前这个项目是个人做着玩的,所以有些地方比较粗糙:

数据量是个问题。现在上面只有我自己上传的几条测试路线,像样的内容还不够。社区类产品最怕的就是冷启动,没内容就没用户,没用户就更没内容。

AI 推荐有时候挺离谱的。用户说"新手友好",它可能推荐一条 500 公里的路线,只是难度是 beginner —— 但新手哪能骑这么远?这个需要对 prompt 做更细致的调优。

图片存储成本高。Supabase 的免费额度只有 1GB,如果用户上传大量路线封面图和 GPX 文件,很快就不够用了。后面可能需要接入对象存储做分层。

移动端体验一般。虽然用了 Ant Design Mobile,但在一些安卓机上还是有点卡顿。React 19 的并发特性好像还没完全发挥出来。

如果你也想玩

整个项目开源,MIT 协议,随便折腾:

git clone https://github.com/ava-agent/adv-agent.git
cd adv-agent/adv-moto-web
npm install
npm run dev

数据库配置是可选的,不配置的话会走本地演示数据,一样能跑。

有个小彩蛋:我在代码里藏了条我自己骑过的路线,从成都到泸定的穿越线,风景挺不错,哈哈。

最后

这个项目其实一开始只是想解决我自己的需求,没想到做起来还挺有意思的。从 GPX 解析到 AI 推荐,每一步都踩了不少坑,但也学到了不少东西。

如果你也骑车,或者对 AI + 地理信息这种组合感兴趣,可以去仓库点个 ⭐,或者提个 issue 聊聊天。

对了,你们平时骑长途都用啥工具规划路线? 是两步路、奥维,还是直接靠路书?有没有啥特别难受的痛点?评论区聊聊 👇


项目还在持续迭代中,最近在想是不是加个组队骑行功能,能在地图上实时看到队友位置。不过得考虑隐私问题,还在纠结...