返回资讯中心
外部精选
软件工程
#开发者实时流

biwak.info 为 4.2 万个高山小屋和山峰生成永久短链接

来源标题:Stable URLs for 42,000 Huts and Peaks

biwak.info 为 OpenStreetMap 中 9429 个小屋和 33437 座山峰共 42866 个地点分配了简短可读的永久 URL。名称唯一的地点保留纯名称,重名地点依次附加地区、海拔和 OpenStreetMap ID,并用固定登记表确保链接永不指向其他地点。

Hacker News 最新lumpa2026/11/8 · 6 天前17 分钟阅读

以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。

biwak.info 上每一座小屋、每一个露营地、每一座山峰,是如何获得一个简短、易读且永不改变、形如 /p/matterhorn 这样的 URL 的:名称唯一的保留纯名称,重名的先加地区,再加海拔,最后加 OpenStreetMap id,而一份提交到代码库的注册表则确保一个 slug 永远不会被挪到另一个地方。

biwak.info 是我做的阿尔卑斯山区小屋、露营地和山峰地图。很长一段时间里,链接到某座小屋的唯一方式是 #Name 这样的哈希,它做精确名称匹配,名称不唯一时就退回搜索。我想要能分享、并且能在聊天里显示正常预览卡片的真实 URL:biwak.info/p/matterhorn。

数据来自 OpenStreetMap:9,429 座小屋和露营地,33,437 座有名称的山峰,共 42,866 个地点。把名称变成 URL 很容易。难的是很多地点重名,而且一个 URL 一旦被分享出去,就应该始终指向同一个地点,哪怕底层数据发生了变化。

第一步是普通的 slugify:德语变音先写出全拼,其余字符折叠为 ASCII、转小写,其他字符的每一段连续出现都变成单个连字符。

GERMAN_TRANSLIT = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss", "ẞ": "ss"}) def slugify(text): """German transliteration for umlauts and ß, then ASCII-fold, lowercase, everything else to single dashes.""" text = (text or "").translate(GERMAN_TRANSLIT) text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii").lower() return re.sub(r"[^a-z0-9]+", "-", text).strip("-")

于是 Oberaletschhütte 变成 oberaletschhuette,Štuhecov dom 变成 stuhecov-dom,Rifugio Città di Busto 变成 rifugio-citta-di-busto。42,866 个名称没有一个变成空串。

阿尔卑斯山的名称重复率很高。每个山谷都有自己的 Jagdhütte 和 Rosskopf。只靠名称的话,最常见的 slug 是:

在选择方案之前,我先测算了加上不同后缀后,还有多少地点会共用同一个 slug:

加上国家几乎没什么帮助:大多数重名都在同一个国家。加上地区大致能减少一半,但仅克恩顿州就还有 27 个地方叫 Jagdhütte。海拔能解决剩下的大部分问题。它没法解决全部,因为有 4,311 座小屋在 OpenStreetMap 里没有海拔。最后一行里最糟的情况是利古里亚的 casella:21 座同名且没有海拔的小屋。

所以不存在一个总是管用的后缀,而把所有这些都加到每个 URL 上,会让 /p/matterhorn 变成 /p/matterhorn-wallis-4478 那样。

我最终定下的规则是:一个地点获得能让它唯一的最短 slug,按以下顺序尝试:

在代码里,这是一个生成器,给定所有当前同名 slug 的地点,为一个地点依次产出候选:

def candidates(p, group): """Slugs for POI p in order of preference; group = all current POIs with the same name slug.""" base, region = p["base"], slugify(p["region"]) others = [o for o in group if o["id"] != p["id"]] if not others: yield base if not any(slugify(o["region"]) == region for o in others): yield f"{base}-{region}" if p["ele"] is not None and not any(slugify(o["region"]) == region and o["ele"] == p["ele"] for o in others): yield f"{base}-{region}-{p['ele']}" yield f"{base}-{region}-{p['id']}"

OpenStreetMap id 是最后的手段,因为它总是唯一的,但没人读得懂。按今天的数据,42,866 个地点是这样分布的:

地区必须是人们认得出来的东西。我把 Natural Earth 的 admin-1 边界裁剪到在阿尔卑斯山区有意义的单元:奥地利和德国的联邦州,瑞士的州,法国的省(萨瓦和上萨瓦比大区更有名),意大利的大区并把南蒂罗尔和特伦蒂诺拆开,以及斯洛文尼亚的统计区。名称在通行的情况下用德语形式(Wallis、Tessin、Aostatal),否则用当地名称。地图上的地点落在这 63 个地区中。

每个地点通过 Shapely 的点在多边形内查询来获得地区。边界是简化过的,所以正好在边界或湖岸上的小屋可能落在所有多边形之外。这时取最近的多边形:

def lookup(self, lat, lon): """(cc, region name); points just outside every polygon (coast, simplified border) take the nearest.""" pt = Point(lon, lat) for i in self.tree.query(pt): if self.geoms[i].contains(pt): break else: i = self.tree.nearest(pt) return self.props[i]["cc"], self.props[i]["name"]

地区也会显示在预览卡片和详情页上,紧挨着类型:「Biwak · Haute-Savoie, Frankreich」。

上面的方案依赖于数据。如果明天有人在 OpenStreetMap 里添加第二座 Matterhorn,纯规则会把真的那座改名为 matterhorn-wallis,此前分享出去的所有链接就都断了。

所以构建过程维护一份 slug 注册表,data/slug-registry.json,它被提交到 git。它把曾经发放过的每个 slug 映射到 OpenStreetMap id 和名称:

"matterhorn": ["n26863664", "Matterhorn"], "oberaletschhuette": ["n11262553552", "Oberaletschhütte"],

分配随后遵循三条规则:

primary = {} for p in pois: fitting = [s for s in by_id.get(p["id"], []) if s == p["base"] or s.startswith(p["base"] + "-")] if fitting: primary[p["id"]] = min(fitting, key=lambda s: (len(s), s)) for base in sorted(groups): for p in groups[base]: if p["id"] in primary: continue slug = next(s for s in candidates(p, groups[base]) if s not in registry) registry[slug] = [p["id"], p["name"]] primary[p["id"]] = slug

从 OpenStreetMap 消失的地点会保留它的 slug。打开它时会启动对旧名称的搜索,而不是显示别人的小屋。

因为注册表是一个排序的 JSON 文件,每行一个 slug,改动 slug 的构建会表现为可读的 git diff。这让一个错误很容易被发现:第一版把 ü 折叠成 u,所以 Oberaletschhütte 是 /p/oberaletschhutte。上线两周后我改用 ue,结果改动了 7,182 个 slug。我从头重建了注册表,所以旧 slug 都不存在了。

(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →