42,000 座小屋和山峰的稳定网址
Stable URLs for 42,000 Huts and Peaks

原始链接: https://peter-hoffmann.com/2026/stable-urls-for-huts-and-peaks.html

biwak.info 为 OpenStreetMap 中的 42,866 个阿尔卑斯山小屋、露营点和山峰创建可分享、可预览的 URL。名称会被 slug 化,但很多重复,因此每个地点得到最短的唯一 slug:纯名称、名称加区域、加海拔,最后加 OSM ID。区域来自简化的 Natural Earth 边界,边缘情况使用最近多边形回退。已提交的 slug 注册表保持共享链接稳定:现有 slug 不移动,地点保留合适的 slug,新地点获得未使用的候选,被移除的地点保留预留的 slug,重命名的地点从旧 slug 重定向。含糊的纯 slug 打开筛选搜索而非报错,以 ID 结尾的 slug 即使名称过时也能解析。仍存在权衡:458 个 URL 仍需 OSM ID,注册表只会增长,规则变更可能重命名许多 URL。相同的 URL 也直接打开山峰。

Hacker News 新帖 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 42,000 个小屋与山峰的稳定 URL ( peter-hoffmann.com ) 10 分 由 lumpa 1 小时前 | 隐藏 | 过去 | 收藏 | 讨论 帮助 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

biwak.info is my map of Alpine huts, bivouacs and peaks. For a long time the only way to link to a hut was a #Name hash that did an exact name match and fell back to the search when the name wasn't unique. I wanted real URLs I can share and that show a proper preview card in a chat: biwak.info/p/matterhorn.

The data comes from OpenStreetMap: 9,429 huts and bivouacs and 33,437 named peaks, 42,866 places in total. Turning a name into a URL is easy. The hard part is that many places share a name, and that a URL, once shared, should keep pointing to the same place, even when the data changes underneath.

The preview card for https://biwak.info/p/bivouac-des-periades

From a name to a slug

The first step is a plain slugify: German umlauts are written out, everything else is folded to ASCII, lowercased, and every run of other characters becomes a single dash.

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("-")

So Oberaletschhütte becomes oberaletschhuette, Štuhecov dom becomes stuhecov-dom and Rifugio Città di Busto becomes rifugio-citta-di-busto. None of the 42,866 names ends up empty.

Names are not unique

Alpine names repeat a lot. Every valley has its Jagdhütte and its Rosskopf. With the name alone, the most common slugs are:

Slug Places
jagdhuette 70
schwarzkogel 37
buchberg 32
rosskopf 32
mitterberg 31

Before choosing a scheme, I measured how many places would still share a slug with different suffixes:

Slug made of Shared slugs Places affected Worst
name 2,181 6,690 (15.6 %) 70
name + country 1,993 5,936 (13.8 %) 64
name + region 1,516 3,932 (9.2 %) 27
name + region + elevation 118 394 (0.9 %) 21

Adding the country barely helps: most duplicates are in the same country. The region roughly halves them, but there are still 27 places called Jagdhütte in Carinthia alone. Elevation fixes most of the rest. It can't fix everything, because 4,311 of the huts have no elevation in OpenStreetMap. The worst case in the last row is casella in Liguria: 21 small huts with the same name and no elevation.

So there is no single suffix that always works, and adding all of them to every URL would make /p/matterhorn look like /p/matterhorn-wallis-4478.

Add a suffix only while the name is shared

The rule I settled on: a place gets the shortest slug that is unique, trying these in order:

  1. the plain name: /p/matterhorn
  2. name and region: /p/oberaletschhuette stays plain, but a shared name becomes something like /p/rosskopf-tirol
  3. name, region and elevation: /p/rosskopf-tirol-2257
  4. name, region and OpenStreetMap id: /p/jagdhuette-kaernten-w1202594655

In code this is a generator that yields the candidates for one place, given all current places with the same name 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']}"

The OpenStreetMap id is the last resort because it's always unique, but nobody can read it. On today's data the 42,866 places split like this:

Slug Places
plain name 36,176
+ region 2,758
+ elevation 3,474
+ OpenStreetMap id 458

Regions from Natural Earth

The region has to be something people recognise. I cut the Natural Earth admin-1 boundaries down to the units that make sense in the Alps: the Bundesland in Austria and Germany, the Kanton in Switzerland, the département in France (Savoie and Haute-Savoie are better known than the big régions), the regione in Italy with South Tyrol and Trentino split, and the statistical regions in Slovenia. Names use the German form where that is common (Wallis, Tessin, Aostatal), otherwise the local one. The places on the map fall into 63 of these regions.

Each place gets its region by a point-in-polygon lookup with Shapely. The boundaries are simplified, so a hut right on a border or a lake shore can fall outside every polygon. Then the nearest polygon wins:

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"]

The region is also shown on the preview card and in the detail view, next to the type: "Biwak · Haute-Savoie, Frankreich".

Stable: a registry that never forgets

The scheme above depends on the data. If someone adds a second Matterhorn to OpenStreetMap tomorrow, the plain rule would rename the real one to matterhorn-wallis, and every link shared so far would break.

So the build keeps a slug registry, data/slug-registry.json, which is committed to git. It maps every slug ever handed out to the OpenStreetMap id and the name:

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

The assignment then follows three rules:

  • A slug in the registry never moves to another place.
  • A place keeps its registry slug as long as it still fits its current name, even if the plain rule would now give it a different one. When a second Matterhorn shows up, the old one stays matterhorn and only the newcomer gets a suffix.
  • A new or renamed place gets the first candidate that isn't taken yet. The old slug of a renamed place stays in the registry and redirects to the new one.
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

A place that disappears from OpenStreetMap keeps its slug reserved. Opening it starts a search for the old name instead of showing someone else's hut.

Because the registry is a sorted JSON file with one slug per line, a build that changes slugs shows up as a readable git diff. That made one mistake easy to spot: the first version folded ü to u, so the Oberaletschhütte was /p/oberaletschhutte. Two weeks after launch I switched to ue, which changed 7,182 slugs. I rebuilt the registry from scratch, so the old slugs are gone instead of redirecting.

Two fallbacks

A plain name that is shared, like /p/rosskopf, doesn't return an error. It opens the search with exactly the places of that name, with their type, elevation and region, and each of them links to its own slug. The same works one level down for /p/rosskopf-tirol.

A slug that ends in an OpenStreetMap id always resolves, even if the name part is wrong or outdated. /p/old-name-n11262553552 answers with a 301 to /p/oberaletschhuette.

Limits

  • Readable doesn't mean unique. 458 places end with an OpenStreetMap id in their slug. Better elevation data in OpenStreetMap would shrink that number, and the registry would still keep the old slugs working as redirects.
  • The registry only grows. It has one line per slug ever handed out, 42,866 today.
  • Changing the rules touches thousands of URLs. Any change to slugify or the regions renames a large part of the places, as the umlaut switch showed.

The same /p/ URLs also let the map open peaks directly, as described in Loading 33,000 Peaks on Demand with a z10 Tile Grid.

联系我们 contact @ memedata.com