URL Normalization¶
Canonical URLs are built in the Rust core, not in your framework. This keeps one source of truth: no adapter re-implements trailing slash logic, and Python and JavaScript agree byte for byte.
The pipeline¶
normalize_public_url runs these steps:
- Parse the input URL or path.
- Extract the base path from
public_base_url. - Prepend the base path when it is not already present.
- Normalize the path (see below).
- Filter query parameters: strip tracking, keep the allowlist.
- Build
scheme://canonical_host/normalized_path?filtered_query.
Path rules¶
Controlled by URLPolicy:
| Rule | Default | Effect |
|---|---|---|
| Ensure leading slash | always | /blog and blog both become /blog |
| Collapse duplicate slashes | True |
/a//b becomes /a/b |
| Lowercase | False |
Path case is preserved; /Products/iPhone stays as written. Set lowercase_paths=True to lowercase |
| Trailing slash | "never" |
/blog/ becomes /blog; use "always" or "preserve" to change |
| Enforce HTTPS | True |
http:// becomes https:// |
Fragments are always dropped: a canonical URL never carries one, whether the input is an absolute URL or a relative path.
Query filtering¶
Tracking parameters are stripped using the absorbed detrack engine. The
built-in list is deliberately narrow: UTM parameters, vendor click ids
(fbclid, gclid, msclkid, ...), and unambiguous session identifiers.
Names that applications commonly use for real content (ref, source,
tag, keyword, campaign, redirect, next, timestamp, ...) are kept
by default. Add site-specific names with extra_tracking_params.
Parameter keys are decoded before matching, so %75tm_source is recognized as
utm_source, while the emitted query preserves the original bytes, order, and
duplicates.
When allowed_query_params is non-empty, only those parameters survive, even
when one of them looks like a tracking parameter.
from easeo import SEOConfig, URLPolicy, SEOEntity, build_seo_payload
config = SEOConfig(
canonical_host="example.com",
public_base_url="https://example.com",
url_policy=URLPolicy(allowed_query_params=["page", "q"]),
)
payload = build_seo_payload(
SEOEntity(entity_type="search", title="Search"),
"/search?q=headphones&utm_source=ad&ref=x",
config,
)
print(payload.canonical)
# "https://example.com/search?q=headphones"
utm_source and ref are stripped; q is kept.
Host validation¶
canonical_host must be host-only. These all fail with ConfigurationError:
https://example.com (scheme)
example.com/path (path)
example.com:8080 (port)
example.com/#frag (fragment)
example.com. (trailing dot)
Sub-path deployments¶
If public_base_url includes a path, that path is used as the base:
config = SEOConfig(
canonical_host="example.com",
public_base_url="https://example.com/blog/",
)
payload = build_seo_payload(entity, "/post", config)
print(payload.canonical)
# "https://example.com/blog/post"
Raw helpers¶
| Python | JavaScript | Purpose |
|---|---|---|
normalize_path(path, policy) |
normalizePath(path, options) |
Normalize a path only |
normalize_public_url(url, config) |
normalizePublicUrl(url, config) |
Build a canonical URL |
clean_url(url) |
cleanUrl(url) |
Remove tracking params, return a report |
clean_query(query) |
cleanQuery(query) |
Clean a query string |
Recap¶
- Normalization lives in the core and is shared by every language.
URLPolicycontrols path rules; the allowlist controls query params.canonical_hostis validated to be host-only.