我爱用Bushnell ENGAGE 8x42双筒望远镜看鸟。我想记录一些经历,但我太懒了,懒得写下来。观鸟之旅刚开始时,我会把每次散步手动录入eBird应用。一开始挺有趣,后来就变成了作业,而作业和“亲近自然”的乐趣是相反的。

Birds.Quest就是我想去掉这份作业的尝试。打开GPS追踪器,出门走路,把手机塞进口袋。之后,一篇关于这次散步的文章就出现在我的博客上,替我写好了。

打开网易新闻 查看精彩图片

自动生成的文章,不是目击记录

自动生成的文章并不是在声称我看到了这些鸟。它更像是一种激励,让我再次出门散步。既然文章会公开,也许其他人也会有同样的感受。

系统会预测,在我那条确切路线上、那个季节里,任何人有可能遇到哪些鸟,然后写成“如果你走这条路,可能会看到什么”。我打开草稿,勾选我真正看到的鸟(如果有时间的话),然后点击重新生成。如果不喜欢文字,我就在并排编辑器里修改Markdown。目前,文章都是草稿,直到我发布为止。等我测试得足够充分,我计划以最小甚至零成本自动发布它们。

两条规则塑造了整个构建

位置永远不离开服务器。博客和LLM提示词中不包含任何坐标和地名。文章描述鸟类和季节,从不描述我在哪里。

预测不是目击。在我批准之前,一切都是草稿,物种列表可以编辑。

这是为我自己散步做的工具,但它是开源的。任何人都可以把它指向自己的Traccar服务器,获得同样轻松的博客体验。

一次真实路线上的真实运行

我没能在第一次观鸟之旅前完成这个项目。不过,我把它设计成即使之前用Traccar跑过的记录,也可以之后上传并发布。

下面是一次真实路线上的真实运行。我从Traccar服务器导出了一条GPS轨迹,保存为KML文件:82个位置点,约10.4公里,时间在2026年10月10日至11日前后。我不公布坐标,理由和博客不公布坐标一样。

该路线和该周的前20名物种如下:

  • 翠鸟、白鹭和苍鹭,暗示附近有水。
  • 家燕和棕背伯劳,是十月中旬会出现的秋季候鸟。

这份列表有几处让我喜欢的地方。把“周”和“位置”一起喂给模型,正是关键所在:同一条路线在六月会给出不同的列表。

接下来,工作进程会从维基百科拉取每个物种的摘要和一张有许可协议的照片。然后Gemma只根据物种名称、这些维基百科事实和季节(“十月中旬,秋季”)来写文章。照片的署名和许可协议由我的代码添加,不是由模型添加。最后我打开/admin,取消勾选我实际没看到的鸟,然后重新生成。

我是怎么搭建的

记录环节:手机上的Traccar Client应用通过HTTPS把位置发送到自托管的Traccar服务器。

行程划分:工作进程用一个只读数据库用户读取Traccar的tc_positions表,把它切分成行程。10分钟的间隔或静止会结束一段行程,不足10分钟的行程被忽略,正在进行的行程永远不会被处理。

预测环节:BirdNET GeoModel(ONNX,FP16)通过onnxruntime-node在CPU上运行。它接收[纬度、经度、周数],返回每个物种的概率。我对路线进行采样,取每个物种的最大值,因为“可能在这条路线沿途任何地方被看到”才是我真正想要的。

事实环节:维基百科的REST API为每只鸟提供摘要和图片。没有许可信息的图片会被跳过,每张图片的许可协议和署名都会随图片一起存储。

写作环节:Gemma 4把物种、事实和季节变成一篇文章。

应用环节:Next.js 16(App Router)、TypeScript、MariaDB上的Drizzle、一个就是一张表的任务队列(FOR UPDATE SKIP LOCKED),以及一个带argon2登录、CSRF令牌、基于nonce的CSP和实时预览Markdown编辑器的管理后台。

测试环节:针对行程切分的单元测试(9分钟被忽略,11分钟被接受)、周数计算、队列、数据清洗,以及最重要的一点——没有任何坐标或地名进入提