翻GitHub找到了statistics-for-strava这个仓库,看着更新还挺频繁的,文档也非常完善,于是打算自己部署一下。

部署

  • 首先要去Strava的API页面新创建一个APP,Authorization Callback Domain要填写自己将来部署的网站,我的是strava.kyxie.me。创建APP成功后就会得到Client ID,Client Secret,Access Token和Refresh Token

  • 创建docker-compose.yml,根据自己的情况修改,我接入了cloudflared网络部署在了公网,宿主机使用8000端口

    services:
      app:
        image: robiningelbrecht/strava-statistics:latest
        container_name: strava
        restart: unless-stopped
        volumes:
          - ./config:/var/www/config/app
          - ./build:/var/www/build
          - ./storage/database:/var/www/storage/database
          - ./storage/files:/var/www/storage/files
        env_file: ./.env
        ports:
          - 8000:8080
        networks:
          - cloudflared
    
    networks:
      cloudflared:
        external: true
    
  • 创建.env,目前STRAVA_REFRESH_TOKEN可以先不填,因为容器启动后才会使用到

    # The client id of your Strava app.
    STRAVA_CLIENT_ID=YOUR_CLIENT_ID
    # The client secret of your Strava app.
    STRAVA_CLIENT_SECRET=YOUR_CLIENT_SECRET
    # You will need to obtain this token the first time you launch the app. 
    # Leave this unchanged for now until the app tells you otherwise.
    # Do not use the refresh token displayed on your Strava API settings page, it will not work.
    STRAVA_REFRESH_TOKEN=YOUR_REFRESH_TOKEN_OBTAINED_AFTER_AUTH_FLOW
    # Valid timezones can found under TZ Identifier column here: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
    TZ=America/Toronto
    
    # The UID and GID to create/own files managed by statistics-for-strava
    PUID=1000
    PGID=1000
    
  • 在反向代理添加域名,statistics-for-strava自动监听8080端口,我把容器名改为了strava

    Subdomain: strava
    Domain: your_domain
    Type: HTTP
    URL: strava:8080
    
  • 此时可以看到创建了一个文件夹config,进入文件夹后创建config.yaml文件,注意一定是config.yaml,不是config.yml

    general:
      # The URL on which the app will be hosted. This URL will be used in the manifest file. 
      # This will allow you to install the web app as a native app on your device.
      appUrl: 'http://localhost:8080/'
      # Optional subtitle to display in the navbar.
      # Useful for distinguishing between multiple instances of the app.
      # Leave empty to disable.
      appSubTitle: null
      # Optional, a link to your profile picture. Will be used to display in the nav bar and link to your Strava profile.
      # Any image can be used; a square format is recommended.
      # Leave empty to disable this feature.
      profilePictureUrl: null
      # Optional, full URL with ntfy topic included. This topic will be used to notify you when a new HTML build has run.
      # Leave empty to disable notifications.
      ntfyUrl: null
      athlete:
        # Your birthday. Needed to calculate heart rate zones.
        birthday: 'YYYY-MM-DD'
        # The formula used to calculate your max heart rate. The default is Fox (220 - age).
        # Allowed values: arena, astrand, fox, gellish, nes, tanaka (https://pmc.ncbi.nlm.nih.gov/articles/PMC7523886/table/t2-ijes-13-7-1242/)
        # Or you can set a fixed number for any given date range.  
        maxHeartRateFormula: 'fox'
        # If you're not sure about your zones, leave this unchanged — the defaults are sensible.
        heartRateZones:
          # Relative or absolute. 
          # Relative will treat the zone numbers as percentages based on your max heart rate, while absolute will treat them as actual heartbeats per minute.
          # This mode will apply to all heart rate zones you define.
          mode: relative
          # The default zones for all activities.
          default:
            zone1:
              from: 50
              to: 60
            zone2:
              from: 61
              to: 70
            zone3:
              from: 71
              to: 80
            zone4:
              from: 81
              to: 90
            zone5:
              from: 91
              to: null # Infinity and beyond.
          # 🔥 PRO tip: You can further refine your heart rate zones by specifying date ranges and sport types.
          #    Read more about the possibilities on https://statistics-for-strava-docs.robiningelbrecht.be/#/configuration/main-configuration?id=athlete-heart-rate-zones
        # maxHeartRateFormula:
        #    "2020-01-01": 198
        #    "2025-01-10": 193
        # History of weight (in kg or pounds, depending on appearance.unitSystem). Needed to calculate relative w/kg.
        # Make sure to replace the YYYY-MM-DD examples with your own weight history.
        # Read more about the weight history on https://statistics-for-strava-docs.robiningelbrecht.be/#/configuration/main-configuration?id=athlete-weight-history
        weightHistory:
          "YYYY-MM-DD": 100
        # Optional, history of FTP. Needed to calculate activity stress level.
        # ftpHistory
        #    "2024-10-03": 198
        #    "2025-01-10": 220
        #
        ftpHistory: []
    appearance:
      # Allowed options: en_US, fr_FR, it_IT, nl_BE, de_DE, pt_BR, pt_PT or zh_CN
      locale: 'en_US'
      # Allowed options: metric or imperial
      unitSystem: 'metric'
      # Time format to use when rendering the app
      # Allowed formats: 24 or 12 (includes AM and PM)
      timeFormat: 24
      # Date format to use when rendering the app
      # For valid PHP date formats: https://www.php.net/manual/en/datetime.format.php
      # If you don't know how to use these formats, leave this unchanged — the defaults are sensible.
      dateFormat:
        short: 'd-m-y' # This renders to 01-01-25
        normal: 'd-m-Y' # This renders to 01-01-2025
    import:
      # Strava API has rate limits (https://statistics-for-strava-docs.robiningelbrecht.be/#/troubleshooting/faq?id=why-does-it-take-so-long-to-import-my-data),
      # to make sure we don't hit the rate limit, we want to cap the number of new activities processed
      # per import. Considering there's a 1000 request per day limit and importing one new activity can
      # take up to 3 API calls, 250 should be a safe number.
      numberOfNewActivitiesToProcessPerImport: 250
      # Sport types to import. Leave empty to import all sport types
      # With this list you can also decide the order the sport types will be rendered in.
      # A full list of allowed options is available on https://statistics-for-strava-docs.robiningelbrecht.be/#/configuration/main-configuration?id=supported-sport-types 
      sportTypesToImport: []
      # Activity visibilities to import. Leave empty to import all visibilities
      # This list can be combined with sportTypesToImport.
      # Allowed values: ["everyone", "followers_only", "only_me"]
      activityVisibilitiesToImport: []
      # Optional, the date (YYYY-MM-DD) from which you want to start importing activities. 
      # Any activity recorded before this date, will not be imported.
      # This can be used if you want to skip the import of older activities. Leave empty to disable.
      skipActivitiesRecordedBefore: null
      # An array of activity ids to skip during import. 
      # This allows you to skip specific activities during import.
      # ["123456789", "987654321"]
      activitiesToSkipDuringImport: []
    metrics:
      # By default, the app calculates your Eddington score for Rides, Runs, and Walks.
      # Each category includes a list of sport types used in the calculation.
      # This setting lets you customize which sport types are grouped together and how the Eddington score is calculated.
      # If you're not familiar with the Eddington score, it's best to leave this as is for now and explore it once the app is running.
      # 🔥 PRO tip: it's possible to use the same sport type over multiple eddington numbers.
      eddington:
          # The label to be used for the tabs on the Eddington page.
        - label: 'Ride'
          # A boolean to indicate if this score should be displayed in the side navigation.
          # You can only enable two of these at the same time.
          showInNavBar: true
          # The sport types to include in the Eddington score for this tab.
          # Only sport types that belong to the same activity type (category) can be combined.
          # For a complete list of supported sport and activity types, visit: https://statistics-for-strava-docs.robiningelbrecht.be/#/configuration/main-configuration?id=supported-sport-types
          sportTypesToInclude: ['Ride', 'MountainBikeRide', 'GravelRide', 'VirtualRide']
        - label: 'Run'
          showInNavBar: true
          sportTypesToInclude: ['Run', 'TrailRun', 'VirtualRun']
        - label: 'Walk'
          showInNavBar: false
          sportTypesToInclude: ['Walk', 'Hike']
    zwift:
      # Optional, your Zwift level (1 - 100). Will be used to render your Zwift badge. Leave empty to disable this feature
      level: null
      # Optional, your Zwift racing score (0 - 1000). Will be used to add to your Zwift badge if zwift.level is filled out.
      racingScore: null
    
  • 启动容器

    docker compose up -d
    
  • 就得到了文档里说的要求你更新Refresh Token,登录成功后会告诉你你的refresh token,然后粘贴在.env中。如果返回了Bad Request,说明在创建APP的时候Callback Domain填错了

  • 之后再回到项目部署的域名,会让你导入数据,运行

    docker compose exec app bin/console app:strava:import-data
    

    由于Strava Rate Limit有限制,15min里最多Call 100个API,且一天做多2000 Call个API,且上传一条activity应该需要消耗3个API,因此这条命令是压着速度运行的。建议:

    • 使用screen,防止一不小心中断丢失console
    • 最开始在config.yaml中设置numberOfNewActivitiesToProcessPerImport为250,等上传完第一轮后,假设还有activities没上传完,隔天再设置为500,这样系统会从断点继续上传,等初始的activities全部上传完之后再重新调回250
  • 上传完之后构建Web页面

    docker compose exec app bin/console app:strava:build-files
    
  • 重启容器

    docker compose down
    docker compose up -d
    
  • 图表很漂亮,而且包含了很多Strava会员才有的功能

    Dashboard

    Heat Map

