这两天在编辑「关于」页面,内容有些单薄,考虑填充个人的一些活动在里面。作为一个懒人,很惭愧,值得分享给大家的活动并不多,骑行算是其中一种,于是就想着怎么把 Strava 的数据展示出来。如果你也是 Strava 用户,希望能有所启发。

本人基本上属于菜鸟水平,以下的操作参考了 Strava 的官方开发者文档,并通过 ChatGPT 辅助完成。
第一步:注册 Strava API
所有操作的前提是已经有一个 Strava 账号,按以下步骤开始:
-
创建一个 Strava 应用:Strava Developer Portal。需要注意的是最后一项「域」,在您的网站正式上线前,此项可以填
localhost
以便测试,上线后再改为正式域名即可; -
获取相关数据。
-
将相关数据记录在 Astro 的
.env
环境变量中,如下图所示。如果项目文件夹根目录没有.env
文件,请自行创建。STRAVA_CLIENT_ID = 123456 // 换成你的客户 ID STRAVA_CLIENT_SECRET = abcd123456 // 换成你的客户密钥
第二步:获取 Access Token
Strava 使用 OAuth 2.0,需要用 code 换取 access token 和 refresh token 才能获得所有 API 的授权,否则就只能通过 athelet.get()
获取基本的个人信息。
首先,我们检查一下 astro.config.mjs
是否正常设置,请确认其中包含了:
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'server', // 请确认是否有该项设置
});
如果没有设置 output: 'server'
,就无法获取 callback 网址中的 query 参数,也就没法正常换取 access token。
然后在 .env
文件中添加新的变量:
STRAVA_CLIENT_ID = 123456 // 换成你的客户 ID
STRAVA_CLIENT_SECRET = abcd123456 // 换成你的客户密钥
STRAVA_REDIRECT_URI = "http://localhost:4321/api/strava-callback" // callback 网址
PUBLIC_BASE_URL = "http://localhost:4321" // 绝对地址,防止相对路径导致失败
准备工作完成,下面是相关的文件结构:
src/
└── strava-token.json // 用来保存换取的 Token
└── pages/
└── strava.astro // Strava 展示界面
└── api/
└── strava.ts // 获取最近一次活动
└── strava-auth.ts // 一键授权跳转
└── strava-callback.ts // 授权并保存 Token
strava-auth.ts
代码:
// src/pages/api/strava-auth.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async () => {
const params = new URLSearchParams({
client_id: import.meta.env.STRAVA_CLIENT_ID,
response_type: 'code',
redirect_uri: import.meta.env.STRAVA_REDIRECT_URI,
scope: 'activity:read_all',
approval_prompt: 'force',
});
const authUrl = `https://www.strava.com/oauth/authorize?${params.toString()}`;
return Response.redirect(authUrl, 302);
};
strava-callback.ts
代码:
// src/pages/api/strava-callback.ts
import type { APIRoute } from 'astro';
import fs from 'fs/promises';
import path from 'path';
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const code = url.searchParams.get('code');
if (!code) {
return new Response('Missing code', { status: 400 });
}
const res = await fetch('https://www.strava.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: import.meta.env.STRAVA_CLIENT_ID,
client_secret: import.meta.env.STRAVA_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
}),
});
const data = await res.json();
// 保存到本地文件
const tokenFile = path.resolve('./strava-tokens.json');
await fs.writeFile(tokenFile, JSON.stringify({
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: data.expires_at,
}, null, 2));
return new Response('✅ Token 已保存,授权成功!');
};
此时访问 http://localhost:4321/api/strava-auth,就会跳出 Strava 的授权页面:

