折腾三个月,我把摩旅路线和 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 聊聊天。
对了,你们平时骑长途都用啥工具规划路线? 是两步路、奥维,还是直接靠路书?有没有啥特别难受的痛点?评论区聊聊 👇
项目还在持续迭代中,最近在想是不是加个组队骑行功能,能在地图上实时看到队友位置。不过得考虑隐私问题,还在纠结...