Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Find the language of an HTML page in a `content-language` `<meta>` that is not the first `<meta>` of the page (#356)
- Rewrite the `poster` attribute (e.g. of `<video>`) when rewriting HTML, like `src` and `href` (#339)
- Do not collapse consecutive slashes `//` into a single `/` when normalizing URLs into ZIM paths, in both Python and JS rewriting (#340)
- Resolve relative links against a page URL containing consecutive slashes `//` the way browsers do, instead of collapsing them like `urllib.parse.urljoin` does (#346)
- `Creator.finish()` no longer silently discards errors raised by libzim while finalizing the ZIM, so a failure during finalization is no longer reported as a successful run (openzim/warc2zim#462)

## [5.4.1] - 2026-07-31
Expand Down
23 changes: 23 additions & 0 deletions javascript/test/wombatUrlRewriting.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,29 @@ test('consecutiveSlashesAreKept', (t) => {
);
});

test('relativeLinkOnPageWithConsecutiveSlashesKeepsThem', (t) => {
// #346: a relative link must be resolved against the page's own URL the way a
// browser would, keeping consecutive slashes in that URL instead of collapsing
// them (uri-js's URI.resolve, unlike Python's urllib.parse.urljoin, already does
// this correctly)
const originalUrl = 'https://www.example.com/path1//resource1.html';
const currentUrl = t.context.prefix + 'www.example.com/path1//resource1.html';
t.is(
urlRewriteFunction(
currentUrl,
t.context.originalHost,
t.context.originalScheme,
originalUrl,
t.context.prefix,
'other.html',
undefined,
undefined,
undefined,
),
t.context.prefix + 'www.example.com/path1//other.html',
);
});

test('simpleContentEmptyPath', (t) => {
t.is(
urlRewriteFunction(
Expand Down
84 changes: 79 additions & 5 deletions src/zimscraperlib/rewriting/url_rewriting.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@
import re
from dataclasses import dataclass
from typing import ClassVar
from urllib.parse import quote, unquote, urljoin, urlsplit
from urllib.parse import (
quote,
unquote,
urlsplit,
urlunsplit,
uses_netloc,
uses_relative,
)

import idna

Expand All @@ -63,6 +70,73 @@ class AdditionalRule:
]


def _remove_dot_segments(path: str) -> str:
"""Remove '.' and '..' segments from a path, per RFC 3986 section 5.2.4

Unlike a naive implementation based on splitting the path on '/', this keeps
empty segments (i.e. consecutive slashes), since those are part of the path.

`path` is expected to always start with a '/'.
"""
output: list[str] = []
while path:
if path.startswith("../"):
path = path[3:]
elif path.startswith("./"):
path = path[2:]
elif path.startswith("/./") or path == "/.":
path = "/" + path[3:]
elif path.startswith("/../") or path == "/..":
path = "/" + path[4:]
if output:
output.pop()
elif path in (".", ".."):
path = ""
else:
end = path.find("/", 1)
segment, path = (path, "") if end == -1 else (path[:end], path[end:])
output.append(segment)
return "".join(output)


def _urljoin(base: str, url: str | None) -> str:
"""Join a base URL and a possibly relative URL, like `urllib.parse.urljoin`

Unlike `urllib.parse.urljoin`, empty path segments (i.e. consecutive slashes)
are not collapsed while merging a relative-path reference onto the base URL's
path, matching how browsers resolve such references (RFC 3986 section 5.3).
"""
if not base:
return url or ""
if not url:
return base

bscheme, bnetloc, bpath, bquery, _bfragment = urlsplit(base)
scheme, netloc, path, query, fragment = urlsplit(url, bscheme)

if scheme != bscheme or scheme not in uses_relative:
return url

if scheme in uses_netloc:
if netloc:
return urlunsplit((scheme, netloc, path, query, fragment))
netloc = bnetloc

if not path:
path = bpath
if not query:
query = bquery
return urlunsplit((scheme, netloc, path, query, fragment))

merged_path = (
path if path.startswith("/") else bpath.rpartition("/")[0] + "/" + path
)

return urlunsplit(
(scheme, netloc, _remove_dot_segments(merged_path), query, fragment)
)


class HttpUrl:
"""A utility class representing an HTTP url, usefull to pass this data around

Expand Down Expand Up @@ -193,8 +267,8 @@ def __init__(
def get_item_path(self, item_url: str, base_href: str | None) -> ZimPath:
"""Utility to transform an item URL into a ZimPath"""

item_absolute_url = urljoin(
urljoin(self.article_url.value, base_href), item_url
item_absolute_url = _urljoin(
_urljoin(self.article_url.value, base_href), item_url
)
return ArticleUrlRewriter.normalize(HttpUrl(item_absolute_url))

Expand All @@ -213,8 +287,8 @@ def __call__(
try:
item_url = item_url.strip()

item_absolute_url = urljoin(
urljoin(self.article_url.value, base_href), item_url
item_absolute_url = _urljoin(
_urljoin(self.article_url.value, base_href), item_url
)

# Make case of standalone fragments more straightforward
Expand Down
29 changes: 29 additions & 0 deletions tests/rewriting/test_url_rewriting.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,24 @@ def test_relative_url(
ZimPath("example.com/a.html"),
),
),
(
"https://example.com/x//y/page.html",
"other.html",
RewriteResult(
"https://example.com/x//y/other.html",
"other.html",
ZimPath("example.com/x//y/other.html"),
),
),
(
"https://example.com/x//y/page.html",
"../z.html",
RewriteResult(
"https://example.com/x//z.html",
"../z.html",
ZimPath("example.com/x//z.html"),
),
),
],
)
def test_relative_url_keeps_consecutive_slashes(
Expand Down Expand Up @@ -1075,6 +1093,17 @@ def test_no_rewrite_other_schemes(
),
id="base_on_absolute_url",
),
pytest.param(
"foo.html",
"kiwix.org/a/a//bar/foo.html",
"../a//bar/",
RewriteResult(
"https://kiwix.org/a/a//bar/foo.html",
"../a//bar/foo.html",
ZimPath("kiwix.org/a/a//bar/foo.html"),
),
id="base_with_consecutive_slashes",
),
],
)
def test_base_href(
Expand Down