Cleaning OSM Website and Contact URL Tags Jump to heading
A URL tag looks like the easiest field in OSM to normalise and is not, because half the values are missing a scheme, a tenth are not URLs at all, and the obvious normalisation of lower-casing the whole string breaks every case-sensitive path.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Four transformations cover almost all of it, and one common instinct is wrong.
Supply a missing scheme. A value beginning with a host name rather than a scheme is the single most common shape, and prefixing a secure scheme makes it a URL. It is a guess, and it is the one guess in this area that is safe, because the alternative is a value no consumer can follow.
Normalise the host, not the path. Host names are case-insensitive and paths are not. Lower-casing the whole string is the obvious normalisation and it breaks every path on a case-sensitive server, which is most of them.
Handle international domains. A host with non-ASCII characters has an ASCII-compatible encoding, and storing both the display form and the encoded form serves different consumers.
Reject non-URLs structurally. Email addresses, phone numbers, social media handles and free text all appear in URL tags. A value with no host, or whose host has no dot, is not a URL, and saying so is more useful than storing it.
The instinct to avoid is verifying by fetching. It is slow, it fails for reasons unrelated to the data, it makes the pipeline non-deterministic, and it means your infrastructure visits every URL in the map — which is a privacy and etiquette problem as well as a technical one.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from urllib.parse import urlsplit, urlunsplit, quote
import idna
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.clean.url")
URL_KEYS = ("website", "contact:website", "url", "contact:url", "operator:website")
EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$")
HANDLE = re.compile(r"^@[\w.]+$")
SPLIT = re.compile(r"\s*[;\s]\s*(?=https?://|www\.)|\s*;\s*")
DEFAULT_SCHEME = "https"
@dataclass(frozen=True)
class Url:
normalised: str | None
display_host: str | None
original: str
key: str
status: str # 'ok' | 'scheme_added' | 'not_a_url' | 'email' | 'handle'
def looks_like_host(text: str) -> bool:
head = text.split("/", 1)[0]
# A host needs a dot and no whitespace. This is deliberately structural.
return "." in head and " " not in head and not head.endswith(".")
def normalise_one(raw: str, key: str) -> Url:
text = raw.strip()
if not text:
return Url(None, None, raw, key, "not_a_url")
if EMAIL.match(text):
# An email in a website tag is information, in the wrong field.
return Url(None, None, raw, key, "email")
if HANDLE.match(text):
return Url(None, None, raw, key, "handle")
added = False
if "://" not in text:
if not looks_like_host(text):
return Url(None, None, raw, key, "not_a_url")
text = f"{DEFAULT_SCHEME}://{text}"
added = True
parts = urlsplit(text)
if parts.scheme not in {"http", "https"} or not parts.netloc:
return Url(None, None, raw, key, "not_a_url")
host = parts.hostname or ""
if not looks_like_host(host):
return Url(None, None, raw, key, "not_a_url")
display_host = host
try:
# An international domain needs its ASCII-compatible form to resolve.
ascii_host = idna.encode(host, uts46=True).decode("ascii")
except idna.IDNAError:
ascii_host = host.lower()
netloc = ascii_host
if parts.port:
netloc = f"{netloc}:{parts.port}"
# Path, query and fragment keep their case: servers are case-sensitive.
path = quote(parts.path, safe="/%:@!$&'()*+,;=~-._")
normalised = urlunsplit((parts.scheme, netloc, path or "/",
parts.query, parts.fragment))
return Url(normalised, display_host, raw, key,
"scheme_added" if added else "ok")
def normalise_tags(tags: dict[str, str]) -> list[Url]:
out: list[Url] = []
for key in URL_KEYS:
value = tags.get(key)
if not value:
continue
for part in (p for p in SPLIT.split(value) if p.strip()):
out.append(normalise_one(part, key))
return out
def audit(results: list[Url]) -> dict[str, int]:
counts: dict[str, int] = {}
for url in results:
counts[url.status] = counts.get(url.status, 0) + 1
logger.info("url normalisation: %s", counts)
misplaced = counts.get("email", 0) + counts.get("handle", 0)
if misplaced:
logger.info("%d value(s) are contact details in a website field; "
"route them to the right key rather than discarding",
misplaced)
return counts
if __name__ == "__main__":
tags = {"website": "www.example.org/Menu; https://Café.example/Über",
"contact:website": "info@example.org"}
for url in normalise_tags(tags):
logger.info("%-18s %-14s %s", url.key, url.status,
url.normalised or url.original)
Step-by-step walkthrough Jump to heading
- Identify misplaced contact details first. An email address or a social handle in a website field is information in the wrong place, and naming it as such lets a pipeline move it rather than discard it.
- Test for a host structurally. A dot, no whitespace and no trailing dot is a cheap test that separates bare hosts from free text without pretending to validate a domain.
- Add the scheme only when the rest looks like a host. Prefixing a scheme onto arbitrary text produces a URL-shaped string that resolves to nothing.
- Restrict the accepted schemes. Anything other than the two web schemes in a website tag is either a mistake or something a consumer following links should not open.
- Encode international hosts, keep both forms. The encoded form is what resolves; the display form is what a human should see, and storing only one of them disappoints one audience.
- Leave the path alone apart from escaping. Paths are case-sensitive on most servers, and lower-casing them is the most common way a cleaning stage breaks working URLs.
- Report the misplaced counts. A high number of emails in website fields is a data-quality finding worth acting on upstream, not just a filtering statistic.
Verification Jump to heading
- Case-sensitive paths survive. A URL with mixed-case path segments must come through unchanged after the host.
- Bare hosts gain a scheme. A value beginning with a host name must produce a followable URL with the added-scheme status.
- Free text is rejected. A note or a sentence in a website field must not become a URL.
- International domains encode. A host with non-ASCII characters should yield both an encoded and a display form.
- Nothing is fetched. Watch the network during a run; a cleaning stage should make no requests at all.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Links return not-found | Whole string lower-cased | Normalise the host only; leave the path as it is |
| Free text became a URL | Scheme added without a host test | Require a dot and no whitespace before prefixing |
| International domains fail | Only the display form stored | Store the encoded form alongside it |
| Emails silently dropped | Non-URLs discarded rather than classified | Detect and label them so they can be relocated |
| Cleaning stage is slow | URLs fetched to verify | Validate structurally; never fetch |
| Some values never seen | Only the primary key read | Read every key that carries a URL |
| Query parameters lost | Query string stripped as noise | Preserve the query; it often identifies the page |
Specification reference Jump to heading
A URL’s host component is case-insensitive while its path, query and fragment are case-sensitive, and internationalised domain names have an ASCII-compatible encoding used for resolution. See RFC 3986 for the component definitions and case rules, and RFC 5891 for the internationalised domain encoding.
Frequently Asked Questions Jump to heading
Is adding a scheme a safe guess?
It is the one safe guess here, provided the rest of the value looks like a host. A bare host name is unambiguously intended as a web address, and prefixing a scheme makes it followable where the alternative is a value no consumer can use. What makes it safe is the host test: prefixing a scheme onto arbitrary text produces something URL-shaped that resolves to nothing, which is worse than rejecting it.
Why not lower-case the whole URL?
Because only the host is case-insensitive. Paths, query strings and fragments are case-sensitive on most servers, so lower-casing them turns working links into not-found errors. This is the most common way a URL cleaning stage does harm, and it is particularly insidious because the result still looks like a valid URL and fails only when somebody follows it.
Should I check that the URL resolves?
No, for four reasons that compound. It is slow, since every value becomes a network round trip. It is non-deterministic, since a site being down makes good data look bad. It makes the pipeline’s output depend on the moment it ran. And it means your infrastructure visits every URL in the map, which is a privacy and etiquette question as well as a technical one. Structural validation catches the values that are genuinely wrong.
What should happen to an email address in a website tag?
Label it rather than discarding it. It is real information filed under the wrong key, and a pipeline that recognises it can route it to the email field, flag it for a mapper, or simply record that it exists. Dropping it loses a contact detail somebody took the trouble to record; storing it as a website produces a link nothing can follow.
Related Jump to heading
- Value Standardization & Regex Cleaning — the parent topic and the wider cleaning stage.
- Normalizing OSM Phone Numbers to E.164 — the same discipline for the neighbouring contact keys.
- Splitting Semicolon-Separated OSM Tag Values — the multi-value handling this uses.
- Fixing Malformed OSM Tags During ETL Ingestion — the general pattern for values in the wrong field.
- Tag Taxonomy & Key-Value Standards — the contact namespace these keys belong to.
Up one level: Value Standardization & Regex Cleaning.