同步

上述操作步骤可以允许我们在内网和外网同时查看,如果希望每次骑完车都可以自动上传,上传后本地自动拉取,可以使用宿主机的crontab,官网文档有教程:Statistics for Strava | Scheduling

也可以使用Strava的Webhook,我自己写了个小工具:GitHub - Kyxie/strava-webhook,本地起一个服务,Strava向本地发送请求的时候转发给同网络的Strava容器,监督它自动更新。

  • 首先修改.env,添加一些字段

    # The client id of your Strava app.
    STRAVA_CLIENT_ID=YOUR_CLIENT_ID
    # The client secret of your Strava app.
    STRAVA_CLIENT_SECRET=YOUR_CLIENT_SECRET
    # You will need to obtain this token the first time you launch the app. 
    # Leave this unchanged for now until the app tells you otherwise.
    # Do not use the refresh token displayed on your Strava API settings page, it will not work.
    STRAVA_REFRESH_TOKEN=YOUR_REFRESH_TOKEN_OBTAINED_AFTER_AUTH_FLOW
    # Valid timezones can found under TZ Identifier column here: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
    TZ=America/Toronto
    
    # The UID and GID to create/own files managed by statistics-for-strava
    PUID=1000
    PGID=1000
    
    # Webhook
    STRAVA_VERIFY_TOKEN=YOUR_VERIFY_TOKEN
    STRAVA_CALLBACK_URL=YOUR_STRAVA_CALLBACK_URL
    
  • 生成一个verify token,把这个token保存到.env

    openssl rand -hex 16
    
  • 这个STRAVA_CALLBACK_URL要部署的url + /webhook,假设我项目部署在https://strava.kyxie.me,这里我就填https://strava.kyxie.me/webhook

  • 修改docker-compose.yml,把webhook服务添加进来

    services:
      app:
        image: robiningelbrecht/strava-statistics:latest
        container_name: strava
        restart: unless-stopped
        volumes:
          - ./config:/var/www/config/app
          - ./build:/var/www/build
          - ./storage/database:/var/www/storage/database
          - ./storage/files:/var/www/storage/files
        env_file: ./.env
        ports:
          - 8000:8080
        networks:
          - cloudflared
    
      # 新添加
      webhook:
        image: kyxie/strava-webhook:latest
        container_name: strava-webhook
        restart: unless-stopped
        env_file: ./.env
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock
        ports:
          - 8001:8001
        networks:
          - cloudflared
    
    networks:
      cloudflared:
        external: true
    
  • 需要把Cloudflare的Bot Fight Mode给关掉,位于Cloudflare → Security → Bots → Bot Fight Mode,关掉之后建议再加上一些WAF的防护措施

  • 这个小工具还可以管理Strava Webhook的注册情况,在miniPC上运行下面代码就是注册Webhook

    curl -X POST http://localhost:8001/subscription/register
    
    • 同样地,取消订阅Webhook

      curl -X POST http://localhost:8001/subscription/unregister
      
    • 查看状态

      curl http://localhost:8001/subscription/status
      
  • 由于必须要暴露/webhook接口,最好再在Cloudflare WAF上加点限流保护

  • 部署好之后有了新的Activities就可以自动更新了