点击授权之后,应该能看到「Token 已保存,授权成功!」的消息,如果看到的是 missing code status: 400
,一般就是前文提及的原因,没有设置 output: 'server'
。
第三步:获取 Strava 数据
strava.ts
代码:
// src/pages/api/strava.ts
import type { APIRoute } from 'astro';
import fs from 'fs/promises';
import path from 'path';
const TOKEN_FILE = path.resolve('./strava-tokens.json');
async function readToken() {
const raw = await fs.readFile(TOKEN_FILE, 'utf-8');
return JSON.parse(raw);
}
async function saveToken(tokenData: any) {
await fs.writeFile(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
}
async function refreshToken(refresh_token: string) {
const res = await fetch('https://www.strava.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: import.meta.env.STRAVA_CLIENT_ID,
client_secret: import.meta.env.STRAVA_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token,
}),
});
const data = await res.json();
await saveToken(data);
return data.access_token;
}
export const GET: APIRoute = async () => {
const now = Math.floor(Date.now() / 1000);
let tokenData = await readToken();
if (tokenData.expires_at <= now) {
tokenData.access_token = await refreshToken(tokenData.refresh_token);
tokenData = await readToken();
}
const res = await fetch(`https://www.strava.com/api/v3/athlete/activities?per_page=20`, {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
});
const activities = await res.json();
// 找到第一个 type 为 Ride 的活动
const rideActivity = activities.find((a: any) => a.type === 'Ride');
if (!rideActivity) {
return new Response(JSON.stringify({ error: '没有找到骑行活动' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify(rideActivity), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
因为我只想获取最近的一次骑行,如果你想获取别的活动,比如跑步什么的,将这一行中的 Ride
改成 Run
就可以了,具体的筛选参数请参考 Strava API。
const rideActivity = activities.find((a: any) => a.type === 'Ride');
接着在 strava.astro
中展示相关数据就好了:
---
const baseUrl = import.meta.env.PUBLIC_BASE_URL || 'http://localhost:4321';
const res = await fetch(`${baseUrl}/api/strava`);
const activity = await res.json();
---
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>我的 Strava 最近活动</title>
</head>
<body>
<h1>最近一次 Strava 活动</h1>
{activity ? (
<div style="border:1px solid #ccc; padding:20px; max-width:400px;">
<h2>{activity.name}</h2>
<p>距离: {(activity.distance / 1000).toFixed(2)} 公里</p>
<p>时间: {Math.floor(activity.moving_time / 60)} 分钟</p>
<p>平均速度: {(activity.average_speed * 3.6).toFixed(1)} km/h</p>
<p>开始时间: {new Date(activity.start_date).toLocaleString()}</p>
</div>
) : (
<p>没有获取到活动数据。</p>
)}
</body>
</html>
如果你只是想展示数据,那到这一步也就足够了。但我觉得还不够,毕竟干巴巴的数据呈现出来没有什么感染力,必须得配图!所以就挖了后面这个深坑。
第四步:绘制骑行地图
很难想象 Strava 的主界面缺少了这些图像化的运动轨迹会多么干燥,现在咱们想要的不就是这个吗?

说干咱就干!在这里需要用到两个相关的库,PolyLine 和 LeafLet。
修改 strava.astro
如下:
---
const baseUrl = import.meta.env.PUBLIC_BASE_URL || 'http://localhost:4321';
const res = await fetch(`${baseUrl}/api/strava`);
const activity = await res.json();
const encoded = activity?.map?.summary_polyline || '';
// 传递 encoded 参数
const script = `<script>
const encoded = ${JSON.stringify(encoded)};
console.log(encoded);
</script>`;
---
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>我的骑行活动</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
/>
<style>
#map {
height: 400px;
max-width: 600px;
margin: 20px 0;
}
</style>
</head>
<body>
<h1>{activity.name}</h1>
<p>距离: {(activity.distance / 1000).toFixed(2)} 公里</p>
<div id="map"></div>
<div set:html={script}></div>
<script>
import L from "https://cdn.skypack.dev/leaflet";
import polyline from 'https://cdn.skypack.dev/@mapbox/polyline';
const coords = polyline.decode(encoded);
const map = L.map("map");
// ✅ 使用 OpenStreetMap 瓦片图层
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "© OpenStreetMap contributors",
}).addTo(map);
const track = L.polyline(coords, {
color: "blue",
weight: 4,
}).addTo(map);
map.fitBounds(track.getBounds());
</script>
</body>
</html>
如果地图不能正常显示,需要仔细检查相关文件是否正确加载(强国局域网!开发者的究极噩梦)。如果出现 polyline is not defined
之类的报错信息,请尝试寻找可用的国内 CDN。
如果一切顺利,此时地图和轨迹应该显示正常,比如我稍作修饰之后,如下图所示:

但这里还是有不少问题:
- 地图太花了,对比 Strava App 的显示效果,差距明显。也可以参考苹果地图 App,信息减少,对比度降低;
- 默认轨迹显示为蓝色,令人无语,你们这些程序员都不修审美课的吗?
- 缩放控件在左上角,和我设置的小标签位置冲突,能不能挪到右下角?
参考 Leaflet 的中文文档,逐一修正如下:
修改默认轨迹的颜色
这个应该是最简单的,在上面的代码中找到这一段:
const track = L.polyline(coords, {
color: "blue",
weight: 4,
}).addTo(map);
其中的参数都可以定制,包括:
color: "blue"
轨迹的颜色,可设置为任意色值,我将其设为 Strava 的品牌色橙色;weight: 4
轨迹线条的粗细;opacity: 0.8
轨迹的透明度;dashArray: "10, 10"
虚线效果;lineJoin: "round"
线条拐弯的地方处理成圆角。
修改缩放控件的位置
先去掉默认的缩放控件,找到这行代码:
const map = L.map("map");
替换为:
const map = L.map("map", {
zoomControl: false,
});
然后再将缩放控件添加到左下角:
L.control.zoom({
position: 'bottomleft'
}).addTo(map);
位置就改好了。然后我们再用 CSS 设置一下样式,现在的阴影有点太重。
.leaflet-bar {
border: none !important;
}
.leaflet-bar a {
border-radius: 10% !important;
font-family: var(--font-sans);
color: var(--color-zinc-400) !important;
}
.leaflet-bar a:hover {
background-color: var(--color-zinc-700) !important;
color: white !important;
}
低对比度地图
想要做到只能替换地图的贴图,ChatGPT 提供的其中一个选项是 CartoDB Positron,将 OpenStreetMap 的相关代码替换为以下代码:
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
maxZoom: 19,
attribution:
'© <a href="https://carto.com/attributions">CARTO</a>, © OpenStreetMap contributors', //如果嫌字太多可以去掉
}).addTo(map);
最终效果展示
折腾了大半天,最终得到的效果是这样的,您也可以转到「关于页面」直接查看。

此外不得不感概 AI 编程真是太牛了,而且还能配合你找 BUG 解决问题,永远不会厌烦,似乎也没有知识盲点。ChatGPT 在 AI 里头编程或许都不算第一梯队,已经达到这么好用的程度,其他的真是不敢想。也许是时候考虑通过 AI 来搞一些个人项目?说干咱就干,先找个点子!