隐私

然而这样直接部署到公网我觉得太暴露隐私了,所有人都能看到你所有的运动记录,代码作者似乎觉得这个系统只能一个人用,因此并没有做登录系统,好在我们可以用Nginx来解决。

Nginx

  • 创建nginx文件夹,创建docker-compose.yml

    mkdir nginx
    cd nginx
    touch docker-compose.yml
    
  • 生成一个密码,用于nginx的basic auth

    openssl passwd -apr1 "your_password"
    echo '<your_username>:<output_password>' > .htpasswd
    
  • 编辑docker-compose.yml,让nginx也接入cloudflared网络,宿主机使用8080端口

    services:
      nginx:
        image: nginx:alpine
        container_name: nginx
        restart: unless-stopped
        ports:
          - "8080:80"
        volumes:
          - ./conf.d:/etc/nginx/conf.d:ro
          - ./.htpasswd:/etc/nginx/.htpasswd
        networks:
          - cloudflared
    
    networks:
      cloudflared:
        external: true
    
  • 创建文件夹conf.d,进入文件夹然后创建strava.conf

    mkdir -p conf.d
    cd conf.d
    touch strava.conf
    
  • 编辑strava.conf,由于都在同一个Docker网络下,只需要写容器名即可,且应该使用容器内的端口号,我们暴露图片和webhook的路径,以及给根目录加上basic auth

    server {
        listen 80;
        server_name strava.kyxie.me;
    
        location = /files/strava-badge.svg {
            proxy_pass http://strava:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
    
        location = /files/rides-this-week.svg {
            proxy_pass http://strava:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
    
        location = /files/monthly-distance.svg {
            proxy_pass http://strava:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
    
        location = /webhook {
            proxy_pass http://strava-webhook:8001;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
    
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
    
        location /files/ {
            return 403;
        }
    
        location / {
            auth_basic "Restricted Area";
            auth_basic_user_file /etc/nginx/.htpasswd;
            proxy_pass http://strava:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
    }
    
  • 去cloudflared上修改

    Subdomain: strava
    Domain: your_domain
    Type: HTTP
    URL: nginx:80
    
  • 这样就可以既能公网访问图片,就禁止别人看我的所有训练活动了,如果自己想看的话可以登录密码

  • 其他的用户登录控制见:为部署的服务添加用户登录 | Kunyang’s Blog

小工具

添加体重

  • 写了个脚本使用命令行添加体重,如果不指定日期就是默认为今天

    #!/bin/bash
    
    # 使用方法: sudo ./add-weight.sh 82 [2025-07-02]
    # 如果不指定日期,则默认使用今天
    
    set -e
    
    # 参数
    WEIGHT="$1"
    DATE="${2:-$(date +%F)}"
    
    # 空值检查
    if [ -z "$WEIGHT" ]; then
      echo "错误:必须提供体重数值"
      echo "用法: ./add-weight.sh <体重> [日期,如 2025-07-02]"
      exit 1
    fi
    
    # YAML 路径
    CONFIG_FILE="config/config.yaml"
    
    # 插入体重记录
    awk -v date="$DATE" -v weight="$WEIGHT" '
      BEGIN { inserted = 0 }
      {
        print $0
        if ($0 ~ /^ *weightHistory:/ && inserted == 0) {
          inserted = 1
          print "      \"" date "\": " weight
        }
      }
    ' "$CONFIG_FILE" > tmp.yaml && mv tmp.yaml "$CONFIG_FILE"
    
    echo "已插入体重记录 $DATE: $WEIGHT kg"
    
  • 更新权限

    chmod +x add-weight.sh